diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5c7eab517..41e1b7bd2 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6 +1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a7a02a26..29f63b54b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ "--privileged", "-e", "GIT_EDITOR=code --wait" - // uncomment and edit the path in order to pass though local USB serial to the conatiner + // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], "appPort": 6052, diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index af54175c0..21393f2ab 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 7ac136515..e9e848da6 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; + message += + `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + + `this PR is on the large side `; + if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; + message += + `(${nonTestChanges} line changes excluding tests, across ` + + `${originalLabelCount} different components/areas)`; } else if (tooManyLabels) { - message += `This PR affects ${originalLabelCount} different components/areas.`; + message += + `(it touches ${originalLabelCount} different components/areas)`; } else { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; + message += `(${nonTestChanges} line changes excluding tests)`; } - message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; - message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; + message += `, which makes it harder for maintainers to review.\n\n`; + message += + `Smaller, focused PRs tend to be reviewed much faster since they ` + + `fit into the short gaps between other maintainer work; large ones ` + + `often have to wait for a rare long uninterrupted block of time. ` + + `If you can break this up into smaller pieces that can be reviewed ` + + `independently, it will almost certainly land faster overall.\n\n`; + message += + `Before putting more time in, it's also worth popping into ` + + `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + + `you scope things and flag anything already in flight.\n\n`; + message += + `For more details (including how to split the work up), see: ` + + `https://developers.esphome.io/contributing/submitting-your-work/` + + `#how-to-approach-large-submissions`; messages.push(message); } diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 27ddfe591..0e5ceb934 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -20,14 +20,14 @@ env: jobs: label: runs-on: ubuntu-latest - if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' + if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2240879bd..20c349ac0 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length @@ -159,7 +159,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -198,7 +198,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -231,7 +231,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -253,7 +253,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4 + uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0 with: run: ${{ steps.build.outputs.binary }} mode: simulation @@ -387,14 +387,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -466,14 +466,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -555,14 +555,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -817,7 +817,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -841,7 +841,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -883,7 +883,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -930,7 +930,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml new file mode 100644 index 000000000..1cd70f5ef --- /dev/null +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -0,0 +1,72 @@ +name: Close PR From Fork Default Branch + +on: + # pull_request_target is required so we have permission to comment and close PRs from forks. + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + +jobs: + close: + name: Close PR opened from fork's default branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + && github.event.pull_request.head.ref == github.event.repository.default_branch + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + const defaultBranch = context.payload.repository.default_branch; + const headRepo = context.payload.pull_request.head.repo.full_name; + + const body = [ + `Hi @${author}, thanks for opening a pull request! :tada:`, + ``, + `It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`, + ``, + `- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`, + `- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`, + `- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`, + `- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`, + ``, + `Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`, + ``, + `\`\`\`bash`, + `# Make sure your fork's ${defaultBranch} is up to date with upstream`, + `git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`, + `git fetch upstream`, + `git checkout ${defaultBranch}`, + `git reset --hard upstream/${defaultBranch}`, + `git push --force-with-lease origin ${defaultBranch}`, + ``, + `# Create a new branch for your change and cherry-pick / re-apply your commits there`, + `git checkout -b my-feature-branch upstream/${defaultBranch}`, + `# ...re-apply your changes, then:`, + `git push origin my-feature-branch`, + `\`\`\``, + ``, + `Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`, + ``, + `Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 67f4690ac..246a86569 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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 8806a8974..20f9a74ea 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,4 +8,4 @@ on: jobs: lock: - uses: esphome/workflows/.github/workflows/lock.yml@main + uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c92581b49..35b9e065e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -256,7 +256,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -287,7 +287,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac4f0049f..ad82bd8e5 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.15.10 + rev: v0.15.12 hooks: # Run the linter. - id: ruff @@ -58,6 +58,7 @@ repos: entry: python3 script/run-in-env.py pylint language: system types: [python] + files: ^esphome/.+\.py$ - id: clang-tidy-hash name: Update clang-tidy hash entry: python script/clang_tidy_hash.py --update-if-changed diff --git a/CODEOWNERS b/CODEOWNERS index 5b1ae65f1..20c19a7df 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81 esphome/components/audio_dac/* @kbx81 esphome/components/audio_file/* @kahrendt esphome/components/audio_file/media_source/* @kahrendt +esphome/components/audio_http/* @kahrendt esphome/components/axs15231/* @clydebarrow esphome/components/b_parasite/* @rbaron esphome/components/ballu/* @bazuchan @@ -403,6 +404,7 @@ esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow esphome/components/qwiic_pir/* @kahrendt +esphome/components/radio_frequency/* @kbx81 esphome/components/radon_eye_ble/* @jeffeb3 esphome/components/radon_eye_rd200/* @jeffeb3 esphome/components/rc522/* @glmnet @@ -438,6 +440,11 @@ esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/media_player/* @kahrendt +esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 @@ -599,6 +606,6 @@ esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 -esphome/components/zigbee/* @tomaszduda23 +esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zwave_proxy/* @kbx81 diff --git a/MANIFEST.in b/MANIFEST.in index ed65edc65..e426627e8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include requirements.txt recursive-include esphome *.yaml recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script +recursive-include esphome *.jinja recursive-include esphome LICENSE.txt diff --git a/esphome/__main__.py b/esphome/__main__.py index 25b404ae4..8c80dab90 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -39,6 +39,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -71,6 +72,7 @@ from esphome.util import ( run_external_process, safe_print, ) +from esphome.zeroconf import discover_mdns_devices _LOGGER = logging.getLogger(__name__) @@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: return [address] +def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None: + """Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``. + + Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so + the downstream resolution path (``resolve_ip_address``) can skip opening a + second Zeroconf client. + """ + from esphome.address_cache import AddressCache + + if CORE.address_cache is None: + CORE.address_cache = AddressCache() + for host, addresses in hosts_to_addresses.items(): + if addresses: + _LOGGER.debug("Caching mDNS result %s -> %s", host, addresses) + CORE.address_cache.add_mdns_addresses(host, addresses) + + +def _discover_mac_suffix_devices() -> list[str] | None: + """Discover ``-.local`` devices and cache their IPs. + + Returns: + - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, + mDNS disabled, or ``CORE.address`` is already an IP). Callers should + then fall back to whatever default OTA address they normally use. + - ``[]`` when discovery ran but found nothing. Callers should NOT fall + back to the base name: with ``name_add_mac_suffix`` enabled, the base + name by definition doesn't exist on the network. + - A non-empty sorted list of ``.local`` hostnames on success. + + Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or + ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we + already have without opening a second Zeroconf client. + """ + if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + return None + _LOGGER.info("Discovering devices...") + if not (discovered := discover_mdns_devices(CORE.name)): + _LOGGER.warning( + "No devices matching '%s-.local' were discovered.", CORE.name + ) + return [] + _populate_mdns_cache(discovered) + return list(discovered) + + +def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: + """Return OTA hostname(s) for the ``--device OTA`` / default-resolve path. + + When ``name_add_mac_suffix`` is enabled, returns discovered + ``-.local`` hostnames (possibly empty — in which case the + caller should not fall back to the base name). Otherwise falls back to + the cache-resolved ``CORE.address``. + """ + if (discovered := _discover_mac_suffix_devices()) is not None: + return discovered + return _resolve_with_cache(CORE.address, purpose) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -242,14 +302,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address() and has_resolvable_address(): - resolved.extend(_resolve_with_cache(CORE.address, purpose)) + resolved.extend(_ota_hostnames_for_default(purpose)) else: resolved.append(device) if not resolved: @@ -281,22 +341,29 @@ def choose_upload_log_host( elif bootsel.permission_error: bootsel_permission_error = True + def add_ota_options() -> None: + """Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled.""" + if (discovered := _discover_mac_suffix_devices()) is not None: + # Discovery was applicable. Use whatever we found — on empty, + # intentionally skip the base-name fallback since with + # name_add_mac_suffix on, the base name doesn't exist on the net. + for host in discovered: + options.append((f"Over The Air ({host})", host)) + elif has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + if purpose == Purpose.LOGGING: if has_mqtt_logging(): mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) if has_api(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): - if has_resolvable_address(): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if has_mqtt_ip_lookup(): - options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + add_ota_options() # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( @@ -407,7 +474,17 @@ def has_resolvable_address() -> bool: return not CORE.address.endswith(".local") -def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): +def has_name_add_mac_suffix() -> bool: + """Check if name_add_mac_suffix is enabled in the config.""" + if CORE.config is None: + return False + esphome_config = CORE.config.get(CONF_ESPHOME, {}) + return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False) + + +def mqtt_get_ip( + config: ConfigType, username: str, password: str, client_id: str +) -> list[str]: from esphome import mqtt return mqtt.get_esphome_device_ip(config, username, password, client_id) @@ -420,6 +497,9 @@ def _resolve_network_devices( This function filters the devices list to: - Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup + - Expand hostnames that are already in ``CORE.address_cache`` to their + cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second + Zeroconf client to resolve them - Deduplicate addresses while preserving order - Only resolve MQTT once even if multiple MQTT strings are present - If MQTT resolution fails, log a warning and continue with other devices @@ -444,13 +524,29 @@ def _resolve_network_devices( mqtt_ips = mqtt_get_ip( config, args.username, args.password, args.client_id ) - network_devices.extend(mqtt_ips) + # pylint can't infer mqtt_get_ip's return through its + # lazy ``from esphome import mqtt`` import, so it flags + # the genexpr below. + network_devices.extend( + addr + for addr in mqtt_ips # pylint: disable=not-an-iterable + if addr not in network_devices + ) except EsphomeError as err: _LOGGER.warning( "MQTT IP discovery failed (%s), will try other devices if available", err, ) mqtt_resolved = True + continue + + # If the hostname is already in the address cache (e.g. populated by + # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't + # open its own Zeroconf to re-resolve it. + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend( + addr for addr in cached if addr not in network_devices + ) elif device not in network_devices: # Regular network address or IP - add if not already present network_devices.append(device) @@ -750,8 +846,15 @@ def upload_using_esptool( platformio_api.FlashImage( path=idedata.firmware_bin_path, offset=firmware_offset ), - *idedata.extra_flash_images, ] + for image in idedata.extra_flash_images: + if not image.path.is_file(): + _LOGGER.warning( + "Skipping missing flash image declared by platform: %s", + image.path, + ) + continue + flash_images.append(image) mcu = "esp8266" if CORE.is_esp32: diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 7c20be90f..4fb368981 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -101,6 +101,17 @@ class AddressCache: """Check if any cache entries exist.""" return bool(self.mdns_cache or self.dns_cache) + def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None: + """Store resolved mDNS addresses for ``hostname`` in the cache. + + Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use + this to avoid a second resolution round-trip during the upload path. + No-op when ``addresses`` is empty. + """ + if not addresses: + return + self.mdns_cache[normalize_hostname(hostname)] = addresses + @classmethod def from_cli_args( cls, mdns_args: Iterable[str], dns_args: Iterable[str] diff --git a/esphome/async_thread.py b/esphome/async_thread.py new file mode 100644 index 000000000..7be3c83a9 --- /dev/null +++ b/esphome/async_thread.py @@ -0,0 +1,56 @@ +"""Helpers for running an async coroutine from sync code via a daemon thread. + +``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup +cycle finishes, which can add hundreds of milliseconds before the caller +receives the result. Running the loop in a daemon thread lets the caller +observe the result as soon as the coroutine completes while cleanup finishes +in the background. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import threading +from typing import Generic, TypeVar + +_T = TypeVar("_T") + + +class AsyncThreadRunner(threading.Thread, Generic[_T]): + """Run an async coroutine in a daemon thread and expose its result. + + The runner catches all exceptions from the coroutine and stores them in + ``exception`` so ``event`` is always set — this prevents callers waiting + on ``event`` from hanging forever when the coroutine crashes. + + Typical usage:: + + runner = AsyncThreadRunner(lambda: my_coro(arg)) + runner.start() + if not runner.event.wait(timeout=5.0): + ... # timed out + if runner.exception is not None: + raise runner.exception + result = runner.result + """ + + def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + super().__init__(daemon=True) + self._coro_factory = coro_factory + self.result: _T | None = None + self.exception: BaseException | None = None + self.event = threading.Event() + + async def _runner(self) -> None: + try: + self.result = await self._coro_factory() + except Exception as exc: # pylint: disable=broad-except + # Capture all exceptions so ``event`` is always set — otherwise a + # crash would hang the waiter forever. + self.exception = exc + finally: + self.event.set() + + def run(self) -> None: + asyncio.run(self._runner()) diff --git a/esphome/automation.py b/esphome/automation.py index b4dcc4199..97d9a0a47 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -199,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False): return cv.Schema([schema])(value) except cv.Invalid as err2: if "extra keys not allowed" in str(err2) and len(err2.path) == 2: - # pylint: disable=raise-missing-from - raise err + raise err from None if "Unable to find action" in str(err): - raise err2 - raise cv.MultipleInvalid([err, err2]) + raise err2 from None + raise cv.MultipleInvalid([err, err2]) from None elif isinstance(value, dict): if CONF_THEN in value: return [schema(value)] diff --git a/esphome/bundle.py b/esphome/bundle.py index b6816c7c9..efa80acc8 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -151,8 +151,8 @@ class ConfigBundleCreator: def __init__(self, config: dict[str, Any]) -> None: self._config = config - self._config_dir = CORE.config_dir - self._config_path = CORE.config_path + self._config_dir = Path(CORE.config_dir).resolve() + self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() @@ -258,21 +258,36 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - We track files by wrapping _load_yaml_internal. The config has already - been loaded at this point (bundle is a POST_CONFIG_ACTION), so we - re-load just to discover the file list. + Deliberately uses a fresh re-parse and force-loads every deferred + ``IncludeFile`` to include *all* potentially-reachable includes, + even branches not selected by the local substitutions. Bundles are + meant to be compiled on another system where command-line + substitution overrides may choose a different branch — e.g. + ``!include network/${eth_model}/config.yaml`` must ship every + candidate so the remote build can pick any one. + + Entries with unresolved substitution variables in the filename + path are skipped with a warning (they cannot be resolved without + the substitution pass). Secrets files are tracked separately so we can filter them to only include the keys this config actually references. """ + # Must be a fresh parse: IncludeFile.load() caches its result in + # _content, and we discover files by listening for loader calls. On + # an already-parsed tree the cache is populated, .load() returns + # without calling the loader, the listener never fires, and the + # referenced files would be silently dropped from the bundle. with yaml_util.track_yaml_loads() as loaded_files: try: - yaml_util.load_yaml(self._config_path) + data = yaml_util.load_yaml(self._config_path) except EsphomeError: _LOGGER.debug( "Bundle: re-loading YAML for include discovery failed, " "proceeding with partial file list" ) + else: + _force_load_include_files(data) for fpath in loaded_files: if fpath == self._config_path.resolve(): @@ -608,6 +623,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) +def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: + """Recursively resolve any ``IncludeFile`` instances in a YAML tree. + + Nested ``!include`` returns a deferred ``IncludeFile`` that is only + resolved during the substitution pass. During bundle discovery we need + the referenced files to actually load so the ``track_yaml_loads`` + listener fires for them. + + ``IncludeFile`` instances with unresolved substitution variables in the + filename cannot be loaded — we skip and warn about those. + """ + if _seen is None: + _seen = set() + + if isinstance(obj, yaml_util.IncludeFile): + if id(obj) in _seen: + return + _seen.add(id(obj)) + if obj.has_unresolved_expressions(): + _LOGGER.warning( + "Bundle: cannot resolve !include %s (referenced from %s) " + "with substitutions in path", + obj.file, + obj.parent_file, + ) + return + try: + loaded = obj.load() + except EsphomeError as err: + _LOGGER.warning( + "Bundle: failed to load !include %s (referenced from %s): %s", + obj.file, + obj.parent_file, + err, + ) + return + _force_load_include_files(loaded, _seen) + elif isinstance(obj, dict): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for value in obj.values(): + _force_load_include_files(value, _seen) + elif isinstance(obj, (list, tuple)): + if id(obj) in _seen: + return + _seen.add(id(obj)) + for item in obj: + _force_load_include_files(item, _seen) + + def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index f731a8c75..3e21d6981 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -190,7 +190,7 @@ void AcDimmer::setup() { this->zero_cross_pin_->setup(); this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr(); this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_, - gpio::INTERRUPT_FALLING_EDGE); + this->zero_cross_interrupt_type_); } #ifdef USE_ESP8266 @@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) { void AcDimmer::dump_config() { ESP_LOGCONFIG(TAG, "AcDimmer:\n" - " Min Power: %.1f%%\n" - " Init with half cycle: %s", + " Min Power: %.1f%%\n" + " Init with half cycle: %s", this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_)); LOG_PIN(" Output Pin: ", this->gate_pin_); LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_); - if (method_ == DIM_METHOD_LEADING_PULSE) { - ESP_LOGCONFIG(TAG, " Method: leading pulse"); - } else if (method_ == DIM_METHOD_LEADING) { - ESP_LOGCONFIG(TAG, " Method: leading"); + if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) { + ESP_LOGCONFIG(TAG, " Interrupt Type: rising"); + } else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) { + ESP_LOGCONFIG(TAG, " Interrupt Type: falling"); } else { - ESP_LOGCONFIG(TAG, " Method: trailing"); + ESP_LOGCONFIG(TAG, " Interrupt Type: any"); + } + if (method_ == DIM_METHOD_LEADING_PULSE) { + ESP_LOGCONFIG(TAG, " Method: leading pulse"); + } else if (method_ == DIM_METHOD_LEADING) { + ESP_LOGCONFIG(TAG, " Method: leading"); + } else { + ESP_LOGCONFIG(TAG, " Method: trailing"); } - LOG_FLOAT_OUTPUT(this); ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2); } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index ca2a19210..6bfcf0bdb 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component { void dump_config() override; void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; } void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; } + void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; } void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; } void set_method(DimMethod method) { method_ = method; } @@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component { InternalGPIOPin *gate_pin_; InternalGPIOPin *zero_cross_pin_; + gpio::InterruptType zero_cross_interrupt_type_; AcDimmerDataStore store_; bool init_with_half_cycle_; DimMethod method_; diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index efc24b65e..1f35095e0 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -7,6 +7,8 @@ from esphome.core import CORE CODEOWNERS = ["@glmnet"] +gpio_ns = cg.esphome_ns.namespace("gpio") + ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer") AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component) @@ -17,15 +19,26 @@ DIM_METHODS = { "TRAILING": DimMethod.DIM_METHOD_TRAILING, } +ZC_INTERRUPT_TYPES = { + "RISING": gpio_ns.INTERRUPT_RISING_EDGE, + "FALLING": gpio_ns.INTERRUPT_FALLING_EDGE, + "ANY": gpio_ns.INTERRUPT_ANY_EDGE, +} + CONF_GATE_PIN = "gate_pin" CONF_ZERO_CROSS_PIN = "zero_cross_pin" CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle" +CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type" + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(AcDimmer), cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum( + ZC_INTERRUPT_TYPES, upper=True, space="_" + ), cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean, cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum( DIM_METHODS, upper=True, space="_" @@ -54,5 +67,6 @@ async def to_code(config): cg.add(var.set_gate_pin(pin)) pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) cg.add(var.set_zero_cross_pin(pin)) + cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE])) cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bab2762f0..09e09f0dc 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,11 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32 import ( + get_esp32_variant, + include_builtin_idf_component, + require_adc_oneshot_iram, +) from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -24,6 +28,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType from . import ( ATTENUATION_MODES, @@ -65,6 +70,13 @@ def validate_config(config): return config +def _require_adc_iram(config: ConfigType) -> ConfigType: + """Register ADC oneshot IRAM requirement during config validation.""" + if CORE.is_esp32: + require_adc_oneshot_iram() + return config + + ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) @@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("60s")), validate_config, + _require_adc_iram, ) CONF_ADC_CHANNEL_ID = "adc_channel_id" diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index fef4f1d85..a14dd728a 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -2,6 +2,8 @@ #include #include +#include "esphome/core/alloc_helpers.h" + namespace esphome { namespace anova { @@ -105,14 +107,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) { } case READ_TARGET_TEMPERATURE: case SET_TARGET_TEMPERATURE: { - this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->target_temp_ = ftoc(this->target_temp_); this->has_target_temp_ = true; break; } case READ_CURRENT_TEMPERATURE: { - this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); + this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT if (this->fahrenheit_) this->current_temp_ = ftoc(this->current_temp_); this->has_current_temp_ = true; diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 84589d540..ad778f20a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -291,12 +291,12 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes - esp32=8, # 520KB RAM available + esp32=5, # 520KB RAM available rp2040=4, # 264KB RAM but LWIP constraints - bk72xx=8, # Moderate RAM - rtl87xx=8, # Moderate RAM + bk72xx=5, # Moderate RAM + rtl87xx=5, # Moderate RAM host=8, # Abundant resources - ln882x=8, # Moderate RAM + ln882x=5, # Moderate RAM ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -336,8 +336,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - if CONF_MAX_CONNECTIONS in config: - cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e4d0c2d16..c3e4c3863 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -671,6 +671,7 @@ message SensorStateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; + option (speed_optimized) = true; fixed32 key = 1 [(force) = true]; float state = 2; @@ -777,9 +778,10 @@ message SubscribeLogsResponse { option (source) = SOURCE_SERVER; option (log) = false; option (no_delay) = false; + option (speed_optimized) = true; - LogLevel level = 1; - bytes message = 3; + LogLevel level = 1 [(force) = true]; + bytes message = 3 [(force) = true]; } // ==================== NOISE ENCRYPTION ==================== @@ -1638,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } @@ -2541,27 +2544,50 @@ message ListEntitiesInfraredResponse { message InfraredRFTransmitRawTimingsRequest { option (id) = 136; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_IR_RF"; + option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; - fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance - uint32 carrier_frequency = 3; // Carrier frequency in Hz - uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.) + fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance + uint32 carrier_frequency = 3; // Carrier frequency in Hz + uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.) repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off) + uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities) } // Event message for received infrared/RF data message InfraredRFReceiveEvent { option (id) = 137; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_IR_RF"; + option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; option (no_delay) = true; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; - fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance + fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } +// ==================== RADIO FREQUENCY ==================== + +// Lists available radio frequency entity instances +message ListEntitiesRadioFrequencyResponse { + option (id) = 148; + option (base_class) = "InfoResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_RADIO_FREQUENCY"; + + string object_id = 1 [(max_data_length) = 120, (force) = true]; + fixed32 key = 2 [(force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + bool disabled_by_default = 5; + EntityCategory entity_category = 6; + uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; + uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver + uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified + uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified + uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported) +} + // ==================== SERIAL PROXY ==================== enum SerialProxyParity { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4663456da..b6f4aa214 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -49,6 +49,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::api { @@ -100,6 +103,12 @@ static const int CAMERA_STOP_STREAM = 5000; entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \ if ((entity_var) == nullptr) \ return; + +// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call(). +// Use when multiple entity types must be checked in sequence (at most one will match). +#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id) + #else // No device support, use simpler macros // Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call // object @@ -115,6 +124,12 @@ static const int CAMERA_STOP_STREAM = 5000; entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ if ((entity_var) == nullptr) \ return; + +// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call(). +// Use when multiple entity types must be checked in sequence (at most one will match). +#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \ + entity_type *entity_var = App.get_##getter_name##_by_key(msg.key) + #endif // USE_DEVICES APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent) { @@ -1471,19 +1486,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { - // TODO: When RF is implemented, add a field to the message to distinguish IR vs RF - // and dispatch to the appropriate entity type based on that field. + // Dispatch by key: infrared entities are checked first, then radio frequency entities. + // The key is unique across all entity instances on a device, so at most one lookup will succeed. #ifdef USE_INFRARED - ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared) - call.set_carrier_frequency(msg.carrier_frequency); - call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); - call.set_repeat_count(msg.repeat_count); - call.perform(); + ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared); + if (infrared != nullptr) { + auto call = infrared->make_call(); + call.set_carrier_frequency(msg.carrier_frequency); + call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.set_repeat_count(msg.repeat_count); + call.perform(); + return; + } +#endif +#ifdef USE_RADIO_FREQUENCY + ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency); + if (radio_frequency != nullptr) { + auto call = radio_frequency->make_call(); + call.set_frequency(msg.carrier_frequency); + call.set_modulation(static_cast(msg.modulation)); + call.set_repeat_count(msg.repeat_count); + call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.perform(); + } #endif } +#endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } #endif @@ -1580,6 +1612,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection } #endif +#ifdef USE_RADIO_FREQUENCY +uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, + uint32_t remaining_size) { + auto *rf = static_cast(entity); + ListEntitiesRadioFrequencyResponse msg; + msg.capabilities = rf->get_capability_flags(); + msg.frequency_min = rf->get_traits().get_frequency_min_hz(); + msg.frequency_max = rf->get_traits().get_frequency_max_hz(); + msg.supported_modulations = rf->get_traits().get_supported_modulations(); + return fill_and_encode_entity_info(rf, msg, conn, remaining_size); +} +#endif + #ifdef USE_UPDATE bool APIConnection::send_update_state(update::UpdateEntity *update) { return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE); @@ -2341,6 +2386,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item, #ifdef USE_INFRARED CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse) #endif +#ifdef USE_RADIO_FREQUENCY + CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse) +#endif #ifdef USE_EVENT CASE_INFO_ONLY(event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7d0879709..4165b7f3a 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,7 +223,7 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); #endif @@ -612,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_INFRARED static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size); #endif +#ifdef USE_RADIO_FREQUENCY + static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size); +#endif #ifdef USE_EVENT static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn, uint32_t remaining_size); diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index dacc290e3..d5d0b37e8 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions { optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; optional bool inline_encode = 1042 [default=false]; + optional bool speed_optimized = 1043 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c2d513f0d..3d1245393 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG #endif return pos; } -uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -912,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } return true; } -uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } -uint32_t SubscribeLogsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += this->level ? 2 : 0; - size += ProtoSize::calc_length(1, this->message_len_); + size += 2; + size += ProtoSize::calc_length_force(1, this->message_len_); return size; } #ifdef USE_API_NOISE @@ -2328,7 +2338,9 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -2350,7 +2362,9 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P } return pos; } -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; @@ -3847,7 +3861,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { return size; } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES @@ -3861,6 +3875,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto case 4: this->repeat_count = value; break; + case 6: + this->modulation = value; + break; default: return false; } @@ -3914,6 +3931,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { return size; } #endif +#ifdef USE_RADIO_FREQUENCY +uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +#ifdef USE_ENTITY_ICON + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); +#endif + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); +#ifdef USE_DEVICES + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); +#endif + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + return pos; +} +uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { + uint32_t size = 0; + size += 2 + this->object_id.size(); + size += 5; + size += 2 + this->name.size(); +#ifdef USE_ENTITY_ICON + size += !this->icon.empty() ? 2 + this->icon.size() : 0; +#endif + size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += this->entity_category ? 2 : 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, this->device_id); +#endif + size += ProtoSize::calc_uint32(1, this->capabilities); + size += ProtoSize::calc_uint32(1, this->frequency_min); + size += ProtoSize::calc_uint32(1, this->frequency_max); + size += ProtoSize::calc_uint32(1, this->supported_modulations); + return size; +} +#endif #ifdef USE_SERIAL_PROXY bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5827a8728..5aa592e4f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3054,11 +3054,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { protected: }; #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 136; - static constexpr uint8_t ESTIMATED_SIZE = 220; + static constexpr uint8_t ESTIMATED_SIZE = 224; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); } #endif @@ -3071,6 +3071,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { const uint8_t *timings_data_{nullptr}; uint16_t timings_length_{0}; uint16_t timings_count_{0}; + uint32_t modulation{0}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3101,6 +3102,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage { protected: }; #endif +#ifdef USE_RADIO_FREQUENCY +class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 148; + static constexpr uint8_t ESTIMATED_SIZE = 56; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); } +#endif + uint32_t capabilities{0}; + uint32_t frequency_min{0}; + uint32_t frequency_max{0}; + uint32_t supported_modulations{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif #ifdef USE_SERIAL_PROXY class SerialProxyConfigureRequest final : public ProtoDecodableMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 640c34737..bdcb6d414 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2576,7 +2576,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest")); #ifdef USE_DEVICES @@ -2591,6 +2591,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const out.append_p(ESPHOME_PSTR(" values, ")); append_uint(out, this->timings_length_); out.append_p(ESPHOME_PSTR(" bytes]\n")); + dump_field(out, ESPHOME_PSTR("modulation"), this->modulation); return out.c_str(); } const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { @@ -2605,6 +2606,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_RADIO_FREQUENCY +const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse")); + dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("name"), this->name); +#ifdef USE_ENTITY_ICON + dump_field(out, ESPHOME_PSTR("icon"), this->icon); +#endif + dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); + dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); +#ifdef USE_DEVICES + dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); +#endif + dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities); + dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min); + dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max); + dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations); + return out.c_str(); +} +#endif #ifdef USE_SERIAL_PROXY const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest")); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index b41233edd..6ae2a3e36 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -625,7 +625,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { InfraredRFTransmitRawTimingsRequest msg; msg.decode(msg_data, msg_size); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6ff988902..aca42ca30 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -211,7 +211,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d9c3cc684..c30bd2e61 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -118,7 +118,7 @@ void APIServer::loop() { this->accept_new_connections_(); } - if (this->clients_.empty()) { + if (this->api_connection_count_ == 0) { // 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) { @@ -135,15 +135,15 @@ void APIServer::loop() { // Check network connectivity once for all clients if (!network::is_connected()) { // Network is down - disconnect all clients - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->on_fatal_error(); client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect")); } // Continue to process and clean up the clients below } - size_t client_index = 0; - while (client_index < this->clients_.size()) { + uint8_t client_index = 0; + while (client_index < this->api_connection_count_) { auto &client = this->clients_[client_index]; // Common case: process active client @@ -161,7 +161,7 @@ void APIServer::loop() { } } -void APIServer::remove_client_(size_t client_index) { +void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -179,14 +179,17 @@ void APIServer::remove_client_(size_t client_index) { // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); - // Swap with the last element and pop (avoids expensive vector shifts) - if (client_index < this->clients_.size() - 1) { - std::swap(this->clients_[client_index], this->clients_.back()); + // Swap-and-reset: move the removed client to the trailing slot and null it out so slots + // [api_connection_count_, N) remain nullptr. + const uint8_t last_index = this->api_connection_count_ - 1; + if (client_index < last_index) { + std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_.pop_back(); + this->clients_[last_index].reset(); + this->api_connection_count_--; // Last client disconnected - set warning and start tracking for reboot timeout - if (this->clients_.empty() && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -210,8 +213,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->clients_.size() >= this->max_connections_) { - ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername); + if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); continue; @@ -220,11 +223,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); auto *conn = new APIConnection(std::move(sock), this); - this->clients_.emplace_back(conn); + this->clients_[this->api_connection_count_++].reset(conn); conn->start(); // First client connected - clear warning and update timestamp - if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -237,7 +240,7 @@ void APIServer::dump_config() { " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); + network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { @@ -255,7 +258,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {} void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \ if (obj->is_internal()) \ return; \ - for (auto &c : this->clients_) { \ + for (auto &c : this->active_clients()) { \ if (c->flags_.state_subscription) \ c->send_##entity_name##_state(obj); \ } \ @@ -337,7 +340,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) void APIServer::on_event(event::Event *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_event(obj); } @@ -349,7 +352,7 @@ void APIServer::on_event(event::Event *obj) { void APIServer::on_update(update::UpdateEntity *obj) { if (obj->is_internal()) return; - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (c->flags_.state_subscription) c->send_update_state(obj); } @@ -360,12 +363,12 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_message(msg); } #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { InfraredRFReceiveEvent resp{}; @@ -375,7 +378,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ resp.key = key; resp.timings = timings; - for (auto &c : this->clients_) + for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } #endif @@ -392,7 +395,7 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { client->send_homeassistant_action(call); } } @@ -532,7 +535,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString return; } ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; c->send_message(req); } @@ -583,7 +586,7 @@ bool APIServer::clear_noise_psk(bool make_active) { #ifdef USE_HOMEASSISTANT_TIME void APIServer::request_time() { - for (auto &client : this->clients_) { + for (auto &client : this->active_clients()) { if (!client->flags_.remove && client->is_authenticated()) { client->send_time_request(); return; // Only request from one client to avoid clock conflicts @@ -593,8 +596,8 @@ void APIServer::request_time() { #endif bool APIServer::is_connected_with_state_subscription() const { - for (const auto &client : this->clients_) { - if (client->flags_.state_subscription) { + for (uint8_t i = 0; i < this->api_connection_count_; i++) { + if (this->clients_[i]->flags_.state_subscription) { return true; } } @@ -609,7 +612,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size // we would be filling a buffer we are trying to clear return; } - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove && c->get_log_subscription_level() >= level) c->try_send_log_message(level, tag, message, message_len); } @@ -618,7 +621,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size #ifdef USE_CAMERA void APIServer::on_camera_image(const std::shared_ptr &image) { - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { if (!c->flags_.remove) c->set_camera_state(image); } @@ -635,7 +638,7 @@ void APIServer::on_shutdown() { this->batch_delay_ = 5; // Send disconnect requests to all connected clients - for (auto &c : this->clients_) { + for (auto &c : this->active_clients()) { DisconnectRequest req; if (!c->send_message(req)) { // If we can't send the disconnect request directly (tx_buffer full), @@ -653,7 +656,7 @@ bool APIServer::teardown() { this->loop(); // Return true only when all clients have been torn down - return this->clients_.empty(); + return this->api_connection_count_ == 0; } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 65076879a..e662d78eb 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -21,6 +21,8 @@ #include "esphome/components/camera/camera.h" #endif +#include +#include #include namespace esphome::api { @@ -63,7 +65,6 @@ class APIServer final : public Component, void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } - void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -182,13 +183,30 @@ class APIServer final : public Component, #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); #endif -#ifdef USE_IR_RF +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif - bool is_connected() const { return !this->clients_.empty(); } + bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect + // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // APIConnection but cannot reset/move the slot and break the count invariant. + using APIConnectionPtr = std::unique_ptr; + class ActiveClientsView { + const APIConnectionPtr *begin_; + const APIConnectionPtr *end_; + + public: + ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {} + const APIConnectionPtr *begin() const { return this->begin_; } + const APIConnectionPtr *end() const { return this->end_; } + }; + ActiveClientsView active_clients() const { + return {this->clients_.data(), this->clients_.data() + this->api_connection_count_}; + } + #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { const char *entity_id; // Pointer to flash (internal) or heap (external) @@ -234,8 +252,8 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); - // Remove a disconnected client by index. Swaps with last element and pops. - void __attribute__((noinline)) remove_client_(size_t client_index); + // Remove a disconnected client by index. Swaps with the last populated slot and resets it. + void __attribute__((noinline)) remove_client_(uint8_t client_index); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, @@ -273,8 +291,9 @@ class APIServer final : public Component, uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; + // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. + std::array, MAX_API_CONNECTIONS> clients_{}; // Vectors and strings (12 bytes each on 32-bit) - std::vector> clients_; // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which // reserves the exact needed size. Pre-allocating here would cause heap fragmentation @@ -309,10 +328,10 @@ class APIServer final : public Component, uint16_t port_{6053}; uint16_t batch_delay_{100}; // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults + // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; - uint8_t max_connections_{8}; bool shutting_down_ = false; + uint8_t api_connection_count_{0}; // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 0c6c569c7..312d937f0 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -93,7 +93,24 @@ async def async_run_logs( config, raw_line, backtrace_state=backtrace_state ) - stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states) + # Safe to fall back to plaintext here only for this diagnostics use + # case: the stream is one-way from device to client, and this code + # never accepts commands or acts on any message the device sends. + # An on-path attacker could still both inject fabricated log lines + # and passively read the device's log output (and any state data + # delivered when subscribe_states is enabled), so this does lose + # confidentiality as well as authentication/integrity. That tradeoff + # is acceptable for operator-visible logs, which aioesphomeapi also + # warns may come from an unverified device. Never mirror this opt-in + # for any connection that sends data to the device or uses Home + # Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + ) try: await asyncio.Event().wait() finally: diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index 0a94c1699..f9e645b50 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater #ifdef USE_INFRARED LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse) #endif +#ifdef USE_RADIO_FREQUENCY +LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse) +#endif #ifdef USE_EVENT LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 7d0eb5bb1..95c626feb 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *entity) override; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *entity) override; #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 9edf0f0f0..f20611e06 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -82,6 +82,9 @@ class InitialStateIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *infrared) override { return true; }; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; }; +#endif #ifdef USE_EVENT bool on_event(event::Event *event) override { return true; }; #endif diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index b141329e9..444306cec 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -83,7 +83,7 @@ def angle_to_position(value, min=-360, max=360): value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION except cv.Invalid as e: - raise cv.Invalid(f"When using angle, {e.error_message}") + raise cv.Invalid(f"When using angle, {e.error_message}") from e def percent_to_position(value): @@ -164,7 +164,7 @@ def has_valid_range_config(): except cv.Invalid as e: raise cv.Invalid( f"The range between start and end position is invalid. It was was {range} but {e.error_message}" - ) + ) from e return validator diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c44a11e3e..95154812c 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent, #endif float get_reference_voltage(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage + return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage #else return 120.0; // Default voltage #endif } float get_reference_current(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current + return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current #else return 5.0f; // Default current #endif diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 8f2102de6..fee582ca2 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,7 +1,11 @@ from dataclasses import dataclass import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component, include_builtin_idf_component +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + include_builtin_idf_component, +) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE from esphome.core import CORE @@ -27,6 +31,7 @@ class AudioData: flac_support: bool = False mp3_support: bool = False opus_support: bool = False + micro_decoder_support: bool = False def _get_data() -> AudioData: @@ -50,6 +55,11 @@ def request_opus_support() -> None: _get_data().opus_support = True +def request_micro_decoder_support() -> None: + """Request micro-decoder library support for audio decoding.""" + _get_data().micro_decoder_support = True + + CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample" CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample" CONF_MIN_CHANNELS = "min_channels" @@ -208,6 +218,19 @@ async def to_code(config): ) data = _get_data() + + if data.micro_decoder_support: + add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + + # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash + if not data.flac_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False) + if not data.mp3_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) + if not data.opus_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + + # Legacy audio_decoder.cpp support defines and components if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") add_idf_component(name="esphome/micro-flac", ref="0.1.1") diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 3ed6c1cd9..bb1ce257d 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -116,7 +116,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": diff --git a/esphome/components/audio_http/__init__.py b/esphome/components/audio_http/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp new file mode 100644 index 000000000..04b7d046e --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -0,0 +1,163 @@ +#include "audio_http_media_source.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include +#include + +#include + +namespace esphome::audio_http { + +static const char *const TAG = "audio_http_media_source"; + +// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers. +static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder +static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout +static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call +static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer +static constexpr uint8_t READER_TASK_PRIORITY = 2; +static constexpr uint8_t DECODER_TASK_PRIORITY = 2; +static constexpr size_t READER_TASK_STACK_SIZE = 4096; +static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; +static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20; +static constexpr const char *const HTTP_URI_PREFIX = "http://"; +static constexpr const char *const HTTPS_URI_PREFIX = "https://"; + +void AudioHTTPMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, + "Audio HTTP Media Source:\n" + " Buffer Size: %zu bytes\n" + " Decoder Task Stack in PSRAM: %s", + this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_)); +} + +void AudioHTTPMediaSource::setup() { + this->disable_loop(); + + micro_decoder::DecoderConfig config; + config.ring_buffer_size = this->buffer_size_; + // Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring + // while the decoder is still draining it, instead of oscillating between empty and full. + config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2); + config.http_timeout_ms = HTTP_TIMEOUT_MS; + config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS; + config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS; + config.reader_priority = READER_TASK_PRIORITY; + config.decoder_priority = DECODER_TASK_PRIORITY; + config.reader_stack_size = READER_TASK_STACK_SIZE; + config.decoder_stack_size = DECODER_TASK_STACK_SIZE; + config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; + + this->decoder_ = std::make_unique(config); + if (this->decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate decoder"); + this->mark_failed(); + return; + } + this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener +} + +void AudioHTTPMediaSource::loop() { this->decoder_->loop(); } + +bool AudioHTTPMediaSource::can_handle(const std::string &uri) const { + return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX); +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +bool AudioHTTPMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) { + return false; + } + + // Check if source is already playing + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + // Validate URI starts with "http://" or "https://" + if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + if (this->decoder_->play_url(uri)) { + this->pause_.store(false, std::memory_order_relaxed); + this->enable_loop(); + return true; + } + + ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str()); + return false; +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: + this->decoder_->stop(); + break; + case media_source::MediaSourceCommand::PAUSE: + // Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state + // machine from getting stuck in PAUSED when no playback is active (which would block the + // next play_uri() call via its IDLE-state precondition). + if (this->get_state() != media_source::MediaSourceState::PLAYING) + break; + // PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily + // yields, which fills the ring buffer and applies back pressure that effectively pauses both + // the decoder and HTTP reader tasks. + this->set_state_(media_source::MediaSourceState::PAUSED); + this->pause_.store(true, std::memory_order_relaxed); + break; + case media_source::MediaSourceCommand::PLAY: + // Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR. + if (this->get_state() != media_source::MediaSourceState::PAUSED) + break; + this->set_state_(media_source::MediaSourceState::PLAYING); + this->pause_.store(false, std::memory_order_relaxed); + break; + default: + break; + } +} + +// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for +// being thread-safe with respect to its own audio writer. +size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (this->pause_.load(std::memory_order_relaxed)) { + vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS)); + return 0; + } + return this->write_output(data, length, timeout_ms, this->stream_info_); +} + +// Called from the decoder task before the first on_audio_write(). +void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) { + this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate()); +} + +// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main +// loop thread and it's safe to call set_state_() directly. +void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) { + switch (state) { + case micro_decoder::DecoderState::IDLE: + this->set_state_(media_source::MediaSourceState::IDLE); + this->disable_loop(); + break; + case micro_decoder::DecoderState::PLAYING: + this->set_state_(media_source::MediaSourceState::PLAYING); + break; + case micro_decoder::DecoderState::FAILED: + this->set_state_(media_source::MediaSourceState::ERROR); + break; + default: + break; + } +} + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h new file mode 100644 index 000000000..e4bd69e9e --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/media_source/media_source.h" +#include "esphome/core/component.h" + +#include +#include + +#include +#include +#include + +namespace esphome::audio_http { + +// Inherits from two unrelated listener-style interfaces: +// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator +// (the orchestrator calls set_listener() on us with a MediaSourceListener*). +// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded +// audio and state changes (we call decoder_->set_listener(this) in setup()). +// The two set_listener() methods live on different base classes and serve opposite directions. +class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + + // DecoderListener interface implementation + size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override; + void on_stream_info(const micro_decoder::AudioStreamInfo &info) override; + void on_state_change(micro_decoder::DecoderState state) override; + + protected: + std::unique_ptr decoder_; + audio::AudioStreamInfo stream_info_; + + size_t buffer_size_{50000}; + + // Written from the main loop in handle_command(), read from the decoder task in + // on_audio_write(). Must be atomic to avoid a data race. + std::atomic pause_{false}; + bool decoder_task_stack_in_psram_{false}; +}; + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py new file mode 100644 index 000000000..519d8df69 --- /dev/null +++ b/esphome/components/audio_http/media_source.py @@ -0,0 +1,59 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import audio, esp32, media_source, psram +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.types import ConfigType + +CODEOWNERS = ["@kahrendt"] +AUTO_LOAD = ["audio"] + +audio_http_ns = cg.esphome_ns.namespace("audio_http") +AudioHTTPMediaSource = audio_http_ns.class_( + "AudioHTTPMediaSource", cg.Component, media_source.MediaSource +) + + +def _request_micro_decoder(config: ConfigType) -> ConfigType: + audio.request_micro_decoder_support() + return config + + +def _validate_task_stack_in_psram(value: Any) -> bool: + # Only require the psram component when actually enabling PSRAM stacks; validating + # the boolean first means `false` doesn't trigger the requires_component check. + if value := cv.boolean(value): + return cv.requires_component(psram.DOMAIN)(value) + return value + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + AudioHTTPMediaSource, + ) + .extend( + { + cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( + min=5000, max=1000000 + ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _request_micro_decoder, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 0b36c299f..29ddbab02 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -332,8 +332,9 @@ def parse_multi_click_timing_str(value): try: state = cv.boolean(parts[0]) except cv.Invalid: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"First word must either be ON or OFF, not {parts[0]}") + raise cv.Invalid( + f"First word must either be ON or OFF, not {parts[0]}" + ) from None if parts[1] != "for": raise cv.Invalid(f"Second word must be 'for', got {parts[1]}") @@ -350,7 +351,9 @@ def parse_multi_click_timing_str(value): try: length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing length failed: {err}" + ) from err return {CONF_STATE: state, key: str(length)} if parts[3] != "to": @@ -359,12 +362,16 @@ def parse_multi_click_timing_str(value): try: min_length = cv.positive_time_period_milliseconds(parts[2]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing minimum length failed: {err}" + ) from err try: max_length = cv.positive_time_period_milliseconds(parts[4]) except cv.Invalid as err: - raise cv.Invalid(f"Multi Click Grammar Parsing minimum length failed: {err}") + raise cv.Invalid( + f"Multi Click Grammar Parsing maximum length failed: {err}" + ) from err return { CONF_STATE: state, diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2..3ffab0f3a 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 70db235a3..d554057f7 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,58 +20,77 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) } void BL0906::loop() { - if (this->current_channel_ == UINT8_MAX) { - return; - } - while (this->available()) this->flush(); - if (this->current_channel_ == 0) { + if (this->current_stage_ == STAGE_IDLE) { + // Woken up between cycles to drain the action queue. Go back to sleep. + this->handle_actions_(); + this->disable_loop(); + return; + } + + if (this->current_stage_ == STAGE_TEMP) { // Temperature this->read_data_(BL0906_TEMPERATURE, BL0906_TREF, this->temperature_sensor_); - } else if (this->current_channel_ == 1) { + } else if (this->current_stage_ == STAGE_CHANNEL_1) { this->read_data_(BL0906_I_1_RMS, BL0906_IREF, this->current_1_sensor_); this->read_data_(BL0906_WATT_1, BL0906_PREF, this->power_1_sensor_); this->read_data_(BL0906_CF_1_CNT, BL0906_EREF, this->energy_1_sensor_); - } else if (this->current_channel_ == 2) { + } else if (this->current_stage_ == STAGE_CHANNEL_2) { this->read_data_(BL0906_I_2_RMS, BL0906_IREF, this->current_2_sensor_); this->read_data_(BL0906_WATT_2, BL0906_PREF, this->power_2_sensor_); this->read_data_(BL0906_CF_2_CNT, BL0906_EREF, this->energy_2_sensor_); - } else if (this->current_channel_ == 3) { + } else if (this->current_stage_ == STAGE_CHANNEL_3) { this->read_data_(BL0906_I_3_RMS, BL0906_IREF, this->current_3_sensor_); this->read_data_(BL0906_WATT_3, BL0906_PREF, this->power_3_sensor_); this->read_data_(BL0906_CF_3_CNT, BL0906_EREF, this->energy_3_sensor_); - } else if (this->current_channel_ == 4) { + } else if (this->current_stage_ == STAGE_CHANNEL_4) { this->read_data_(BL0906_I_4_RMS, BL0906_IREF, this->current_4_sensor_); this->read_data_(BL0906_WATT_4, BL0906_PREF, this->power_4_sensor_); this->read_data_(BL0906_CF_4_CNT, BL0906_EREF, this->energy_4_sensor_); - } else if (this->current_channel_ == 5) { + } else if (this->current_stage_ == STAGE_CHANNEL_5) { this->read_data_(BL0906_I_5_RMS, BL0906_IREF, this->current_5_sensor_); this->read_data_(BL0906_WATT_5, BL0906_PREF, this->power_5_sensor_); this->read_data_(BL0906_CF_5_CNT, BL0906_EREF, this->energy_5_sensor_); - } else if (this->current_channel_ == 6) { + } else if (this->current_stage_ == STAGE_CHANNEL_6) { this->read_data_(BL0906_I_6_RMS, BL0906_IREF, this->current_6_sensor_); this->read_data_(BL0906_WATT_6, BL0906_PREF, this->power_6_sensor_); this->read_data_(BL0906_CF_6_CNT, BL0906_EREF, this->energy_6_sensor_); - } else if (this->current_channel_ == UINT8_MAX - 2) { + } else if (this->current_stage_ == STAGE_FREQ) { // Frequency - this->read_data_(BL0906_FREQUENCY, BL0906_FREF, frequency_sensor_); + this->read_data_(BL0906_FREQUENCY, BL0906_FREF, this->frequency_sensor_); // Voltage - this->read_data_(BL0906_V_RMS, BL0906_UREF, voltage_sensor_); - } else if (this->current_channel_ == UINT8_MAX - 1) { + this->read_data_(BL0906_V_RMS, BL0906_UREF, this->voltage_sensor_); + } else if (this->current_stage_ == STAGE_POWER) { // Total power this->read_data_(BL0906_WATT_SUM, BL0906_WATT, this->total_power_sensor_); // Total Energy this->read_data_(BL0906_CF_SUM_CNT, BL0906_CF, this->total_energy_sensor_); - } else { - this->current_channel_ = UINT8_MAX - 2; // Go to frequency and voltage - return; } - this->current_channel_++; + this->advance_stage_(); this->handle_actions_(); } +void BL0906::advance_stage_() { + switch (this->current_stage_) { + case STAGE_CHANNEL_6: + this->current_stage_ = STAGE_FREQ; + break; + case STAGE_FREQ: + this->current_stage_ = STAGE_POWER; + break; + case STAGE_POWER: + // Cycle complete; sleep until the next update(). + this->current_stage_ = STAGE_IDLE; + this->disable_loop(); + break; + default: + this->current_stage_ = static_cast(this->current_stage_ + 1); + break; + } +} + void BL0906::setup() { while (this->available()) this->flush(); @@ -85,12 +104,20 @@ void BL0906::setup() { this->bias_correction_(BL0906_RMSOS_6, 0.01200, 0); // Calibration current_6 this->write_array(USR_WRPROT_ONLYREAD, sizeof(USR_WRPROT_ONLYREAD)); + + // Loop stays idle until the first update() or enqueued action. + this->disable_loop(); } -void BL0906::update() { this->current_channel_ = 0; } +void BL0906::update() { + this->current_stage_ = STAGE_TEMP; + this->enable_loop(); +} size_t BL0906::enqueue_action_(ActionCallbackFuncPtr function) { this->action_queue_.push_back(function); + // Ensure the queue is serviced even if the read cycle has already completed. + this->enable_loop(); return this->action_queue_.size(); } diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 493b645c8..f7ba5423d 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -12,6 +12,22 @@ namespace esphome { namespace bl0906 { +// Stage values for the read state machine. After STAGE_CHANNEL_6 the state machine +// jumps to the two sentinel stages below, then to STAGE_IDLE which marks the cycle +// as complete and disables the loop. +enum BL0906Stage : uint8_t { + STAGE_TEMP = 0, // chip temperature + STAGE_CHANNEL_1 = 1, // per-phase current + power + energy + STAGE_CHANNEL_2 = 2, + STAGE_CHANNEL_3 = 3, + STAGE_CHANNEL_4 = 4, + STAGE_CHANNEL_5 = 5, + STAGE_CHANNEL_6 = 6, + STAGE_FREQ = UINT8_MAX - 2, // frequency + voltage + STAGE_POWER = UINT8_MAX - 1, // total power + total energy + STAGE_IDLE = UINT8_MAX, // cycle complete +}; + struct DataPacket { // NOLINT(altera-struct-pack-align) uint8_t l{0}; uint8_t m{0}; @@ -79,7 +95,8 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { void bias_correction_(uint8_t address, float measurements, float correction); - uint8_t current_channel_{0}; + BL0906Stage current_stage_{STAGE_IDLE}; + void advance_stage_(); size_t enqueue_action_(ActionCallbackFuncPtr function); void handle_actions_(); diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 062094c03..d911301c9 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -63,7 +63,7 @@ void BM8563::read_time() { rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index a86e061cd..2365f8d10 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Fr CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] +CONFLICTS_WITH = ["bme68x_bsec2"] MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5f0afa9c9..5083d283e 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -13,10 +13,12 @@ from esphome.const import ( ) CODEOWNERS = ["@neffs", "@kbx81"] +CONFLICTS_WITH = ["bme680_bsec"] DOMAIN = "bme68x_bsec2" BSEC2_LIBRARY_VERSION = "1.10.2610" +BME68x_LIBRARY_VERSION = "v1.3.40408" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -170,7 +172,9 @@ async def to_code_base(config): with open(path, encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: - raise core.EsphomeError(f"Could not open binary configuration file {path}: {e}") + raise core.EsphomeError( + f"Could not open binary configuration file {path}: {e}" + ) from e # Convert retrieved BSEC2 config to an array of ints rhs = [int(x) for x in bsec2_iaq_config.split(",")] @@ -184,16 +188,31 @@ async def to_code_base(config): if core.CORE.using_arduino: cg.add_library("Wire", None) cg.add_library("SPI", None) - cg.add_library( - "BME68x Sensor library", - None, - "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", - ) - cg.add_library( - "BSEC2 Software Library", - None, - f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", - ) + + if core.CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + add_idf_component( + name="boschsensortec/Bosch-BME68x-Library", + repo="https://github.com/esphome-libs/Bosch-BME68x-Library", + ref=BME68x_LIBRARY_VERSION, + ) + add_idf_component( + name="boschsensortec/Bosch-BSEC2-Library", + repo="https://github.com/esphome-libs/Bosch-BSEC2-Library", + ref=BSEC2_LIBRARY_VERSION, + ) + else: + cg.add_library( + "BME68x Sensor library", + None, + f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}", + ) + cg.add_library( + "BSEC2 Software Library", + None, + f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", + ) cg.add_define("USE_BSEC2") diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 7d3bf78f4..fcd342ad3 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args): await cg.register_parented(var, config[CONF_CANBUS_ID]) if (can_id := config.get(CONF_CAN_ID)) is not None: - can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index c231f314c..ea0138e1d 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -106,6 +106,30 @@ void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_lo void CC1101Component::setup() { this->spi_setup(); + + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + + this->configure(); + if (this->is_failed()) { + return; + } + + // 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); + if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } + }); + } +} + +void CC1101Component::configure() { + // Manual reset sequence per CC1101 datasheet section 19.1.2 this->cs_->digital_write(true); delayMicroseconds(1); this->cs_->digital_write(false); @@ -128,11 +152,6 @@ void CC1101Component::setup() { 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++) { @@ -142,21 +161,11 @@ void CC1101Component::setup() { this->write_(static_cast(i)); } this->set_output_power(this->output_power_requested_); + if (!this->enter_rx_()) { this->mark_failed(); return; } - - // 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); - if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { - this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); - } - }); - } } void CC1101Component::call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) { @@ -273,7 +282,7 @@ void CC1101Component::begin_rx() { void CC1101Component::reset() { this->strobe_(Command::RES); - this->setup(); + this->configure(); } void CC1101Component::set_idle() { diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 68d81ac8f..000a13d58 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -25,6 +25,7 @@ class CC1101Component : public Component, void setup() override; void loop() override; void dump_config() override; + void configure(); // Actions void begin_tx(); diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 7525b901f..0ecaaced7 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -204,24 +204,27 @@ void CSE7761Component::get_data_() { value = this->read_(CSE7761_REG_RMSIA, 3); this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA value = this->read_(CSE7761_REG_POWERPA, 4); - this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : ((uint32_t) abs((int) value)); + // PowerPA is two's complement signed 32-bit per datasheet + this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast(value); value = this->read_(CSE7761_REG_RMSIB, 3); this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA value = this->read_(CSE7761_REG_POWERPB, 4); - this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : ((uint32_t) abs((int) value)); + // PowerPB is two's complement signed 32-bit per datasheet + this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast(value); // convert values and publish to sensors - float voltage = (float) this->data_.voltage_rms / this->coefficient_by_unit_(RMS_UC); + float voltage = static_cast(this->data_.voltage_rms) / this->coefficient_by_unit_(RMS_UC); if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(voltage); } for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 - float active_power = (float) this->data_.active_power[channel] / this->coefficient_by_unit_(POWER_PAC); // W - float amps = (float) this->data_.current_rms[channel] / this->coefficient_by_unit_(RMS_IAC); // A + float active_power = + static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W + float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); if (channel == 0) { if (this->power_sensor_1_ != nullptr) { diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 289c5e7e1..0e0317195 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -11,10 +11,8 @@ struct CSE7761DataStruct { uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; - uint32_t energy[2] = {0}; - uint32_t active_power[2] = {0}; + int32_t active_power[2] = {0}; uint16_t coefficient[8] = {0}; - uint8_t energy_update = 0; bool ready = false; }; diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 15f68c3a3..d97a4aa13 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -30,7 +30,7 @@ void DebugComponent::dump_config() { char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; ESP_LOGD(TAG, "ESPHome version %s", ESPHOME_VERSION); - size_t pos = buf_append_printf(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, "%s", ESPHOME_VERSION); + size_t pos = buf_append_str(device_info_buffer, DEVICE_INFO_BUFFER_SIZE, 0, ESPHOME_VERSION); this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 2e0409074..ea0c63520 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -224,17 +224,21 @@ size_t DebugComponent::get_device_info_(std::span const char *model = ESPHOME_VARIANT; // Build features string - pos = buf_append_printf(buf, size, pos, "|Chip: %s Features:", model); + pos = buf_append_str(buf, size, pos, "|Chip: "); + pos = buf_append_str(buf, size, pos, model); + pos = buf_append_str(buf, size, pos, " Features:"); bool first_feature = true; for (const auto &feature : CHIP_FEATURES) { if (info.features & feature.bit) { - pos = buf_append_printf(buf, size, pos, "%s%s", first_feature ? "" : ", ", feature.name); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_str(buf, size, pos, feature.name); first_feature = false; info.features &= ~feature.bit; } } if (info.features != 0) { - pos = buf_append_printf(buf, size, pos, "%sOther:0x%" PRIx32, first_feature ? "" : ", ", info.features); + pos = buf_append_str(buf, size, pos, first_feature ? "" : ", "); + pos = buf_append_printf(buf, size, pos, "Other:0x%" PRIx32, info.features); } pos = buf_append_printf(buf, size, pos, " Cores:%u Revision:%u", info.cores, info.revision); @@ -267,17 +271,20 @@ size_t DebugComponent::get_device_info_(std::span // Framework detection #ifdef USE_ARDUINO ESP_LOGD(TAG, " Framework: Arduino"); - pos = buf_append_printf(buf, size, pos, "|Framework: Arduino"); + pos = buf_append_str(buf, size, pos, "|Framework: Arduino"); #else ESP_LOGD(TAG, " Framework: ESP-IDF"); - pos = buf_append_printf(buf, size, pos, "|Framework: ESP-IDF"); + pos = buf_append_str(buf, size, pos, "|Framework: ESP-IDF"); #endif - pos = buf_append_printf(buf, size, pos, "|ESP-IDF: %s", esp_get_idf_version()); + pos = buf_append_str(buf, size, pos, "|ESP-IDF: "); + pos = buf_append_str(buf, size, pos, esp_get_idf_version()); pos = buf_append_printf(buf, size, pos, "|EFuse MAC: %02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Wakeup: %s", wakeup_cause); + pos = buf_append_str(buf, size, pos, "|Reset: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Wakeup: "); + pos = buf_append_str(buf, size, pos, wakeup_cause); return pos; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1d458c602..6f36debb9 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -38,9 +38,12 @@ size_t DebugComponent::get_device_info_(std::span lt_get_version(), lt_cpu_get_model_name(), lt_cpu_get_model(), lt_cpu_get_freq_mhz(), mac_id, lt_get_board_code(), flash_kib, ram_kib, reset_reason); - pos = buf_append_printf(buf, size, pos, "|Version: %s", LT_BANNER_STR + 10); - pos = buf_append_printf(buf, size, pos, "|Reset Reason: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|Chip Name: %s", lt_cpu_get_model_name()); + pos = buf_append_str(buf, size, pos, "|Version: "); + pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, "|Reset Reason: "); + pos = buf_append_str(buf, size, pos, reset_reason); + pos = buf_append_str(buf, size, pos, "|Chip Name: "); + pos = buf_append_str(buf, size, pos, lt_cpu_get_model_name()); pos = buf_append_printf(buf, size, pos, "|Chip ID: 0x%06" PRIX32, mac_id); pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 " KiB", flash_kib); pos = buf_append_printf(buf, size, pos, "|RAM: %" PRIu32 " KiB", ram_kib); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index d1580dae8..49790b5b9 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -162,14 +162,18 @@ size_t DebugComponent::get_device_info_(std::span const char *supply_status = (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) ? "Normal voltage." : "High voltage."; ESP_LOGD(TAG, "Main supply status: %s", supply_status); - pos = buf_append_printf(buf, size, pos, "|Main supply status: %s", supply_status); + pos = buf_append_str(buf, size, pos, "|Main supply status: "); + pos = buf_append_str(buf, size, pos, supply_status); // Regulator stage 0 if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos); ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: "); + pos = buf_append_str(buf, size, pos, reg0_type); + pos = buf_append_str(buf, size, pos, ", "); + pos = buf_append_str(buf, size, pos, reg0_voltage); #ifdef USE_NRF52_REG0_VOUT if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) { ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT)); @@ -177,13 +181,14 @@ size_t DebugComponent::get_device_info_(std::span #endif } else { ESP_LOGD(TAG, "Regulator stage 0: disabled"); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled"); + pos = buf_append_str(buf, size, pos, "|Regulator stage 0: disabled"); } // Regulator stage 1 const char *reg1_type = nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; ESP_LOGD(TAG, "Regulator stage 1: %s", reg1_type); - pos = buf_append_printf(buf, size, pos, "|Regulator stage 1: %s", reg1_type); + pos = buf_append_str(buf, size, pos, "|Regulator stage 1: "); + pos = buf_append_str(buf, size, pos, reg1_type); // USB power state const char *usb_state; @@ -197,7 +202,8 @@ size_t DebugComponent::get_device_info_(std::span usb_state = "disconnected"; } ESP_LOGD(TAG, "USB power state: %s", usb_state); - pos = buf_append_printf(buf, size, pos, "|USB power state: %s", usb_state); + pos = buf_append_str(buf, size, pos, "|USB power state: "); + pos = buf_append_str(buf, size, pos, usb_state); // Power-fail comparator bool enabled; @@ -302,14 +308,18 @@ size_t DebugComponent::get_device_info_(std::span break; } ESP_LOGD(TAG, "Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s, VDDH: %s", pof_voltage, vddh_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); + pos = buf_append_str(buf, size, pos, ", VDDH: "); + pos = buf_append_str(buf, size, pos, vddh_voltage); } else { ESP_LOGD(TAG, "Power-fail comparator: %s", pof_voltage); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: %s", pof_voltage); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: "); + pos = buf_append_str(buf, size, pos, pof_voltage); } } else { ESP_LOGD(TAG, "Power-fail comparator: disabled"); - pos = buf_append_printf(buf, size, pos, "|Power-fail comparator: disabled"); + pos = buf_append_str(buf, size, pos, "|Power-fail comparator: disabled"); } auto package = [](uint32_t value) { diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0f..0ca557bd6 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, get_esp32_variant, ) +from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -33,6 +34,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_NRF52, PlatformFramework, ) from esphome.core import CORE @@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]), validate_config, ) @@ -369,6 +371,8 @@ async def to_code(config): if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) + if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") @@ -413,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index b5fadd723..8dca32689 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() { lt_deep_sleep_enter(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace esphome::deep_sleep #endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 3dd1b7093..d2c5db54b 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; -bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void DeepSleepComponent::setup() { +#ifdef USE_ZEPHYR + k_sem_init(&this->wakeup_sem_, 0, 1); +#endif global_has_deep_sleep = true; + this->schedule_sleep_(); + // It can be used from another thread for waking up the device. + // It should be called as last item in setup. + global_deep_sleep.store(this); +} +void DeepSleepComponent::schedule_sleep_() { + this->next_enter_deep_sleep_ = false; const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) { if (this->sleep_duration_.has_value()) { ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_); } - App.run_safe_shutdown_hooks(); - // It's critical to teardown components cleanly for deep sleep to ensure - // Home Assistant sees a clean disconnect instead of marking the device unavailable - App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); - App.run_powerdown_hooks(); + + if (this->should_teardown_()) { + App.run_safe_shutdown_hooks(); + // It's critical to teardown components cleanly for deep sleep to ensure + // Home Assistant sees a clean disconnect instead of marking the device unavailable + App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); + App.run_powerdown_hooks(); + } this->deep_sleep_(); + this->schedule_sleep_(); } float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 9090f9187..854ab152a 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -4,6 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include #ifdef USE_ESP32 #include @@ -14,6 +15,10 @@ #include "esphome/core/time.h" #endif +#ifdef USE_ZEPHYR +#include +#endif + #include namespace esphome { @@ -120,6 +125,9 @@ class DeepSleepComponent : public Component { void prevent_deep_sleep(); void allow_deep_sleep(); +#ifdef USE_ZEPHYR + void wakeup(); +#endif protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -129,6 +137,8 @@ class DeepSleepComponent : public Component { void dump_config_platform_(); bool prepare_to_sleep_(); void deep_sleep_(); + void schedule_sleep_(); + bool should_teardown_(); #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; @@ -157,6 +167,9 @@ class DeepSleepComponent : public Component { optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; +#ifdef USE_ZEPHYR + k_sem wakeup_sem_; +#endif }; extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -243,5 +256,8 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; +extern std::atomic + global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace deep_sleep } // namespace esphome diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 4f4d262d3..80a218e91 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() { esp_deep_sleep_start(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index efbd45c34..42c153c2f 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() { ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance) } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp new file mode 100644 index 000000000..82d6d8c7d --- /dev/null +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -0,0 +1,60 @@ +#include "deep_sleep_component.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" +#include +#include +#include +#include + +namespace esphome::deep_sleep { + +static const char *const TAG = "deep_sleep"; + +void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); } + +optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } + +void DeepSleepComponent::dump_config_platform_() {} + +bool DeepSleepComponent::prepare_to_sleep_() { return true; } + +void DeepSleepComponent::deep_sleep_() { + k_timeout_t sleep_duration = K_FOREVER; + if (this->sleep_duration_.has_value()) { + sleep_duration = K_USEC(*this->sleep_duration_); + } else { +#ifndef USE_ZIGBEE + // the device can be woken up through one of the following signals: + // - The DETECT signal, optionally generated by the GPIO peripheral. + // - The ANADETECT signal, optionally generated by the LPCOMP module. + // - The SENSE signal, optionally generated by the NFC module to wake-on-field. + // - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT). + // - A reset. + // + // The system is reset when it wakes up from System OFF mode. + sys_poweroff(); +#endif + } + // It might wake up immediately if k_sem_give was called again after wake up + int ret = k_sem_take(&this->wakeup_sem_, sleep_duration); + if (ret == 0) { + ESP_LOGD(TAG, "Woken up by another thread"); + } else { + ESP_LOGD(TAG, "Timeout expired (normal sleep)"); + } +} + +bool DeepSleepComponent::should_teardown_() { + if (this->sleep_duration_.has_value()) { + return false; + } +#ifdef USE_ZIGBEE + return false; +#else + return true; +#endif +} + +} // namespace esphome::deep_sleep + +#endif diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 8fff4213b..ba2ad6032 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -44,7 +44,7 @@ void DS1307Component::read_time() { .year = uint16_t(ds1307_.reg.year + 10u * ds1307_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 9c493bfcf..31ec1ce5b 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -1,8 +1,19 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID +from esphome.const import ( + CONF_ID, + CONF_RECEIVE_TIMEOUT, + CONF_RX_BUFFER_SIZE, + CONF_UART_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@PolarGoose"] @@ -21,8 +32,7 @@ CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length" CONF_REQUEST_INTERVAL = "request_interval" CONF_REQUEST_PIN = "request_pin" -# Hack to prevent compile error due to ambiguity with lib namespace -dsmr_ns = cg.esphome_ns.namespace("esphome::dsmr") +dsmr_ns = cg.esphome_ns.namespace("dsmr") Dsmr = dsmr_ns.class_("Dsmr", cg.Component, uart.UARTDevice) @@ -54,24 +64,47 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): uart_component = await cg.get_variable(config[CONF_UART_ID]) - var = cg.new_Pvariable(config[CONF_ID], uart_component, config[CONF_CRC_CHECK]) - cg.add(var.set_max_telegram_length(config[CONF_MAX_TELEGRAM_LENGTH])) - if CONF_DECRYPTION_KEY in config: - cg.add(var.set_decryption_key(config[CONF_DECRYPTION_KEY])) - await cg.register_component(var, config) - if CONF_REQUEST_PIN in config: request_pin = await cg.gpio_pin_expression(config[CONF_REQUEST_PIN]) - cg.add(var.set_request_pin(request_pin)) - cg.add(var.set_request_interval(config[CONF_REQUEST_INTERVAL].total_milliseconds)) - cg.add(var.set_receive_timeout(config[CONF_RECEIVE_TIMEOUT].total_milliseconds)) + else: + request_pin = cg.nullptr + decryption_key = config.get(CONF_DECRYPTION_KEY) + if decryption_key is None: + decryption_key = cg.nullptr + var = cg.new_Pvariable( + config[CONF_ID], + uart_component, + config[CONF_CRC_CHECK], + config[CONF_MAX_TELEGRAM_LENGTH], + config[CONF_REQUEST_INTERVAL].total_milliseconds, + config[CONF_RECEIVE_TIMEOUT].total_milliseconds, + request_pin, + decryption_key, + ) + await cg.register_component(var, config) cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID])) cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - # DSMR Parser - cg.add_library("esphome/dsmr_parser", "1.1.0") + cg.add_library("esphome/dsmr_parser", "1.4.0") - # Crypto - cg.add_library("polargoose/Crypto-no-arduino", "0.4.0") + +def final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + + for uart_conf in full_config["uart"]: + if uart_conf[CONF_ID] == config[CONF_UART_ID]: + rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] + if rx_buffer_size < 1500: + _LOGGER.warning( + "UART '%s' rx_buffer_size should be bigger than 1500 bytes to avoid packet losses (currently %d bytes).", + config[CONF_UART_ID], + rx_buffer_size, + ) + break + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index baf7f5931..2fa51f73a 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -1,315 +1,183 @@ -#include "dsmr.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) -#include -#include -#include +#include "dsmr.h" +#include "esphome/core/log.h" +#include namespace esphome::dsmr { -static const char *const TAG = "dsmr"; +static constexpr auto &TAG = "dsmr"; + +static void log_callback(dsmr_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dsmr_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dsmr_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} void Dsmr::setup() { - this->telegram_ = new char[this->max_telegram_len_]; // NOLINT + dsmr_parser::Logger::set_log_function(log_callback); if (this->request_pin_ != nullptr) { this->request_pin_->setup(); } } void Dsmr::loop() { - if (this->ready_to_request_data_()) { - if (this->decryption_key_.empty()) { - this->receive_telegram_(); - } else { - this->receive_encrypted_telegram_(); - } + if (!this->ready_to_request_data_()) { + return; + } + + if (this->encryption_enabled_) { + this->receive_encrypted_telegram_(); + } else { + this->receive_telegram_(); } } bool Dsmr::ready_to_request_data_() { - // When using a request pin, then wait for the next request interval. - if (this->request_pin_ != nullptr) { - if (!this->requesting_data_ && this->request_interval_reached_()) { - this->start_requesting_data_(); - } - } - // Otherwise, sink serial data until next request interval. - else { - if (this->request_interval_reached_()) { - this->start_requesting_data_(); - } - if (!this->requesting_data_) { - this->drain_rx_buffer_(); - } + if (!this->requesting_data_ && this->request_interval_reached_()) { + this->start_requesting_data_(); } return this->requesting_data_; } -bool Dsmr::request_interval_reached_() { +bool Dsmr::request_interval_reached_() const { if (this->last_request_time_ == 0) { return true; } return millis() - this->last_request_time_ > this->request_interval_; } -bool Dsmr::receive_timeout_reached_() { return millis() - this->last_read_time_ > this->receive_timeout_; } - -bool Dsmr::available_within_timeout_() { - // Data are available for reading on the UART bus? - // Then we can start reading right away. - if (this->available()) { - this->last_read_time_ = millis(); - return true; - } - // When we're not in the process of reading a telegram, then there is - // no need to actively wait for new data to come in. - if (!header_found_) { - return false; - } - // A telegram is being read. The smart meter might not deliver a telegram - // in one go, but instead send it in chunks with small pauses in between. - // When the UART RX buffer cannot hold a full telegram, then make sure - // that the UART read buffer does not overflow while other components - // perform their work in their loop. Do this by not returning control to - // the main loop, until the read timeout is reached. - if (this->parent_->get_rx_buffer_size() < this->max_telegram_len_) { - while (!this->receive_timeout_reached_()) { - delay(5); - if (this->available()) { - this->last_read_time_ = millis(); - return true; - } - } - } - // No new data has come in during the read timeout? Then stop reading the - // telegram and start waiting for the next one to arrive. - if (this->receive_timeout_reached_()) { - ESP_LOGW(TAG, "Timeout while reading data for telegram"); - this->reset_telegram_(); - } - - return false; -} - void Dsmr::start_requesting_data_() { - if (!this->requesting_data_) { - if (this->request_pin_ != nullptr) { - ESP_LOGV(TAG, "Start requesting data from P1 port"); - this->request_pin_->digital_write(true); - } else { - ESP_LOGV(TAG, "Start reading data from P1 port"); - } - this->requesting_data_ = true; - this->last_request_time_ = millis(); + if (this->requesting_data_) { + return; } + + ESP_LOGV(TAG, "Start reading data from P1 port"); + this->flush_rx_buffer_(); + + if (this->request_pin_ != nullptr) { + ESP_LOGV(TAG, "Set request pin to 1"); + this->request_pin_->digital_write(true); + } + + this->requesting_data_ = true; + this->last_request_time_ = millis(); } void Dsmr::stop_requesting_data_() { - if (this->requesting_data_) { - if (this->request_pin_ != nullptr) { - ESP_LOGV(TAG, "Stop requesting data from P1 port"); - this->request_pin_->digital_write(false); - } else { - ESP_LOGV(TAG, "Stop reading data from P1 port"); - } - this->drain_rx_buffer_(); - this->requesting_data_ = false; + if (!this->requesting_data_) { + return; } + + ESP_LOGV(TAG, "Stop reading data from P1 port"); + if (this->request_pin_ != nullptr) { + ESP_LOGV(TAG, "Set request pin to 0"); + this->request_pin_->digital_write(false); + } + this->requesting_data_ = false; } -void Dsmr::drain_rx_buffer_() { - uint8_t buf[64]; - size_t avail; - while ((avail = this->available()) > 0) { - if (!this->read_array(buf, std::min(avail, sizeof(buf)))) { - break; - } +void Dsmr::flush_rx_buffer_() { + ESP_LOGV(TAG, "Flush UART RX buffer"); + while (!this->uart_read_chunk_().empty()) { } } -void Dsmr::reset_telegram_() { - this->header_found_ = false; - this->footer_found_ = false; - this->bytes_read_ = 0; - this->crypt_bytes_read_ = 0; - this->crypt_telegram_len_ = 0; -} - void Dsmr::receive_telegram_() { - while (this->available_within_timeout_()) { - // Read all available bytes in batches to reduce UART call overhead. - uint8_t buf[64]; - size_t avail = this->available(); - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) + for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) { + for (uint8_t byte : data) { + const auto telegram = this->packet_accumulator_.process_byte(byte); + if (!telegram) { // No full packet received yet + continue; + } + if (this->parse_telegram_(telegram.value())) { return; - avail -= to_read; - - for (size_t i = 0; i < to_read; i++) { - const char c = static_cast(buf[i]); - - // Find a new telegram header, i.e. forward slash. - if (c == '/') { - ESP_LOGV(TAG, "Header of telegram found"); - this->reset_telegram_(); - this->header_found_ = true; - } - if (!this->header_found_) - continue; - - // Check for buffer overflow. - if (this->bytes_read_ >= this->max_telegram_len_) { - this->reset_telegram_(); - ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_); - return; - } - - // Some v2.2 or v3 meters will send a new value which starts with '(' - // in a new line, while the value belongs to the previous ObisId. For - // proper parsing, remove these new line characters. - if (c == '(') { - while (true) { - auto previous_char = this->telegram_[this->bytes_read_ - 1]; - if (previous_char == '\n' || previous_char == '\r') { - this->bytes_read_--; - } else { - break; - } - } - } - - // Store the byte in the buffer. - this->telegram_[this->bytes_read_] = c; - this->bytes_read_++; - - // Check for a footer, i.e. exclamation mark, followed by a hex checksum. - if (c == '!') { - ESP_LOGV(TAG, "Footer of telegram found"); - this->footer_found_ = true; - continue; - } - // Check for the end of the hex checksum, i.e. a newline. - if (this->footer_found_ && c == '\n') { - // Parse the telegram and publish sensor values. - this->parse_telegram(); - this->reset_telegram_(); - return; - } } } } } void Dsmr::receive_encrypted_telegram_() { - while (this->available_within_timeout_()) { - // Read all available bytes in batches to reduce UART call overhead. - uint8_t buf[64]; - size_t avail = this->available(); - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) - return; - avail -= to_read; - - for (size_t i = 0; i < to_read; i++) { - const char c = static_cast(buf[i]); - - // Find a new telegram start byte. - if (!this->header_found_) { - if ((uint8_t) c != 0xDB) { - continue; - } - ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found"); - this->reset_telegram_(); - this->header_found_ = true; - } - - // Check for buffer overflow. - if (this->crypt_bytes_read_ >= this->max_telegram_len_) { - this->reset_telegram_(); - ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_); - return; - } - - // Store the byte in the buffer. - this->crypt_telegram_[this->crypt_bytes_read_] = c; - this->crypt_bytes_read_++; - - // Read the length of the incoming encrypted telegram. - if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) { - // Complete header + data bytes - this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]); - ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_); - } - - // Check for the end of the encrypted telegram. - if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) { - continue; - } - ESP_LOGV(TAG, "End of encrypted telegram found"); - - // Decrypt the encrypted telegram. - GCM *gcmaes128{new GCM()}; - gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize()); - // the iv is 8 bytes of the system title + 4 bytes frame counter - // system title is at byte 2 and frame counter at byte 15 - for (int i = 10; i < 14; i++) - this->crypt_telegram_[i] = this->crypt_telegram_[i + 4]; - constexpr uint16_t iv_size{12}; - gcmaes128->setIV(&this->crypt_telegram_[2], iv_size); - gcmaes128->decrypt(reinterpret_cast(this->telegram_), - // the ciphertext start at byte 18 - &this->crypt_telegram_[18], - // cipher size - this->crypt_bytes_read_ - 17); - delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory) - - this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_); - ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_); - ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_); - - // Parse the decrypted telegram and publish sensor values. - this->parse_telegram(); - this->reset_telegram_(); - return; + for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) { + for (uint8_t byte : data) { + if (this->buffer_pos_ >= this->buffer_.size()) { // Reset buffer if overflow + ESP_LOGW(TAG, "Encrypted buffer overflow, resetting"); + this->buffer_pos_ = 0; } + + this->buffer_[this->buffer_pos_] = byte; + this->buffer_pos_++; } + this->last_read_time_ = millis(); + } + + // Detect inter-frame delay. If no byte is received for more than receive_timeout, then the packet is complete. + if (millis() - this->last_read_time_ > this->receive_timeout_ && this->buffer_pos_ > 0) { + ESP_LOGV(TAG, "Encrypted telegram received (%zu bytes)", this->buffer_pos_); + + const auto telegram = this->dlms_decryptor_.decrypt_inplace({this->buffer_.data(), this->buffer_pos_}); + + // Reset buffer position for the next packet + this->buffer_pos_ = 0; + this->last_read_time_ = 0; + + if (!telegram) { // decryption failed + return; + } + + // Parse and publish the telegram + this->parse_telegram_(telegram.value()); } } -bool Dsmr::parse_telegram() { - MyData data; - ESP_LOGV(TAG, "Trying to parse telegram"); +bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - const auto &res = dsmr_parser::P1Parser::parse( - data, this->telegram_, this->bytes_read_, false, - this->crc_check_); // Parse telegram according to data definition. Ignore unknown values. - if (res.err) { - // Parsing error, show it - auto err_str = res.fullError(this->telegram_, this->telegram_ + this->bytes_read_); - ESP_LOGE(TAG, "%s", err_str.c_str()); - return false; - } else { - this->status_clear_warning(); - this->publish_sensors(data); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); - // publish the telegram, after publishing the sensors so it can also trigger action based on latest values - if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(this->telegram_, this->bytes_read_); - } - return true; + MyData data; + if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { + ESP_LOGE(TAG, "Failed to parse telegram"); + return false; } + + this->status_clear_warning(); + this->publish_sensors(data); + + // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values + if (this->s_telegram_ != nullptr) { + this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + } + return true; } void Dsmr::dump_config() { ESP_LOGCONFIG(TAG, "DSMR:\n" - " Max telegram length: %d\n" + " Max telegram length: %zu\n" " Receive timeout: %.1fs", - this->max_telegram_len_, this->receive_timeout_ / 1e3f); + this->buffer_.size(), this->receive_timeout_ / 1e3f); if (this->request_pin_ != nullptr) { LOG_PIN(" Request Pin: ", this->request_pin_); } @@ -324,30 +192,37 @@ void Dsmr::dump_config() { DSMR_TEXT_SENSOR_LIST(DSMR_LOG_TEXT_SENSOR, ) } -void Dsmr::set_decryption_key(const char *decryption_key) { +void Dsmr::set_decryption_key_(const char *decryption_key) { if (decryption_key == nullptr || decryption_key[0] == '\0') { - ESP_LOGI(TAG, "Disabling decryption"); - this->decryption_key_.clear(); - if (this->crypt_telegram_ != nullptr) { - delete[] this->crypt_telegram_; - this->crypt_telegram_ = nullptr; - } + this->encryption_enabled_ = false; return; } - if (!parse_hex(decryption_key, this->decryption_key_, 16)) { - ESP_LOGE(TAG, "Error, decryption key must be 32 hex characters"); - this->decryption_key_.clear(); + auto key = dsmr_parser::Aes128GcmDecryptionKey::from_hex(decryption_key); + if (!key) { + ESP_LOGE(TAG, "Error, decryption key has incorrect format"); + this->encryption_enabled_ = false; return; } ESP_LOGI(TAG, "Decryption key is set"); - // Verbose level prints decryption key - ESP_LOGV(TAG, "Using decryption key: %s", decryption_key); - if (this->crypt_telegram_ == nullptr) { - this->crypt_telegram_ = new uint8_t[this->max_telegram_len_]; // NOLINT + this->gcm_decryptor_.set_encryption_key(key.value()); + this->encryption_enabled_ = true; +} + +std::span Dsmr::uart_read_chunk_() { + const auto avail = this->available(); + if (avail == 0) { + return {}; } + size_t to_read = std::min(avail, uart_chunk_reading_buf_.size()); + if (!this->read_array(uart_chunk_reading_buf_.data(), to_read)) { + return {}; + } + return {uart_chunk_reading_buf_.data(), to_read}; } } // namespace esphome::dsmr + +#endif diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index dc81ba9b2..626a389c1 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -1,31 +1,46 @@ #pragma once +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/log.h" +#include #include +#include #include +#include +#include #include +#if __has_include() +#include +#elif __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#error "The platform doesn't provide a compatible encryption library for dsmr_parser" +#endif + namespace esphome::dsmr { -using namespace dsmr_parser::fields; - -// DSMR_**_LIST generated by ESPHome and written in esphome/core/defines - -#if !defined(DSMR_SENSOR_LIST) && !defined(DSMR_TEXT_SENSOR_LIST) -// Neither set, set it to a dummy value to not break build -#define DSMR_TEXT_SENSOR_LIST(F, SEP) F(identification) -#endif - -#if defined(DSMR_SENSOR_LIST) && defined(DSMR_TEXT_SENSOR_LIST) -#define DSMR_BOTH , +#if __has_include() +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; +#elif __has_include() +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #else -#define DSMR_BOTH +using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; #endif +using namespace dsmr_parser::fields; + #ifndef DSMR_SENSOR_LIST #define DSMR_SENSOR_LIST(F, SEP) #endif @@ -34,21 +49,33 @@ using namespace dsmr_parser::fields; #define DSMR_TEXT_SENSOR_LIST(F, SEP) #endif -#define DSMR_DATA_SENSOR(s) s +#define DSMR_IDENTITY(s) s #define DSMR_COMMA , +#define DSMR_PREPEND_COMMA(...) __VA_OPT__(, ) __VA_ARGS__ -using MyData = dsmr_parser::ParsedData; +#ifdef DSMR_TEXT_SENSOR_LIST_DEFINED +using MyData = dsmr_parser::ParsedData; +#else +using MyData = dsmr_parser::ParsedData; +#endif class Dsmr : public Component, public uart::UARTDevice { public: - Dsmr(uart::UARTComponent *uart, bool crc_check) : uart::UARTDevice(uart), crc_check_(crc_check) {} + Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, + uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) + : uart::UARTDevice(uart), + request_interval_(request_interval), + receive_timeout_(receive_timeout), + request_pin_(request_pin), + buffer_(max_telegram_length), + packet_accumulator_(buffer_, crc_check) { + this->set_decryption_key_(decryption_key); + } void setup() override; void loop() override; - bool parse_telegram(); - void publish_sensors(MyData &data) { #define DSMR_PUBLISH_SENSOR(s) \ if (data.s##_present && this->s_##s##_ != nullptr) \ @@ -57,20 +84,15 @@ class Dsmr : public Component, public uart::UARTDevice { #define DSMR_PUBLISH_TEXT_SENSOR(s) \ if (data.s##_present && this->s_##s##_ != nullptr) \ - s_##s##_->publish_state(data.s.c_str()); + s_##s##_->publish_state(data.s.data(), data.s.size()); DSMR_TEXT_SENSOR_LIST(DSMR_PUBLISH_TEXT_SENSOR, ) }; void dump_config() override; - void set_decryption_key(const char *decryption_key); // Remove before 2026.8.0 - ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); } - void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; } - void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; } - void set_request_interval(uint32_t interval) { this->request_interval_ = interval; } - void set_receive_timeout(uint32_t timeout) { this->receive_timeout_ = timeout; } + ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") + void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -85,56 +107,40 @@ class Dsmr : public Component, public uart::UARTDevice { void set_telegram(text_sensor::TextSensor *sensor) { s_telegram_ = sensor; } protected: + void set_decryption_key_(const char *decryption_key); void receive_telegram_(); void receive_encrypted_telegram_(); - void reset_telegram_(); - void drain_rx_buffer_(); + void flush_rx_buffer_(); - /// Wait for UART data to become available within the read timeout. - /// - /// The smart meter might provide data in chunks, causing available() to - /// return 0. When we're already reading a telegram, then we don't return - /// right away (to handle further data in an upcoming loop) but wait a - /// little while using this method to see if more data are incoming. - /// By not returning, we prevent other components from taking so much - /// time that the UART RX buffer overflows and bytes of the telegram get - /// lost in the process. - bool available_within_timeout_(); - - // Request telegram - uint32_t request_interval_; - bool request_interval_reached_(); - GPIOPin *request_pin_{nullptr}; - uint32_t last_request_time_{0}; - bool requesting_data_{false}; + bool parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram); + bool request_interval_reached_() const; bool ready_to_request_data_(); void start_requesting_data_(); void stop_requesting_data_(); + std::span uart_read_chunk_(); - // Read telegram + // Config + uint32_t request_interval_; uint32_t receive_timeout_; - bool receive_timeout_reached_(); - size_t max_telegram_len_; - char *telegram_{nullptr}; - size_t bytes_read_{0}; - uint8_t *crypt_telegram_{nullptr}; - size_t crypt_telegram_len_{0}; - size_t crypt_bytes_read_{0}; - uint32_t last_read_time_{0}; - bool header_found_{false}; - bool footer_found_{false}; - - // handled outside dsmr + GPIOPin *request_pin_{nullptr}; text_sensor::TextSensor *s_telegram_{nullptr}; - -// Sensor member pointers #define DSMR_DECLARE_SENSOR(s) sensor::Sensor *s_##s##_{nullptr}; DSMR_SENSOR_LIST(DSMR_DECLARE_SENSOR, ) - #define DSMR_DECLARE_TEXT_SENSOR(s) text_sensor::TextSensor *s_##s##_{nullptr}; DSMR_TEXT_SENSOR_LIST(DSMR_DECLARE_TEXT_SENSOR, ) - std::vector decryption_key_{}; - bool crc_check_; + // State + uint32_t last_request_time_{0}; + uint32_t last_read_time_{0}; + bool requesting_data_{false}; + bool encryption_enabled_{false}; + size_t buffer_pos_{0}; + std::vector buffer_; + dsmr_parser::PacketAccumulator packet_accumulator_; + Aes128GcmDecryptorImpl gcm_decryptor_; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr + +#endif diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index c49614eaa..292e5a115 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, DEVICE_CLASS_WATER, @@ -119,6 +120,42 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_delivered_tariff2_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_delivered_tariff3_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff1_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff2_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("energy_returned_tariff3_il"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("total_imported_energy"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=3, @@ -511,6 +548,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_GAS, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("gas_delivered_gj"): sensor.sensor_schema( + unit_of_measurement=UNIT_GIGA_JOULE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("water_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_CUBIC_METER, accuracy_decimals=3, @@ -614,6 +657,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("active_demand_net"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional("active_demand_abs"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT, accuracy_decimals=3, @@ -728,6 +777,37 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l1"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l2"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_factor_l3"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("min_power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("period_3_for_instantaneous_values"): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -746,6 +826,7 @@ async def to_code(config): sensors.append(f"F({key})") if sensors: + cg.add_define("DSMR_SENSOR_LIST_DEFINED") cg.add_define( "DSMR_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) ) diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index 203c9c997..a8f29c7ca 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -15,7 +15,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_failure_log_il"): text_sensor.text_sensor_schema(), cv.Optional("message_short"): text_sensor.text_sensor_schema(), cv.Optional("message_long"): text_sensor.text_sensor_schema(), cv.Optional("equipment_id"): text_sensor.text_sensor_schema(), @@ -52,6 +54,7 @@ async def to_code(config): text_sensors.append(f"F({key})") if text_sensors: + cg.add_define("DSMR_TEXT_SENSOR_LIST_DEFINED") cg.add_define( "DSMR_TEXT_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(text_sensors)), diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp new file mode 100644 index 000000000..6fb7e1ac1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp @@ -0,0 +1,97 @@ +#include "epaper_spi_ssd1683.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.mono"; + +void EPaperSSD1683::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + this->cmd_data(0x3C, {partial ? (uint8_t) 0x80 : (uint8_t) 0x01}); + // On partial update, set red RAM to inverse to remove BW ghosting + this->cmd_data(0x21, {partial ? (uint8_t) 0x80 : (uint8_t) 0x40, (uint8_t) 0x00}); + // Set full update to 0xD7 for fast update, 0xF7 for normal + // Fast update flashes less and draws sooner but is in busy state for the same amount of time + // Manufacturer recommends not using fast update all the time, TODO expose this to the user + this->cmd_data(0x22, {partial ? (uint8_t) 0xFC : (uint8_t) 0xF7}); + this->command(0x20); +} + +// Puts the display into deep sleep mode 1, only way to get out is to reset the display +// Mode 1 retains RAM while sleeping, necessary for future partial and window updates +void EPaperSSD1683::deep_sleep() { + if (this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep mode 1"); + this->cmd_data(0x10, {0x01}); // deep sleep, retain RAM + } else { + ESP_LOGV(TAG, "Deep sleep mode 2"); + this->cmd_data(0x10, {0x03}); // deep sleep, lose RAM + } +} + +void EPaperSSD1683::set_window() { + // if not using partial update, the display will go into deep sleep mode 2, so must rewrite entire + // buffer since the display RAM will not retain contents + if (!this->is_using_partial_update_()) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + + // round x-coordinates to byte boundaries + this->x_low_ /= 8; + this->x_high_ += 7; + this->x_high_ /= 8; + + this->cmd_data(0x44, {(uint8_t) this->x_low_, (uint8_t) (this->x_high_ - 1)}); + this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1), + (uint8_t) ((this->y_high_ - 1) / 256)}); + this->cmd_data(0x4E, {(uint8_t) this->x_low_}); + this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)}); +} + +bool HOT EPaperSSD1683::transfer_data() { + auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->send_red_) { + // round to byte boundaries + this->set_window(); + } + // for monochrome, we need to send red on every refresh to prevent dirty pixels + // when doing a partial refresh + this->command(this->send_red_ ? 0x26 : 0x24); + this->current_data_index_ = this->y_low_; // actually current line + } + size_t row_length = this->x_high_ - this->x_low_; + FixedVector bytes_to_send{}; + bytes_to_send.init(row_length); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_; + for (size_t i = 0; i != row_length; i++) { + bytes_to_send[i] = this->buffer_[data_idx++]; + } + ++this->current_data_index_; + this->write_array(&bytes_to_send.front(), row_length); // NOLINT + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (this->send_red_) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1683.h b/esphome/components/epaper_spi/epaper_spi_ssd1683.h new file mode 100644 index 000000000..4532900dd --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { +/** + * A class for Solomon SSD1683 epaper displays. + */ +class EPaperSSD1683 : public EPaperMono { + public: + EPaperSSD1683(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperMono(name, width, height, init_sequence, init_sequence_length) {} + + protected: + void refresh_screen(bool partial) override; + void deep_sleep() override; + void set_window() override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py new file mode 100644 index 000000000..983f5bb38 --- /dev/null +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -0,0 +1,27 @@ +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class SSD1683(EpaperModel): + def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): + defaults[CONF_DATA_RATE] = data_rate + super().__init__(name, class_name, **defaults) + + # fmt: off + def get_init_sequence(self, config: dict): + _width, height = self.get_dimensions(config) + return ( + (0x01, (height - 1) % 256, (height - 1) // 256, 0x00), # Set column gate limit + (0x18, 0x80), # Select internal Temp sensor + (0x11, 0x03), # Set transform + ) + + +ssd1683 = SSD1683("ssd1683") + +goodisplay_gdey042t81 = ssd1683.extend( + "goodisplay-gdey042t81-4.2", + width=400, + height=300, +) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f27690c97..1a7ae700c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -33,6 +33,7 @@ from esphome.const import ( CONF_TYPE, CONF_VARIANT, CONF_VERSION, + CONF_WATCHDOG_TIMEOUT, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, @@ -128,23 +129,30 @@ ASSERTION_LEVELS = { SIGNING_SCHEMES = { "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } -# Chip variants that only support one signing scheme for Secure Boot V2. +# Chip variants that only support one V2 signing scheme. # Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. -# Variants not listed in either set support both RSA and ECDSA +# Variants not listed in either set support both RSA and ECDSA V2 # (e.g. C5, C6, H2, P4). New variants should be added to the # appropriate set if they only support one scheme. -SIGNED_OTA_RSA_ONLY_VARIANTS = { - VARIANT_ESP32, +# Note: VARIANT_ESP32 is not listed here because it supports V2 RSA only +# when minimum_chip_revision >= 3.0, which requires special handling. +SIGNED_OTA_V2_RSA_ONLY_VARIANTS = { VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32C3, } -SIGNED_OTA_ECC_ONLY_VARIANTS = { +SIGNED_OTA_V2_ECC_ONLY_VARIANTS = { VARIANT_ESP32C2, VARIANT_ESP32C61, } +# V1 ECDSA (Secure Boot V1) is only supported on the original ESP32. +# Based on SOC_SECURE_BOOT_V1 in soc_caps.h. +SIGNED_OTA_V1_ECDSA_VARIANTS = { + VARIANT_ESP32, +} COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", @@ -671,11 +679,12 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 7), - "latest": cv.Version(3, 3, 7), - "dev": cv.Version(3, 3, 7), + "recommended": cv.Version(3, 3, 8), + "latest": cv.Version(3, 3, 8), + "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), cv.Version(3, 3, 5): cv.Version(55, 3, 35), @@ -695,6 +704,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), cv.Version(3, 3, 5): cv.Version(5, 5, 2), @@ -714,17 +724,15 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 3, "1"), - "latest": cv.Version(5, 5, 3, "1"), + "recommended": cv.Version(5, 5, 4), + "latest": cv.Version(5, 5, 4), "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version( - 5, 5, 4 - ): "https://github.com/pioarduino/platform-espressif32.git#develop", + cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -744,8 +752,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 37), - "latest": cv.Version(55, 3, 37), + "recommended": cv.Version(55, 3, 38, "1"), + "latest": cv.Version(55, 3, 38, "1"), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } @@ -991,25 +999,73 @@ def final_validate(config): if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): scheme = signed_ota[CONF_SIGNING_SCHEME] variant = config[CONF_VARIANT] - scheme_variant_conflicts = { - "ecdsa256": (SIGNED_OTA_RSA_ONLY_VARIANTS, "rsa3072"), - "rsa3072": (SIGNED_OTA_ECC_ONLY_VARIANTS, "ecdsa256"), - } - if (conflict := scheme_variant_conflicts.get(scheme)) and variant in conflict[ - 0 - ]: + min_rev = advanced.get(CONF_MINIMUM_CHIP_REVISION) + scheme_path = [ + CONF_FRAMEWORK, + CONF_ADVANCED, + CONF_SIGNED_OTA_VERIFICATION, + CONF_SIGNING_SCHEME, + ] + + # V1 ECDSA is only available on the original ESP32 + if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: errs.append( cv.Invalid( - f"Signing scheme '{scheme}' is not supported on " - f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", - path=[ - CONF_FRAMEWORK, - CONF_ADVANCED, - CONF_SIGNED_OTA_VERIFICATION, - CONF_SIGNING_SCHEME, - ], + f"Signing scheme 'ecdsa_v1' is only supported on " + f"{VARIANT_FRIENDLY[VARIANT_ESP32]}. " + f"Use 'rsa3072' or 'ecdsa256' instead.", + path=scheme_path, ) ) + elif variant == VARIANT_ESP32: + # On ESP32, V2 RSA requires minimum_chip_revision >= 3.0 + # Note: string comparison works here because cv.one_of constrains + # min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1"). + if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"): + errs.append( + cv.Invalid( + f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " + f"requires minimum_chip_revision: '3.0' or higher " + f"(Secure Boot V2 RSA needs chip revision 3.0+). " + f"For older chip revisions, use 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) + elif scheme == "ecdsa256": + errs.append( + cv.Invalid( + f"Signing scheme 'ecdsa256' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use 'rsa3072' (with " + f"minimum_chip_revision: '3.0') or 'ecdsa_v1' instead.", + path=scheme_path, + ) + ) + # V1 on rev 3.0+ -- suggest V2 RSA for stronger security + elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0": + _LOGGER.info( + "Using Secure Boot V1 ECDSA on %s rev %s. " + "Consider using 'rsa3072' (Secure Boot V2 RSA) for " + "stronger security on chip revision 3.0+.", + VARIANT_FRIENDLY[variant], + min_rev, + ) + else: + # Non-ESP32 variants: check V2 scheme-variant compatibility + scheme_variant_conflicts = { + "ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"), + "rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"), + } + if ( + conflict := scheme_variant_conflicts.get(scheme) + ) and variant in conflict[0]: + errs.append( + cv.Invalid( + f"Signing scheme '{scheme}' is not supported on " + f"{VARIANT_FRIENDLY[variant]}. Use '{conflict[1]}' instead.", + path=scheme_path, + ) + ) if CONF_OTA not in full_config: _LOGGER.warning( "Signed OTA verification is enabled but no OTA component is configured. " @@ -1058,6 +1114,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" +CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" # VFS requirement tracking # Components that need VFS features can call require_vfs_*() functions @@ -1071,6 +1128,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" +KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" def require_vfs_select() -> None: @@ -1168,6 +1226,17 @@ def require_fatfs() -> None: CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True +def require_adc_oneshot_iram() -> None: + """Mark that ADC oneshot IRAM safety is required by a component. + + Call this from components that use the ADC oneshot driver. When flash cache is + disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management), + the ADC oneshot read function must be in IRAM to avoid crashes. + This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM. + """ + CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1209,7 +1278,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, cv.Optional(CONF_MINIMUM_CHIP_REVISION): cv.one_of( - *ESP32_CHIP_REVISIONS + *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, @@ -1268,6 +1337,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), @@ -1438,6 +1508,10 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, + cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), + ), } ), _detect_variant, @@ -1805,6 +1879,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) + add_idf_sdkconfig_option( + "CONFIG_ESP_TASK_WDT_TIMEOUT_S", + config[CONF_WATCHDOG_TIMEOUT].total_seconds, + ) # Disable dynamic log level control to save memory add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) @@ -2068,6 +2146,16 @@ async def to_code(config): if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + # Place ADC oneshot control functions in IRAM for cache safety + # When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread, + # power management, etc.), ADC reads will crash if these functions are in flash. + # Components using ADC call require_adc_oneshot_iram() to force this. + if ( + CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False) + or advanced[CONF_ADC_ONESHOT_IN_IRAM] + ): + add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) + # Disable FATFS support # Components that need FATFS (SD card, etc.) can call require_fatfs() if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2bd08e7c3..2c73fe7d0 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1960,6 +1960,10 @@ BOARDS = { "name": "Hornbill ESP32 Minima", "variant": VARIANT_ESP32, }, + "huidu_hd_wf1": { + "name": "Huidu HD-WF1", + "variant": VARIANT_ESP32S2, + }, "huidu_hd_wf2": { "name": "Huidu HD-WF2", "variant": VARIANT_ESP32S3, @@ -2028,6 +2032,10 @@ BOARDS = { "name": "LilyGo T-Display-S3", "variant": VARIANT_ESP32S3, }, + "lilygo-t-energy-s3": { + "name": "LilyGo T-Energy-S3", + "variant": VARIANT_ESP32S3, + }, "lilygo-t3-s3": { "name": "LilyGo T3-S3", "variant": VARIANT_ESP32S3, @@ -2289,10 +2297,18 @@ BOARDS = { "name": "S.ODI Ultra v1", "variant": VARIANT_ESP32, }, + "seeed_xiao_esp32_s3_plus": { + "name": "Seeed Studio XIAO ESP32S3 Plus", + "variant": VARIANT_ESP32S3, + }, "seeed_xiao_esp32c3": { "name": "Seeed Studio XIAO ESP32C3", "variant": VARIANT_ESP32C3, }, + "seeed_xiao_esp32c5": { + "name": "Seeed Studio XIAO ESP32C5", + "variant": VARIANT_ESP32C5, + }, "seeed_xiao_esp32c6": { "name": "Seeed Studio XIAO ESP32C6", "variant": VARIANT_ESP32C6, diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 313818e60..1c6313718 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,7 +23,26 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), +// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because +// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. +// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. +uint32_t IRAM_ATTR HOT millis() { +#if CONFIG_FREERTOS_HZ == 1000 + if (xPortInIsrContext()) [[unlikely]] { + return xTaskGetTickCountFromISR(); + } + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); +#endif +} +// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is +// safe because the two are never cross-compared: millis() values are only used for +// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while +// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME), +// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly, +// so the Scheduler is internally consistent on the esp_timer clock. uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } @@ -61,6 +80,9 @@ uint32_t arch_get_cpu_freq_hz() { } TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StackType_t + loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void loop_task(void *pv_params) { setup(); @@ -73,9 +95,11 @@ extern "C" void app_main() { initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE - xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle); + loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, + &loop_task_tcb); #else - xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1); + loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, + loop_task_stack, &loop_task_tcb, 1); #endif } diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd..36dd44155 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 8d1321425..b329f6b82 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -5,6 +5,7 @@ import json # noqa: E402 import os # noqa: E402 import pathlib # noqa: E402 import shutil # noqa: E402 +import subprocess # noqa: E402 from glob import glob # noqa: E402 @@ -25,6 +26,114 @@ def _parse_sdkconfig(sdkconfig_path): return options +def _generate_v1_verification_key(env): + """Generate the V1 ECDSA verification key binary and assembly source file. + + Secure Boot V1 embeds the public verification key directly in the app binary + as a compiled object (via a .S assembly file). The ESP-IDF CMake build generates + these files via custom commands, but PlatformIO's SCons bridge does not execute + them. This function replicates that logic: + 1. Extracts the raw public key from the PEM signing key using espsecure. + 2. Generates the .S assembly source that embeds the key bytes. + """ + build_dir = pathlib.Path(env.subst("$BUILD_DIR")) + project_dir = pathlib.Path(env.subst("$PROJECT_DIR")) + pioenv = env.subst("$PIOENV") + sdkconfig = _parse_sdkconfig(project_dir / f"sdkconfig.{pioenv}") + + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") != "y": + return + + bin_path = build_dir / "signature_verification_key.bin" + asm_path = build_dir / "signature_verification_key.bin.S" + + # Determine the source of the verification key + if sdkconfig.get("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES") == "y": + # Extract public key from the signing key + signing_key = sdkconfig.get("CONFIG_SECURE_BOOT_SIGNING_KEY") + if not signing_key: + return + signing_key_path = pathlib.Path(signing_key) + if not signing_key_path.exists(): + print(f"Error: V1 ECDSA signing key not found: {signing_key_path}") + env.Exit(1) + return + + if not bin_path.exists() or bin_path.stat().st_mtime < signing_key_path.stat().st_mtime: + python_exe = env.subst("$PYTHONEXE") + result = subprocess.run( + [python_exe, "-m", "espsecure", "extract_public_key", + "--keyfile", str(signing_key_path), str(bin_path)], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"Error extracting V1 verification key: {result.stderr}") + env.Exit(1) + return + print(f"Extracted V1 ECDSA verification key from {signing_key_path.name}") + else: + # User-provided verification key -- should already be a raw binary file + verification_key = sdkconfig.get("CONFIG_SECURE_BOOT_VERIFICATION_KEY") + if not verification_key: + return + verification_key_path = pathlib.Path(verification_key) + if not verification_key_path.exists(): + print(f"Error: Verification key not found: {verification_key_path}") + env.Exit(1) + return + shutil.copyfile(str(verification_key_path), str(bin_path)) + + if not bin_path.exists(): + return + + # Generate the .S assembly file from the binary key data. + # Replicates ESP-IDF's data_file_embed_asm.cmake with RENAME_TO=signature_verification_key_bin. + # The file is needed in both the app build dir and the bootloader build dir, since + # the bootloader also embeds the verification key when CONFIG_SECURE_SIGNED_ON_BOOT_NO_SECURE_BOOT + # is enabled. PlatformIO's SCons bridge does not execute the CMake custom commands that + # normally generate these files. + data = bin_path.read_bytes() + varname = "signature_verification_key_bin" + + lines = [] + lines.append(f"/* Data converted from {bin_path.name} */") + lines.append(".data") + lines.append("#if !defined (__APPLE__) && !defined (__linux__)") + lines.append(".section .rodata.embedded") + lines.append("#endif") + lines.append(f"\n.global {varname}") + lines.append(f"{varname}:") + lines.append(f"\n.global _binary_{varname}_start") + lines.append(f"_binary_{varname}_start: /* for objcopy compatibility */") + + # Format binary data as .byte lines (16 bytes per line) + for i in range(0, len(data), 16): + chunk = data[i:i + 16] + hex_bytes = ", ".join(f"0x{b:02x}" for b in chunk) + lines.append(f".byte {hex_bytes}") + + lines.append(f"\n.global _binary_{varname}_end") + lines.append(f"_binary_{varname}_end: /* for objcopy compatibility */") + lines.append(f"\n.global {varname}_length") + lines.append(f"{varname}_length:") + lines.append(f".long {len(data)}") + lines.append("") + lines.append('#if defined (__linux__)') + lines.append('.section .note.GNU-stack,"",@progbits') + lines.append("#endif") + + asm_content = "\n".join(lines) + "\n" + + # Write to app build dir and bootloader build dir + asm_path.write_text(asm_content) + bootloader_dir = build_dir / "bootloader" + if bootloader_dir.is_dir(): + bootloader_bin = bootloader_dir / "signature_verification_key.bin" + bootloader_asm = bootloader_dir / "signature_verification_key.bin.S" + shutil.copyfile(str(bin_path), str(bootloader_bin)) + bootloader_asm.write_text(asm_content) + + def sign_firmware(source, target, env): """ Sign the firmware binary using espsecure.py if signed OTA verification is enabled. @@ -55,9 +164,12 @@ def sign_firmware(source, target, env): env.Exit(1) return - # ESPHome only exposes RSA3072 and ECDSA256 (both Secure Boot V2 schemes), - # so the espsecure signature version is always 2. - sign_version = "2" + # Determine espsecure signature version from the signing scheme: + # V1 ECDSA (Secure Boot V1) uses --version 1, V2 RSA/ECDSA use --version 2. + if sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME") == "y": + sign_version = "1" + else: + sign_version = "2" firmware_name = os.path.basename(env.subst("$PROGNAME")) + ".bin" firmware_path = build_dir / firmware_name @@ -217,6 +329,11 @@ def esp32_copy_ota_bin(source, target, env): print(f"Copied firmware to {new_file_name}") +# Generate V1 ECDSA verification key files before build starts. +# Workaround for PlatformIO not executing CMake custom commands that extract +# the public key and generate the .S assembly file for Secure Boot V1. +_generate_v1_verification_key(env) # noqa: F821 + # Run signing first, then merge, then ota copy env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", sign_firmware) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe..925c4e766 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -4,7 +4,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include #include #include @@ -12,9 +11,6 @@ namespace esphome::esp32 { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); size_t actual_len; esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { @@ -108,8 +104,8 @@ bool ESP32Preferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str); if (this->is_changed_(this->nvs_handle, save, key_str)) { esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size()); diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 79d05049b..c7b6b4039 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv @@ -342,6 +343,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) ), + cv.Optional(CONF_USE_PSRAM): cv.All( + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -598,6 +602,22 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for + # heap allocations and use dynamic (heap-based) environment memory tables + # instead of large static DRAM arrays. This frees ~40 kB of internal RAM. + # Reference: Espressif ADF Design Considerations + # https://espressif-docs.readthedocs-hosted.com/projects/esp-adf/en/latest/ + # design-guide/design-considerations.html + if config.get(CONF_USE_PSRAM, False): + cg.add_define("USE_ESP32_BLE_PSRAM") + # CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST is only available on ESP32 + # (BTDM dual-mode controller). BLE-only SoCs (C3, S3, C2, H2) do not + # expose this Kconfig symbol; applying it there would cause a build error. + if get_esp32_variant() == const.VARIANT_ESP32: + add_idf_sdkconfig_option("CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST", True) + # CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY applies to all Bluedroid-enabled variants. + add_idf_sdkconfig_option("CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY", True) + # Register the core BLE loggers that are always needed register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 028043973..6bbf0d6a2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -257,11 +257,9 @@ bool ESP32BLE::ble_setup_() { if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr, @@ -667,6 +665,9 @@ void ESP32BLE::dump_config() { " MAC address: %s\n" " IO Capability: %s", mac_s, io_capability_s); +#ifdef USE_ESP32_BLE_PSRAM + ESP_LOGCONFIG(TAG, " PSRAM BLE allocation: enabled"); +#endif #ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS const char *auth_req_mode_s = ""; diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd2..bfa5ffb55 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,6 +78,14 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) + if len(merged_ota_esphome_configs_by_port) > 1: + raise cv.Invalid( + f"Only a single port is supported for '{CONF_OTA}' " + f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports " + f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate " + f"onto a single port; configs sharing a port are merged automatically." + ) + new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) full_conf[CONF_OTA] = new_ota_conf @@ -142,11 +150,17 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) - # Password could be set to an empty string and we can assume that means no password - if config.get(CONF_PASSWORD): - cg.add(var.set_auth_password(config[CONF_PASSWORD])) + # Compile the auth path whenever `password:` is present in YAML, even if empty. + # An empty password opts in to the auth code path so set_auth_password() can be + # called at runtime (e.g. to rotate the password from a lambda). When `password:` + # is omitted entirely, the auth path is excluded to save flash on small devices. + if CONF_PASSWORD in config: cg.add_define("USE_OTA_PASSWORD") + if config[CONF_PASSWORD]: + cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. + cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") await cg.register_component(var, config) await ota_to_code(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19..47f661a8e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif #include #include @@ -28,6 +31,17 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Single-instance pointer — multi-port configs are rejected in final_validate. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static ESPHomeOTAComponent *global_esphome_ota_component = nullptr; + +// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ). +extern "C" void esphome_wake_ota_component_any_context() { + if (global_esphome_ota_component != nullptr) { + global_esphome_ota_component->enable_loop_soon_any_context(); + } +} + void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { @@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("listen")); return; } + + // loop() self-disables on its first idle tick; no explicit disable_loop() needed here. + global_esphome_ota_component = this; +#ifdef USE_LWIP_FAST_SELECT + // Filter fast-select wakes to this listener only. If the sock lookup returns nullptr, + // no wakes fire and loop() falls back to the self-disable safety net. + esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -81,13 +103,15 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_handshake_() call if no client connected and no incoming connections - // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() - // if server_ creation fails - if (this->client_ != nullptr || this->server_->ready()) { - this->handle_handshake_(); + // Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select + // listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back + // to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more + // iteration so a connection queued mid-session is still caught here. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } + this->handle_handshake_(); } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; @@ -566,6 +590,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif + // Intentionally no disable_loop() — letting loop() run one more iteration catches + // any connection that queued on the listener mid-session (otherwise the wake flag, + // set while we were in LOOP state, would be lost to enable_pending_loops_()). } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f3a595239..53288fc00 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -28,6 +28,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } +#else + // Stub so lambdas referencing set_auth_password() produce a clear error instead of + // a cryptic "no member" diagnostic. Only fires if the stub is actually instantiated. + template void set_auth_password(const std::string &) { + static_assert(B, "set_auth_password() requires the OTA auth path to be compiled. " + "Add 'password: \"\"' (empty string) to your 'ota: - platform: esphome' " + "config to enable runtime password rotation."); + } #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3a8784231..17c84ee95 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -221,7 +221,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI3_HOST}; + spi_host_device_t interface_{SPI2_HOST}; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a1339a4bc..a10c45a9d 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -325,7 +325,7 @@ def download_gfont(value): raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" - ) + ) from e match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) if match is None: raise cv.Invalid( diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index ec6730a41..46725fe6d 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) + # Use the global's value_type alias as the lambda return type so + # TemplatableFn stores a direct function pointer instead of going through + # the deprecated converting trampoline when the value expression deduces + # to a different type (e.g. int literal assigned to a float global). + value_type = cg.RawExpression(f"{full_id.type}::value_type") templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True + config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression ) cg.add(var.set_value(templ)) return var diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 3c2021d40..390b26ba1 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -60,20 +60,35 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): - var = await binary_sensor.new_binary_sensor(config) - await cg.register_component(var, config) +def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: + """Check if pin is shared exclusively with deep_sleep (wakeup pin).""" + pin_key = (CORE.target_platform, CORE.target_platform, pin_num) + pin_users = pins.PIN_SCHEMA_REGISTRY.pins_used.get(pin_key, []) + if len(pin_users) != 2: + return False + return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - # Check for ESP8266 GPIO16 interrupt limitation - # GPIO16 on ESP8266 is a special pin that doesn't support interrupts through - # the Arduino attachInterrupt() function. This is the only known GPIO pin - # across all supported platforms that has this limitation, so we handle it - # here instead of in the platform-specific code. +def _final_validate(config): use_interrupt = config[CONF_USE_INTERRUPT] - if use_interrupt and CORE.is_esp8266 and config[CONF_PIN][CONF_NUMBER] == 16: + if not use_interrupt: + return config + + pin_num = config[CONF_PIN][CONF_NUMBER] + + # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt + # attachment — only internal/native GPIO pins do. + if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform: + _LOGGER.info( + "GPIO binary_sensor '%s': Pin is not an internal GPIO, " + "falling back to polling mode.", + config.get(CONF_NAME, config[CONF_ID]), + ) + config[CONF_USE_INTERRUPT] = False + return config + + # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). + if CORE.is_esp8266 and pin_num == 16: _LOGGER.warning( "GPIO binary_sensor '%s': GPIO16 on ESP8266 doesn't support interrupts. " "Falling back to polling mode (same as in ESPHome <2025.7). " @@ -81,22 +96,45 @@ async def to_code(config): "performance with interrupts.", config.get(CONF_NAME, config[CONF_ID]), ) - use_interrupt = False + config[CONF_USE_INTERRUPT] = False + return config - # Check if pin is shared with other components (allow_other_uses) # When a pin is shared, interrupts can interfere with other components - # (e.g., duty_cycle sensor) that need to monitor the pin's state changes - if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): - _LOGGER.info( - "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. " - "The sensor will use polling mode for compatibility with other pin uses.", - config.get(CONF_NAME, config[CONF_ID]), - config[CONF_PIN][CONF_NUMBER], - ) - use_interrupt = False + # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. + # Exception: deep_sleep wakeup pins are compatible with interrupts when + # the pin is only shared between this sensor and deep_sleep (count == 2). + if config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): + if not _pin_shared_only_with_deep_sleep(pin_num): + _LOGGER.info( + "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared " + "with other components. The sensor will use polling mode for " + "compatibility with other pin uses.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) + config[CONF_USE_INTERRUPT] = False + else: + _LOGGER.debug( + "GPIO binary_sensor '%s': Pin %s is shared with deep_sleep, " + "keeping interrupts enabled.", + config.get(CONF_NAME, config[CONF_ID]), + pin_num, + ) - if use_interrupt: + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + + if config[CONF_USE_INTERRUPT]: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: - # Only generate call when disabling interrupts (default is true) - cg.add(var.set_use_interrupt(use_interrupt)) + cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 39b1a2f71..1f0154c70 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -46,11 +46,6 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { } void GPIOBinarySensor::setup() { - if (this->store_.use_interrupt_ && !this->pin_->is_internal()) { - ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode"); - this->store_.use_interrupt_ = false; - } - if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 7743da77a..b7e043748 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -127,6 +127,6 @@ async def to_code(config): cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_library("tonia/HeatpumpIR", "1.0.40") + cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274f..b067ebbf6 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); } uint32_t IRAM_ATTR HOT millis() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t ms = round(spec.tv_nsec / 1e6); - return ((uint32_t) seconds) * 1000U + ms; + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); } uint64_t millis_64() { struct timespec spec; @@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) { uint32_t IRAM_ATTR HOT micros() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = round(spec.tv_nsec / 1e3); - return ((uint32_t) seconds) * 1000000U + us; + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { struct timespec ts; diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 2c74638f1..d45208ed5 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -22,7 +22,7 @@ void HttpRequestComponent::dump_config() { } std::string HttpContainer::get_response_header(const std::string &header_name) { - auto lower = str_lower_case(header_name); + auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index ae73983ba..f37bf7763 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -400,7 +401,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, request_headers, lower); } @@ -415,7 +416,7 @@ class HttpRequestComponent : public Component { std::vector lower; lower.reserve(collect_headers.size()); for (const auto &h : collect_headers) { - lower.push_back(str_lower_case(h)); + lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index f0dd64928..05f9db1c0 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -161,7 +161,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->response_headers_.clear(); auto header_count = container->client_.headers(); for (int i = 0; i < header_count; i++) { - const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); + const std::string header_name = str_lower_case(container->client_.headerName(i).c_str()); // NOLINT if (should_collect_header(lower_case_collect_headers, header_name)) { std::string header_value = container->client_.header(i).c_str(); ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 60ab4d68a..85c6e8b3c 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -115,7 +115,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, container->content_length = container->response_body_.size(); for (auto header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); - auto lower_name = str_lower_case(header.first); + auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { container->response_headers_.push_back({lower_name, header.second}); } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 30f53eecd..3e341395a 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -38,7 +38,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { - const std::string header_name = str_lower_case(evt->header_key); + const std::string header_name = str_lower_case(evt->header_key); // NOLINT if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 761cbb7f4..1392d1d4e 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_( ) INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] -PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] def _validate_esp32_variant(config): diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index d1d1bc3ee..99aa712c6 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -33,13 +33,16 @@ AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] DEPENDENCIES = ["i2s_audio"] -I2SAudioSpeaker = i2s_audio_ns.class_( - "I2SAudioSpeaker", cg.Component, speaker.Speaker, I2SAudioOut +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut ) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t") INTERNAL_DAC_OPTIONS = { CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN, @@ -183,11 +186,11 @@ async def to_code(config): await speaker.register_speaker(var, config) cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) - fmt = "std" # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long + fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: - fmt = "msb" + fmt = I2SCommFmt.MSB elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: - fmt = "pcm" + fmt = I2SCommFmt.PCM cg.add(var.set_i2s_comm_fmt(fmt)) if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index dde1f70bc..836221e38 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -13,36 +13,10 @@ #include "esp_timer.h" -namespace esphome { -namespace i2s_audio { - -static const uint32_t DMA_BUFFER_DURATION_MS = 15; -static const size_t DMA_BUFFERS_COUNT = 4; - -static const size_t TASK_STACK_SIZE = 4096; -static const ssize_t TASK_PRIORITY = 19; - -static const size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1; +namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -enum SpeakerEventGroupBits : uint32_t { - COMMAND_START = (1 << 0), // indicates loop should start speaker task - COMMAND_STOP = (1 << 1), // stops the speaker task - COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written - - TASK_STARTING = (1 << 10), - TASK_RUNNING = (1 << 11), - TASK_STOPPING = (1 << 12), - TASK_STOPPED = (1 << 13), - - ERR_ESP_NO_MEM = (1 << 19), - - WARN_DROPPED_EVENT = (1 << 20), - - ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits -}; - // Lists the Q15 fixed point scaling factor for volume reduction. // Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB. // dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) @@ -56,17 +30,21 @@ static const std::vector Q15_VOLUME_SCALING_FACTORS = { 8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415, 19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767}; -void I2SAudioSpeaker::setup() { +void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); if (this->event_group_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event group"); + ESP_LOGE(TAG, "Event group creation failed"); this->mark_failed(); return; } + + // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // When no audio_dac is configured, this initializes software volume control. + this->set_volume(this->volume_); } -void I2SAudioSpeaker::dump_config() { +void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" @@ -75,10 +53,9 @@ void I2SAudioSpeaker::dump_config() { if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } - ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str()); } -void I2SAudioSpeaker::loop() { +void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { @@ -92,12 +69,12 @@ void I2SAudioSpeaker::loop() { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); } if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) { - ESP_LOGD(TAG, "Started"); + ESP_LOGV(TAG, "Started"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); this->state_ = speaker::STATE_RUNNING; } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { - ESP_LOGD(TAG, "Stopping"); + ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -111,10 +88,12 @@ void I2SAudioSpeaker::loop() { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); this->status_clear_error(); + this->on_task_stopped(); + this->state_ = speaker::STATE_STOPPED; } - // Log any errors encounted by the task + // Log any errors encountered by the task if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { ESP_LOGE(TAG, "Not enough memory"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); @@ -133,14 +112,14 @@ void I2SAudioSpeaker::loop() { break; } - if (this->start_i2s_driver_(this->audio_stream_info_) != ESP_OK) { + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver-faiure", 1000); + this->status_momentary_error("driver-failure", 1000); break; } if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeaker::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); if (this->speaker_task_handle_ == nullptr) { @@ -157,7 +136,7 @@ void I2SAudioSpeaker::loop() { } } -void I2SAudioSpeaker::set_volume(float volume) { +void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { @@ -166,15 +145,21 @@ void I2SAudioSpeaker::set_volume(float volume) { } this->audio_dac_->set_volume(volume); } else -#endif +#endif // USE_AUDIO_DAC { - // Fallback to software volume control by using a Q15 fixed point scaling factor - ssize_t decibel_index = remap(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1); - this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index]; + // Fallback to software volume control by using a Q15 fixed point scaling factor. + // At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing + // and avoid any floating-point precision issues that could cause slight volume reduction. + if (volume >= 1.0f) { + this->q15_volume_factor_ = INT16_MAX; + } else { + ssize_t decibel_index = remap(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1); + this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index]; + } } } -void I2SAudioSpeaker::set_mute_state(bool mute_state) { +void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC if (this->audio_dac_) { @@ -184,7 +169,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) { this->audio_dac_->set_mute_off(); } } else -#endif +#endif // USE_AUDIO_DAC { if (mute_state) { // Fallback to software volume control and scale by 0 @@ -196,11 +181,12 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) { } } -size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { +size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { if (this->is_failed()) { ESP_LOGE(TAG, "Setup failed; cannot play audio"); return 0; } + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { this->start(); } @@ -214,8 +200,8 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick size_t bytes_written = 0; if (this->state_ == speaker::STATE_RUNNING) { std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); - if (temp_ring_buffer.use_count() == 2) { - // Only the speaker task and this temp_ring_buffer own the ring buffer, so its safe to write to + if (temp_ring_buffer != nullptr) { + // The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait); } } @@ -223,7 +209,7 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick return bytes_written; } -bool I2SAudioSpeaker::has_buffered_data() const { +bool I2SAudioSpeakerBase::has_buffered_data() const { if (this->audio_ring_buffer_.use_count() > 0) { std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); return temp_ring_buffer->available() > 0; @@ -231,216 +217,27 @@ bool I2SAudioSpeaker::has_buffered_data() const { return false; } -void I2SAudioSpeaker::speaker_task(void *params) { - I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) params; - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STARTING); - - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; - // Ensure ring buffer duration is at least the duration of all DMA buffers - const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this_speaker->buffer_duration_ms_); - - // The DMA buffers may have more bits per sample, so calculate buffer sizes based in the input audio stream info - const size_t ring_buffer_size = this_speaker->current_stream_info_.ms_to_bytes(ring_buffer_duration); - - const uint32_t frames_to_fill_single_dma_buffer = - this_speaker->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t bytes_to_fill_single_dma_buffer = - this_speaker->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); - - bool successful_setup = false; - std::unique_ptr transfer_buffer = - audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); - - if (transfer_buffer != nullptr) { - std::shared_ptr temp_ring_buffer = RingBuffer::create(ring_buffer_size); - if (temp_ring_buffer.use_count() == 1) { - transfer_buffer->set_source(temp_ring_buffer); - this_speaker->audio_ring_buffer_ = temp_ring_buffer; - successful_setup = true; - } - } - - if (!successful_setup) { - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - bool stop_gracefully = false; - bool tx_dma_underflow = true; - - uint32_t frames_written = 0; - uint32_t last_data_received_time = millis(); - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_RUNNING); - - while (this_speaker->pause_state_ || !this_speaker->timeout_.has_value() || - (millis() - last_data_received_time) <= this_speaker->timeout_.value()) { - uint32_t event_group_bits = xEventGroupGetBits(this_speaker->event_group_); - - if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - break; - } - if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { - xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); - stop_gracefully = true; - } - - if (this_speaker->audio_stream_info_ != this_speaker->current_stream_info_) { - // Audio stream info changed, stop the speaker task so it will restart with the proper settings. - break; - } - int64_t write_timestamp; - while (xQueueReceive(this_speaker->i2s_event_queue_, &write_timestamp, 0)) { - // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes - // on the timing info via the audio_output_callback. - uint32_t frames_sent = frames_to_fill_single_dma_buffer; - if (frames_to_fill_single_dma_buffer > frames_written) { - tx_dma_underflow = true; - frames_sent = frames_written; - const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; - write_timestamp -= this_speaker->current_stream_info_.frames_to_microseconds(frames_zeroed); - } else { - tx_dma_underflow = false; - } - frames_written -= frames_sent; - if (frames_sent > 0) { - this_speaker->audio_output_callback_(frames_sent, write_timestamp); - } - } - - if (this_speaker->pause_state_) { - // Pause state is accessed atomically, so thread safe - // Delay so the task yields, then skip transferring audio data - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); - continue; - } - - // Wait half the duration of the data already written to the DMA buffers for new audio data - // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 - const uint32_t read_delay = - (this_speaker->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; - - size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); - uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; - - if (bytes_read > 0) { - if (this_speaker->q15_volume_factor_ < INT16_MAX) { - // Apply the software volume adjustment by unpacking the sample into a Q31 fixed-point number, shifting it, - // multiplying by the volume factor, and packing the sample back into the original bytes per sample. - - const size_t bytes_per_sample = this_speaker->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - // Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31 - int32_t shift = 15; // Q31 -> Q16 - int32_t gain_factor = this_speaker->q15_volume_factor_; // Q15 - - if (bytes_per_sample >= 3) { - // Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31 - - shift = 8; // Q31 -> Q23 - gain_factor >>= 7; // Q15 -> Q8 - } - - for (uint32_t i = 0; i < len; ++i) { - int32_t sample = - audio::unpack_audio_sample_to_q31(&new_data[i * bytes_per_sample], bytes_per_sample); // Q31 - sample >>= shift; - sample *= gain_factor; // Q31 - audio::pack_q31_as_audio_sample(sample, &new_data[i * bytes_per_sample], bytes_per_sample); - } - } - -#ifdef USE_ESP32_VARIANT_ESP32 - // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this_speaker->current_stream_info_.get_channels() == 1 && - this_speaker->current_stream_info_.get_bits_per_sample() == 16) { - int16_t *samples = reinterpret_cast(new_data); - size_t sample_count = bytes_read / sizeof(int16_t); - for (size_t i = 0; i + 1 < sample_count; i += 2) { - int16_t tmp = samples[i]; - samples[i] = samples[i + 1]; - samples[i + 1] = tmp; - } - } -#endif - } - - if (transfer_buffer->available() == 0) { - if (stop_gracefully && tx_dma_underflow) { - break; - } - vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2)); - } else { - size_t bytes_written = 0; - if (tx_dma_underflow) { - // Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue so timing - // callbacks are accurate. Preload the data. - i2s_channel_disable(this_speaker->tx_handle_); - const i2s_event_callbacks_t callbacks = { - .on_sent = nullptr, - }; - - i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker); - i2s_channel_preload_data(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), - transfer_buffer->available(), &bytes_written); - } else { - // Audio is already playing, use regular I2S write to add to the DMA buffers - i2s_channel_write(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), - &bytes_written, DMA_BUFFER_DURATION_MS); - } - if (bytes_written > 0) { - last_data_received_time = millis(); - frames_written += this_speaker->current_stream_info_.bytes_to_frames(bytes_written); - transfer_buffer->decrease_buffer_length(bytes_written); - if (tx_dma_underflow) { - tx_dma_underflow = false; - // Reset the event queue timestamps - // Enable the on_sent callback to accurately track the timestamps of played audio - // Enable the I2S channel to start sending the preloaded audio - - xQueueReset(this_speaker->i2s_event_queue_); - - const i2s_event_callbacks_t callbacks = { - .on_sent = i2s_on_sent_cb, - }; - i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker); - - i2s_channel_enable(this_speaker->tx_handle_); - } - } - } - } - } - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPING); - - if (transfer_buffer != nullptr) { - transfer_buffer.reset(); - } - - xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPED); - - while (true) { - // Continuously delay until the loop method deletes the task - vTaskDelay(pdMS_TO_TICKS(10)); - } +void I2SAudioSpeakerBase::speaker_task(void *params) { + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params; + this_speaker->run_speaker_task(); } -void I2SAudioSpeaker::start() { +void I2SAudioSpeakerBase::start() { if (!this->is_ready() || this->is_failed() || this->status_has_error()) return; if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; + // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. + this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } -void I2SAudioSpeaker::stop() { this->stop_(false); } +void I2SAudioSpeakerBase::stop() { this->stop_(false); } -void I2SAudioSpeaker::finish() { this->stop_(true); } +void I2SAudioSpeakerBase::finish() { this->stop_(true); } -void I2SAudioSpeaker::stop_(bool wait_on_empty) { +void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { if (this->is_failed()) return; if (this->state_ == speaker::STATE_STOPPED) @@ -453,105 +250,16 @@ void I2SAudioSpeaker::stop_(bool wait_on_empty) { } } -esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; // store the stream info settings the driver will use - - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT - // Can't reconfigure I2S bus, so the sample rate must match the configured value - ESP_LOGE(TAG, "Audio stream settings are not compatible with this I2S configuration"); - return ESP_ERR_NOT_SUPPORTED; - } - - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Audio streams with more bits per sample than the I2S speaker's configuration is not supported"); - return ESP_ERR_NOT_SUPPORTED; - } - - if (!this->parent_->try_lock()) { - ESP_LOGE(TAG, "Parent I2S bus not free"); - return ESP_ERR_INVALID_STATE; - } - - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); - - i2s_chan_config_t chan_cfg = { - .id = this->parent_->get_port(), - .role = this->i2s_role_, - .dma_desc_num = DMA_BUFFERS_COUNT, - .dma_frame_num = dma_buffer_length, - .auto_clear = true, - .intr_priority = 3, - }; - /* Allocate a new TX channel and get the handle of this channel */ +esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size) { esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL); if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to allocate new I2S channel"); + ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err)); this->parent_->unlock(); return err; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; -#ifdef I2S_CLK_SRC_APLL - if (this->use_apll_) { - clk_src = I2S_CLK_SRC_APLL; - } -#endif - i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); - - i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = audio_stream_info.get_sample_rate(), - .clk_src = clk_src, - .mclk_multiple = this->mclk_multiple_, - }; - - i2s_slot_mode_t slot_mode = this->slot_mode_; - i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; - if (audio_stream_info.get_channels() == 1) { - slot_mode = I2S_SLOT_MODE_MONO; - } else if (audio_stream_info.get_channels() == 2) { - slot_mode = I2S_SLOT_MODE_STEREO; - slot_mask = I2S_STD_SLOT_BOTH; - } - - i2s_std_slot_config_t std_slot_cfg; - if (this->i2s_comm_fmt_ == "std") { - std_slot_cfg = - I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } else if (this->i2s_comm_fmt_ == "pcm") { - std_slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } else { - std_slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); - } -#ifdef USE_ESP32_VARIANT_ESP32 - // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher then the bits - // per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems to - // make it play at the correct speed while sending more bits per slot. - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { - uint32_t configured_bit_width = static_cast(this->slot_bit_width_); - std_slot_cfg.ws_width = configured_bit_width; - if (configured_bit_width > 16) { - std_slot_cfg.msb_right = false; - } - } -#else - std_slot_cfg.slot_bit_width = this->slot_bit_width_; -#endif - std_slot_cfg.slot_mask = slot_mask; - - pin_config.dout = this->dout_pin_; - - i2s_std_config_t std_cfg = { - .clk_cfg = clk_cfg, - .slot_cfg = std_slot_cfg, - .gpio_cfg = pin_config, - }; - /* Initialize the channel */ err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); - if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize channel"); i2s_del_channel(this->tx_handle_); @@ -559,23 +267,34 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_strea this->parent_->unlock(); return err; } + if (this->i2s_event_queue_ == nullptr) { - this->i2s_event_queue_ = xQueueCreate(I2S_EVENT_QUEUE_COUNT, sizeof(int64_t)); + this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); + } else { + // Reset queue to clear any stale events from previous task + xQueueReset(this->i2s_event_queue_); } - i2s_channel_enable(this->tx_handle_); - - return err; + return ESP_OK; } -bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { +void I2SAudioSpeakerBase::stop_i2s_driver_() { + if (this->tx_handle_ != nullptr) { + i2s_channel_disable(this->tx_handle_); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + } + this->parent_->unlock(); +} + +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); BaseType_t need_yield1 = pdFALSE; BaseType_t need_yield2 = pdFALSE; BaseType_t need_yield3 = pdFALSE; - I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) user_ctx; + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { // Queue is full, so discard the oldest event and set the warning flag to inform the user @@ -589,14 +308,47 @@ bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_eve return need_yield1 | need_yield2 | need_yield3; } -void I2SAudioSpeaker::stop_i2s_driver_() { - i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - this->parent_->unlock(); +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { + if (this->q15_volume_factor_ >= INT16_MAX) { + return; // Max volume, no processing needed + } + + const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); + const uint32_t len = bytes_read / bytes_per_sample; + + // Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31 + int32_t shift = 15; // Q31 -> Q16 + int32_t gain_factor = this->q15_volume_factor_; // Q15 + + if (bytes_per_sample >= 3) { + // Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31 + shift = 8; // Q31 -> Q23 + gain_factor >>= 7; // Q15 -> Q8 + } + + for (uint32_t i = 0; i < len; ++i) { + int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31 + sample >>= shift; + sample *= gain_factor; // Q31 + audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample); + } } -} // namespace i2s_audio -} // namespace esphome +void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit mono mode, adjacent samples need to be swapped. + if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + int16_t *samples = reinterpret_cast(data); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif // USE_ESP32_VARIANT_ESP32 +} + +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 76b669220..b2644efd0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,10 +16,34 @@ #include "esphome/core/helpers.h" #include "esphome/core/ring_buffer.h" -namespace esphome { -namespace i2s_audio { +namespace esphome::i2s_audio { -class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Component { +// Shared constants for I2S audio speaker implementations +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; +static constexpr size_t TASK_STACK_SIZE = 4096; +static constexpr ssize_t TASK_PRIORITY = 19; + +enum SpeakerEventGroupBits : uint32_t { + COMMAND_START = (1 << 0), // indicates loop should start speaker task + COMMAND_STOP = (1 << 1), // stops the speaker task + COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written + + TASK_STARTING = (1 << 10), + TASK_RUNNING = (1 << 11), + TASK_STOPPING = (1 << 12), + TASK_STOPPED = (1 << 13), + + ERR_ESP_NO_MEM = (1 << 19), + + WARN_DROPPED_EVENT = (1 << 20), + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +/// @brief Abstract base class for I2S audio speaker implementations. +/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle) +/// for derived I2S speaker classes. +class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component { public: float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } @@ -30,7 +54,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } - void set_i2s_comm_fmt(std::string mode) { this->i2s_comm_fmt_ = std::move(mode); } + + /// @brief Get the I2S TX channel handle + i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; } void start() override; void stop() override; @@ -63,40 +89,55 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp void set_mute_state(bool mute_state) override; protected: - /// @brief Function for the FreeRTOS task handling audio output. - /// Allocates space for the buffers, reads audio from the ring buffer and writes audio to the I2S port. Stops - /// immmiately after receiving the COMMAND_STOP signal and stops only after the ring buffer is empty after receiving - /// the COMMAND_STOP_GRACEFULLY signal. Stops if the ring buffer hasn't read data for more than timeout_ milliseconds. - /// When stopping, it deallocates the buffers. It communicates its state and any errors via ``event_group_``. - /// @param params I2SAudioSpeaker component + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). + /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); + /// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior. + virtual void run_speaker_task() = 0; + /// @brief Sends a stop command to the speaker task via ``event_group_``. /// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal. void stop_(bool wait_on_empty); - /// @brief Callback function used to send playback timestamps the to the speaker task. + /// @brief Callback function used to send playback timestamps to the speaker task. /// @param handle (i2s_chan_handle_t) /// @param event (i2s_event_data_t) /// @param user_ctx (void*) User context pointer that the callback accesses /// @return True if a higher priority task was interrupted static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); - /// @brief Starts the ESP32 I2S driver. - /// Attempts to lock the I2S port, starts the I2S driver using the passed in stream information, and sets the data out - /// pin. If it fails, it will unlock the I2S port and uninstalls the driver, if necessary. + /// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration. /// @param audio_stream_info Stream information for the I2S driver. - /// @return ESP_ERR_NOT_ALLOWED if the I2S port can't play the incoming audio stream. - /// ESP_ERR_INVALID_STATE if the I2S port is already locked. - /// ESP_ERR_INVALID_ARG if installing the driver or setting the data outpin fails due to a parameter error. - /// ESP_ERR_NO_MEM if the driver fails to install due to a memory allocation error. - /// ESP_FAIL if setting the data out pin fails due to an IO error - /// ESP_OK if successful - esp_err_t start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info); + /// @return ESP_OK if successful, or an error code + virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0; + + /// @brief Shared I2S channel allocation, initialization, and event queue setup. + /// Called by derived start_i2s_driver_() implementations after building mode-specific configs. + /// @param chan_cfg I2S channel configuration + /// @param std_cfg I2S standard mode configuration (clock, slot, GPIO) + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent. + esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size); /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. + virtual void on_task_stopped() {} + + /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void apply_software_volume_(uint8_t *data, size_t bytes_read); + + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. + /// Only applies when running on original ESP32 with 16-bit mono audio. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); + TaskHandle_t speaker_task_handle_{nullptr}; EventGroupHandle_t event_group_{nullptr}; @@ -115,11 +156,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info gpio_num_t dout_pin_; - std::string i2s_comm_fmt_; - i2s_chan_handle_t tx_handle_; + i2s_chan_handle_t tx_handle_{nullptr}; }; -} // namespace i2s_audio -} // namespace esphome +} // namespace esphome::i2s_audio #endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp new file mode 100644 index 000000000..020346403 --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,307 @@ +#include "i2s_audio_speaker_standard.h" + +#ifdef USE_ESP32 + +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker.std"; + +static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1; + +void I2SAudioSpeaker::dump_config() { + I2SAudioSpeakerBase::dump_config(); + const char *fmt_str; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + fmt_str = "pcm"; + break; + case I2SCommFmt::MSB: + fmt_str = "msb"; + break; + default: + fmt_str = "std"; + break; + } + ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); +} + +void I2SAudioSpeaker::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; + // Ensure ring buffer duration is at least the duration of all DMA buffers + const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); + + // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration); + const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); + const size_t bytes_to_fill_single_dma_buffer = + this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + + bool successful_setup = false; + std::unique_ptr transfer_buffer = + audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer); + + if (transfer_buffer != nullptr) { + std::shared_ptr temp_ring_buffer = RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer.use_count() == 1) { + transfer_buffer->set_source(temp_ring_buffer); + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + bool stop_gracefully = false; + bool tx_dma_underflow = true; + + uint32_t frames_written = 0; + uint32_t last_data_received_time = millis(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // Main speaker task loop. Continues while: + // - Paused, OR + // - No timeout configured, OR + // - Timeout hasn't elapsed since last data + while (this->pause_state_ || !this->timeout_.has_value() || + (millis() - last_data_received_time) <= this->timeout_.value()) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); + break; + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + int64_t write_timestamp; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + // Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes + // on the timing info via the audio_output_callback. + uint32_t frames_sent = frames_to_fill_single_dma_buffer; + if (frames_to_fill_single_dma_buffer > frames_written) { + tx_dma_underflow = true; + frames_sent = frames_written; + const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written; + write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); + } else { + tx_dma_underflow = false; + } + frames_written -= frames_sent; + + // Standard I2S mode: fire callback immediately for each event + if (frames_sent > 0) { + this->audio_output_callback_(frames_sent, write_timestamp); + } + } + + if (this->pause_state_) { + // Pause state is accessed atomically, so thread safe + // Delay so the task yields, then skip transferring audio data + vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + continue; + } + + // Wait half the duration of the data already written to the DMA buffers for new audio data + // The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000 + uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2; + + size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay)); + uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read; + + if (bytes_read > 0) { + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); + } + + if (transfer_buffer->available() == 0) { + if (stop_gracefully && tx_dma_underflow) { + break; + } + vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2)); + } else { + size_t bytes_written = 0; + + if (tx_dma_underflow) { + // Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue + i2s_channel_disable(this->tx_handle_); + const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr}; + i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this); + i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), + &bytes_written); + } else { + // Audio is already playing, use regular write to add to the DMA buffers + i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(), + &bytes_written, DMA_BUFFER_DURATION_MS); + } + + if (bytes_written > 0) { + last_data_received_time = millis(); + frames_written += this->current_stream_info_.bytes_to_frames(bytes_written); + transfer_buffer->decrease_buffer_length(bytes_written); + + if (tx_dma_underflow) { + tx_dma_underflow = false; + // Enable the on_sent callback and channel after preload + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + } + } + } + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + if (transfer_buffer != nullptr) { + transfer_buffer.reset(); + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + + if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT + // Can't reconfigure I2S bus, so the sample rate must match the configured value + ESP_LOGE(TAG, "Incompatible stream settings"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && + (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { + // Currently can't handle the case when the incoming audio has more bits per sample than the configured value + ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); + return ESP_ERR_NOT_SUPPORTED; + } + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + + i2s_role_t i2s_role = this->i2s_role_; + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length); + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = i2s_role, + .dma_desc_num = DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // Build standard I2S clock/slot/gpio configuration + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = audio_stream_info.get_sample_rate(), + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_slot_mode_t slot_mode = this->slot_mode_; + i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; + if (audio_stream_info.get_channels() == 1) { + slot_mode = I2S_SLOT_MODE_MONO; + } else if (audio_stream_info.get_channels() == 2) { + slot_mode = I2S_SLOT_MODE_STEREO; + slot_mask = I2S_STD_SLOT_BOTH; + } + + i2s_std_slot_config_t slot_cfg; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + slot_cfg = + I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + break; + case I2SCommFmt::MSB: + slot_cfg = + I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + break; + default: + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), + slot_mode); + break; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the + // bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems + // to make it play at the correct speed while sending more bits per slot. + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + uint32_t configured_bit_width = static_cast(this->slot_bit_width_); + slot_cfg.ws_width = configured_bit_width; + if (configured_bit_width > 16) { + slot_cfg.msb_right = false; + } + } +#else + slot_cfg.slot_bit_width = this->slot_bit_width_; +#endif // USE_ESP32_VARIANT_ESP32 + slot_cfg.slot_mask = slot_mask; + + i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); + gpio_cfg.dout = this->dout_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + i2s_channel_enable(this->tx_handle_); + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h new file mode 100644 index 000000000..7b7f8b647 --- /dev/null +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "i2s_audio_speaker.h" + +namespace esphome::i2s_audio { + +enum class I2SCommFmt : uint8_t { + STANDARD, // Philips / I2S standard + PCM, // PCM short + MSB, // MSB / left-justified +}; + +/// @brief Standard I2S speaker implementation. +/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. +class I2SAudioSpeaker : public I2SAudioSpeakerBase { + public: + void dump_config() override; + + void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 1f20b21a0..b1d332c1e 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -283,7 +283,7 @@ async def to_code(config): try: return Image.open(path) except Exception as e: - raise core.EsphomeError(f"Could not load image file {path}: {e}") + raise core.EsphomeError(f"Could not load image file {path}: {e}") from e # make a wide horizontal combined image. images = [load_image(x) for x in config[CONF_COLOR_PALETTE_IMAGES]] diff --git a/esphome/components/ili9xxx/ili9xxx_defines.h b/esphome/components/ili9xxx/ili9xxx_defines.h index f4c5aad95..70e0937f7 100644 --- a/esphome/components/ili9xxx/ili9xxx_defines.h +++ b/esphome/components/ili9xxx/ili9xxx_defines.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome { namespace ili9xxx { diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index a3eff901d..11acb8a73 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -229,6 +229,10 @@ void ILI9XXXDisplay::update() { } void ILI9XXXDisplay::display_() { + // buffer may be null if allocation failed + if (this->buffer_ == nullptr) { + return; + } // check if something was displayed if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) { return; diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4a5fcc385..8375ab91d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,7 +28,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -676,12 +675,16 @@ def _final_validate(config): :param config: :return: """ - fv = full_config.get() - if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): - config = config.copy() - for c in config: - if not c.get(CONF_BYTE_ORDER): - c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + config = config.copy() + for c in config: + if byte_order := c.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + c.get(CONF_FILE), + ) + else: + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" return config @@ -753,7 +756,7 @@ async def write_image(config, all_frames=False): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index a6f9e35e2..5b4ed6968 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -189,7 +189,7 @@ Color Image::get_rgb_pixel_(int x, int y) const { } Color Image::get_rgb565_pixel_(int x, int y) const { const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1)); + uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index 31a92f90a..b7332ee81 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -20,8 +20,6 @@ void InternalTemperatureSensor::update() { success = (result == 0); #if defined(USE_LIBRETINY_VARIANT_BK7231N) temperature = raw * -0.38f + 156.0f; -#elif defined(USE_LIBRETINY_VARIANT_BK7231T) - temperature = raw * 0.04f; #else // USE_LIBRETINY_VARIANT temperature = raw * 0.128f; #endif // USE_LIBRETINY_VARIANT diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 5239a4667..60b0cd513 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -1,13 +1,73 @@ #include "ir_rf_proxy.h" + +#include + #include "esphome/core/log.h" namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; +// ========== Shared transmit helper ========== +// Static template: all instantiations occur in this translation unit. + +template +static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call) { + if (transmitter == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return; + } + + auto transmit_call = transmitter->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency); + + if (call.is_packed()) { + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return; + } + constexpr int32_t max_timing_us = 500000; + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + + transmit_call.perform(); +} + +// ========== IrRfProxy (Infrared platform) ========== + +#ifdef USE_IR_RF + void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, - "IR/RF Proxy '%s'\n" + "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -20,4 +80,54 @@ void IrRfProxy::dump_config() { } } +void IrRfProxy::control(const infrared::InfraredCall &call) { + uint32_t carrier = call.get_carrier_frequency().value_or(0); + transmit_raw_timings(this->transmitter_, carrier, call); +} + +#endif // USE_IR_RF + +// ========== RfProxy (Radio Frequency platform) ========== + +#ifdef USE_RADIO_FREQUENCY + +void RfProxy::setup() { + this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); + this->traits_.set_supports_receiver(this->receiver_ != nullptr); + + // remote_transmitter/receiver always uses OOK (on-off keying) + this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); + + if (this->receiver_ != nullptr) { + this->receiver_->register_listener(this); + } +} + +void RfProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "RF Proxy '%s'\n" + " Backend: remote_transmitter/receiver\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + + const auto &traits = this->traits_; + if (traits.get_frequency_min_hz() > 0) { + if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f, + traits.get_frequency_max_hz() / 1e6f); + } + } +} + +void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { + // RF: no IR carrier modulation + transmit_raw_timings(this->transmitter_, 0, call); +} + +#endif // USE_RADIO_FREQUENCY + } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 05b988f28..973e9e205 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -4,10 +4,19 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. +#include "esphome/components/remote_base/remote_base.h" + +#ifdef USE_IR_RF #include "esphome/components/infrared/infrared.h" +#endif + +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::ir_rf_proxy { +#ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy : public infrared::Infrared { public: @@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: + void control(const infrared::InfraredCall &call) override; + // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; +#endif // USE_IR_RF + +#ifdef USE_RADIO_FREQUENCY +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +class RfProxy : public radio_frequency::RadioFrequency { + public: + RfProxy() = default; + + void setup() override; + void dump_config() override; + + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + /// Set the remote receiver component + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + + /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) + void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } + + protected: + void control(const radio_frequency::RadioFrequencyCall &call) override; + + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + remote_base::RemoteReceiverBase *receiver_{nullptr}; +}; +#endif // USE_RADIO_FREQUENCY } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py new file mode 100644 index 000000000..9982f5e4d --- /dev/null +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -0,0 +1,68 @@ +"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" + +import esphome.codegen as cg +from esphome.components import radio_frequency, remote_receiver, remote_transmitter +import esphome.config_validation as cv +from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["radio_frequency"] + +RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency) + +CONFIG_SCHEMA = cv.All( + radio_frequency.radio_frequency_schema(RfProxy).extend( + { + cv.Optional(CONF_FREQUENCY): cv.frequency, + cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( + remote_receiver.RemoteReceiverComponent + ), + cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( + remote_transmitter.RemoteTransmitterComponent + ), + } + ), + cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID), +) + + +def _final_validate(config: ConfigType) -> None: + """Validate that RF transmitters have carrier duty set to 100%.""" + if CONF_REMOTE_TRANSMITTER_ID not in config: + return + + transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] + full_config = fv.full_config.get() + transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_config = full_config.get_config_for_path(transmitter_path) + + duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) + if duty_percent is not None and duty_percent != 100: + raise cv.Invalid( + f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " + "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " + "applying a carrier duty cycle would corrupt the signal" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + """Code generation for remote_base radio frequency platform.""" + var = await radio_frequency.new_radio_frequency(config) + + if CONF_FREQUENCY in config: + cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) + + if CONF_REMOTE_TRANSMITTER_ID in config: + transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) + cg.add(var.set_transmitter(transmitter)) + + if CONF_REMOTE_RECEIVER_ID in config: + receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) + cg.add(var.set_receiver(receiver)) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index f10e7ec0a..32e49c643 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); @@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor: 38th bytes */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif #ifdef USE_BINARY_SENSOR @@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 687ed21d1..31186b135 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 38e1a59ab..093e8c72d 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, - encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]) - if (this->detection_distance_sensor_ != nullptr) { + encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); + if (this->detection_distance_sensor_.has_sensor()) { int new_detect_distance = 0; if (target_state != 0x00 && (target_state & MOVE_BITMASK)) { new_detect_distance = @@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() { } else if (target_state != 0x00) { new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]); } - this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance); + this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance); } if (engineering_mode) { // Engineering mode needs at least LIGHT_SENSOR + 1 bytes @@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor value */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif // the radar module won't tell us when it's done, so we just have to keep polling... @@ -766,32 +766,38 @@ void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); } void LD2412Component::set_basic_config() { + uint8_t min_gate = 1; + uint8_t max_gate = TOTAL_GATES; + uint16_t timeout = DEFAULT_PRESENCE_TIMEOUT; + uint8_t out_pin_level = 0x01; + #ifdef USE_NUMBER - if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() || - !this->timeout_number_->has_state()) { - return; + if (this->min_distance_gate_number_ != nullptr) { + if (!this->min_distance_gate_number_->has_state()) + return; + min_gate = static_cast(this->min_distance_gate_number_->state); + } + if (this->max_distance_gate_number_ != nullptr) { + if (!this->max_distance_gate_number_->has_state()) + return; + max_gate = static_cast(this->max_distance_gate_number_->state) + 1; + } + if (this->timeout_number_ != nullptr) { + if (!this->timeout_number_->has_state()) + return; + timeout = static_cast(this->timeout_number_->state); } #endif #ifdef USE_SELECT - if (!this->out_pin_level_select_->has_state()) { - return; + if (this->out_pin_level_select_ != nullptr) { + if (!this->out_pin_level_select_->has_state()) + return; + out_pin_level = find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()); } #endif uint8_t value[5] = { -#ifdef USE_NUMBER - lowbyte(static_cast(this->min_distance_gate_number_->state)), - lowbyte(static_cast(this->max_distance_gate_number_->state) + 1), - lowbyte(static_cast(this->timeout_number_->state)), - highbyte(static_cast(this->timeout_number_->state)), -#else - 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, -#endif -#ifdef USE_SELECT - find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->current_option().c_str()), -#else - 0x01, // Default value if not using select -#endif + lowbyte(min_gate), lowbyte(max_gate), lowbyte(timeout), highbyte(timeout), out_pin_level, }; this->set_config_mode_(true); this->send_command_(CMD_BASIC_CONF, value, sizeof(value)); @@ -846,12 +852,11 @@ void LD2412Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 7fd224597..306e7ae31 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 58c3cac42..0dc2638aa 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() { SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count); // Moving Target Count SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count); + #endif #ifdef USE_BINARY_SENSOR @@ -872,33 +873,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) { - this->move_x_sensors_[target] = new SensorWithDedup(s); + this->move_x_sensors_[target].set_sensor(s); } void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) { - this->move_y_sensors_[target] = new SensorWithDedup(s); + this->move_y_sensors_[target].set_sensor(s); } void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) { - this->move_speed_sensors_[target] = new SensorWithDedup(s); + this->move_speed_sensors_[target].set_sensor(s); } void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) { - this->move_angle_sensors_[target] = new SensorWithDedup(s); + this->move_angle_sensors_[target].set_sensor(s); } void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) { - this->move_distance_sensors_[target] = new SensorWithDedup(s); + this->move_distance_sensors_[target].set_sensor(s); } void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) { - this->move_resolution_sensors_[target] = new SensorWithDedup(s); + this->move_resolution_sensors_[target].set_sensor(s); } void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_still_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_still_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_moving_target_count_sensors_[zone].set_sensor(s); } #endif #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index cbcdec10b..10f9bb874 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice { ZoneOfNumbers zone_numbers_[MAX_ZONES]; #endif #ifdef USE_SENSOR - std::array *, MAX_TARGETS> move_x_sensors_{}; - std::array *, MAX_TARGETS> move_y_sensors_{}; - std::array *, MAX_TARGETS> move_speed_sensors_{}; - std::array *, MAX_TARGETS> move_angle_sensors_{}; - std::array *, MAX_TARGETS> move_distance_sensors_{}; - std::array *, MAX_TARGETS> move_resolution_sensors_{}; - std::array *, MAX_ZONES> zone_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_still_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_moving_target_count_sensors_{}; + std::array, MAX_TARGETS> move_x_sensors_{}; + std::array, MAX_TARGETS> move_y_sensors_{}; + std::array, MAX_TARGETS> move_speed_sensors_{}; + std::array, MAX_TARGETS> move_angle_sensors_{}; + std::array, MAX_TARGETS> move_distance_sensors_{}; + std::array, MAX_TARGETS> move_resolution_sensors_{}; + std::array, MAX_ZONES> zone_target_count_sensors_{}; + std::array, MAX_ZONES> zone_still_target_count_sensors_{}; + std::array, MAX_ZONES> zone_moving_target_count_sensors_{}; #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index fd5516797..cba1b68a1 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -11,28 +11,20 @@ #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ protected: \ - ld24xx::SensorWithDedup *name##_sensor_{nullptr}; \ + ld24xx::SensorWithDedup name##_sensor_{}; \ \ public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { \ - this->name##_sensor_ = new ld24xx::SensorWithDedup(sensor); \ - } + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ - if ((sensor) != nullptr) { \ - LOG_SENSOR(tag, name, (sensor)->sens); \ + if ((sensor).has_sensor()) { \ + LOG_SENSOR(tag, name, (sensor).get_sensor()); \ } -#define SAFE_PUBLISH_SENSOR(sensor, value) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_if_not_dup(value); \ - } +#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value) -#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_unknown(); \ - } +#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown() #define highbyte(val) (uint8_t)((val) >> 8) #define lowbyte(val) (uint8_t)((val) &0xff) @@ -70,25 +62,33 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -// Helper class to store a sensor with a deduplicator & publish state only when the value changes +/// Sensor with deduplication — sensor may be null, null check is internal. +/// Stored inline, no heap allocation. Does nothing when no sensor is set. template class SensorWithDedup { public: - SensorWithDedup(sensor::Sensor *sens) : sens(sens) {} + void set_sensor(sensor::Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } void publish_state_if_not_dup(T state) { - if (this->publish_dedup.next(state)) { - this->sens->publish_state(static_cast(state)); + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); } } void publish_state_unknown() { - if (this->publish_dedup.next_unknown()) { - this->sens->publish_state(NAN); + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); } } - sensor::Sensor *sens; - Deduplicator publish_dedup; + bool has_sensor() const { return this->sens_ != nullptr; } + sensor::Sensor *get_sensor() const { return this->sens_; } + + protected: + sensor::Sensor *sens_{nullptr}; + Deduplicator dedup_; }; #endif } // namespace esphome::ld24xx diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 656eee6d7..40b8c8dc6 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH +from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -441,6 +443,13 @@ async def component_to_code(config): # 4-8KB flash). Even if linked, it would use locks, so explicit FreeRTOS # mutexes are simpler and equivalent. cg.add_define(ThreadModel.MULTI_NO_ATOMICS) + # Enable FreeRTOS static allocation so FreeRTOSQueue can use + # xQueueCreateStatic (queue storage in BSS, no heap allocation). + # Also moves FreeRTOS internal structures (timer command queue) to BSS. + # BK72xx's FreeRTOSConfig.h doesn't define this, defaulting to 0. + # The -D wins over the #ifndef default in FreeRTOS.h. + # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. + cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220) @@ -465,6 +474,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). + # On other families, patch_linker.py routes .sram.text into the right + # RAM-executable output section and prints a post-link placement summary. + if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -549,3 +563,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + script_dir = Path(__file__).parent + patch_linker_file = script_dir / "patch_linker.py.script" + copy_file_if_changed( + patch_linker_file, + CORE.relative_build_path("patch_linker.py"), + ) diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 1cfe68e92..ca46bcb89 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -16,8 +16,29 @@ void loop(); namespace esphome { void HOT yield() { ::yield(); } +// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast +// path instead of going through the Arduino core's out-of-line ::millis() wrapper. +// +// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR +// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis(). +// +// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch +// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h), +// so no ISR runs while flash is stalled. +#if defined(USE_RTL87XX) || defined(USE_LN882X) +uint32_t IRAM_ATTR HOT millis() { + static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick"); + return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount(); +} +#elif defined(USE_BK72XX) +uint32_t HOT millis() { + static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick"); + return xTaskGetTickCount() * portTICK_PERIOD_MS; +} +#else uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +#endif +uint64_t millis_64() { return Millis64Impl::compute(millis()); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void HOT delay(uint32_t ms) { ::delay(ms); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } @@ -35,7 +56,7 @@ void arch_init() { // // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // This is safe because ESPHome yields voluntarily via wakeable_delay() and // the Arduino mainTask yield() after each loop() iteration. static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); diff --git a/esphome/components/libretiny/freertos_static_alloc.c b/esphome/components/libretiny/freertos_static_alloc.c new file mode 100644 index 000000000..62b052423 --- /dev/null +++ b/esphome/components/libretiny/freertos_static_alloc.c @@ -0,0 +1,52 @@ +/* + * FreeRTOS static allocation callbacks for LibreTiny platforms. + * + * Required when configSUPPORT_STATIC_ALLOCATION is enabled. These callbacks + * provide memory for the idle and timer tasks. Following ESP-IDF's approach, + * we allocate from the FreeRTOS heap (pvPortMalloc) rather than using truly + * static buffers, to avoid assumptions about memory layout. + * + * This enables xQueueCreateStatic, xTaskCreateStatic, etc. throughout ESPHome, + * allowing queue storage to live in BSS with zero runtime heap allocation. + */ + +#ifdef USE_BK72XX + +#include +#include + +#if (configSUPPORT_STATIC_ALLOCATION == 1) + +void vApplicationGetIdleTaskMemory(StaticTask_t **ppxIdleTaskTCBBuffer, StackType_t **ppxIdleTaskStackBuffer, + uint32_t *pulIdleTaskStackSize) { + /* Stack grows down on ARM — allocate stack first, then TCB, + * so the stack does not grow into the TCB. */ + StackType_t *stack = (StackType_t *) pvPortMalloc(configMINIMAL_STACK_SIZE * sizeof(StackType_t)); + StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t)); + configASSERT(stack != NULL); + configASSERT(tcb != NULL); + + *ppxIdleTaskTCBBuffer = tcb; + *ppxIdleTaskStackBuffer = stack; + *pulIdleTaskStackSize = configMINIMAL_STACK_SIZE; +} + +#if (configUSE_TIMERS == 1) + +void vApplicationGetTimerTaskMemory(StaticTask_t **ppxTimerTaskTCBBuffer, StackType_t **ppxTimerTaskStackBuffer, + uint32_t *pulTimerTaskStackSize) { + StackType_t *stack = (StackType_t *) pvPortMalloc(configTIMER_TASK_STACK_DEPTH * sizeof(StackType_t)); + StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t)); + configASSERT(stack != NULL); + configASSERT(tcb != NULL); + + *ppxTimerTaskTCBBuffer = tcb; + *ppxTimerTaskStackBuffer = stack; + *pulTimerTaskStackSize = configTIMER_TASK_STACK_DEPTH; +} + +#endif /* configUSE_TIMERS */ + +#endif /* configSUPPORT_STATIC_ALLOCATION */ + +#endif /* USE_BK72XX */ diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b438944..d5437895a 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -79,6 +79,11 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() ''' BASE_CODE_BOARDS = ''' diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 000000000..282a31d3f --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,171 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re +import subprocess + +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family +# section routed into RAM-executable memory (see esphome/core/hal.h). +# +# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK +# masks FIQ+IRQ around flash writes). On the remaining families: +# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. +# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# +# All families also get a post-link summary showing where IRAM_ATTR landed. + + +_MARKER = "/* esphome .sram.text */" +# Strong assignments (not PROVIDE) so the symbols are always emitted in the +# ELF; PROVIDE symbols with no references can be garbage-collected. +_KEEP_LINE = ( + " __esphome_sram_text_start = .; " + "KEEP(*(.sram.text*)) " + "__esphome_sram_text_end = .; " + + _MARKER + "\n" +) +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") + + +def _detect(env): + prefix = "USE_LIBRETINY_VARIANT_" + # CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds + # the raw "-DNAME" strings. PlatformIO populates both, but the exact order + # vs. extra_scripts varies, so check both to be robust. + for token in env.get("CPPDEFINES", []): + if isinstance(token, (list, tuple)): + token = token[0] + if isinstance(token, str) and token.startswith(prefix): + return token[len(prefix):] + for flag in env.get("BUILD_FLAGS", []): + if isinstance(flag, str) and "-D" + prefix in flag: + name = flag.split("-D", 1)[1].split("=", 1)[0].strip() + if name.startswith(prefix): + return name[len(prefix):] + return None + + +KNOWN_VARIANTS = frozenset({ + "LN882H", + "RTL8710B", + "RTL8720C", +}) + + +def _inject_keep(host_section): + """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + def patch(content): + if _MARKER in content: + return content + return host_section.sub(r"\1" + _KEEP_LINE, content, count=1) + return patch + + +# Variants not listed here intentionally have no .ld patcher: +# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker +# already routes into .ram_image2.text (> BD_RAM). +# - RTL8720C: stock linker already consumes *(.sram.text*). +# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. +_PATCHERS_BY_VARIANT = { + "LN882H": (_inject_keep(_LN_COPY),), +} + + +def _patchers_for(variant): + return _PATCHERS_BY_VARIANT.get(variant, ()) + + +def _pre_link(target, source, env): + build_dir = env.subst("$BUILD_DIR") + ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] + patched = 0 + for name in ld_files: + path = os.path.join(build_dir, name) + with open(path, "r", encoding="utf-8") as fh: + original = fh.read() + if _MARKER in original: + patched += 1 + continue + content = original + for fn in _patchers: + content = fn(content) + if content != original: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: + raise RuntimeError( + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) + ) + + +# Substrings matched against demangled names as a fallback on RTL8720C, +# where we cannot inject __esphome_sram_text_start/end markers. +_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", + "enable_loop_soon_any_context") + + +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + nm = env.subst("$NM") + try: + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) + return + start = end = None + fallback = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr_str, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr_str, 16) + elif name == "__esphome_sram_text_end": + end = int(addr_str, 16) + elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS): + fallback.append(int(addr_str, 16)) + print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) + if start is not None and end is not None: + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + elif fallback: + lo, hi = min(fallback), max(fallback) + print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) + else: + print(" no IRAM_ATTR symbols found") + + +if (_variant := _detect(env)) is None: + raise RuntimeError( + "ESPHome: could not determine LibreTiny variant from build flags. " + "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " + "into SRAM; without it, ISR handlers would silently end up in flash." + ) +if _variant not in KNOWN_VARIANTS: + raise RuntimeError( + "ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not " + "know how to route IRAM_ATTR into SRAM for this family. Update " + "patch_linker.py.script before shipping firmware.".format(_variant) + ) + +if _patchers := _patchers_for(_variant): + # LibreTiny writes the processed .ld templates into $BUILD_DIR during its + # own builder setup, which may run after this script. Register the patch + # as a pre-link action so it executes once the linker scripts exist. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index fba671729..313b36d31 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -3,7 +3,6 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include #include #include @@ -11,9 +10,6 @@ namespace esphome::libretiny { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 2f6ffc9a3..d2f5913f4 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -58,6 +58,12 @@ void AddressableLightTransformer::start() { // our transition will handle brightness, disable brightness in correction. this->light_.correction_.set_local_brightness(255); this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state()); + + // Uniformity scan is deferred to the first apply() call. start() can run before the underlying + // LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE + // triggering a transition), and reading through ESPColorView would deref a null buffer. + this->uniform_start_scanned_ = false; + this->uniform_start_is_uniform_ = false; } inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) { @@ -97,12 +103,57 @@ optional AddressableLightTransformer::apply() { // non-linear when applying small deltas. if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) { - int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); - for (auto led : this->light_) { - led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), - subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), - subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), - subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + // Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the + // frame buffer is valid. When every LED already has the same color (the common case: plain + // turn_on/turn_off on a uniform strip), interpolate math-only against a single start color. + // Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip + // quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27 + // round to stored 0, freezing progress). + if (!this->uniform_start_scanned_) { + this->uniform_start_scanned_ = true; + if (this->light_.size() > 0) { + Color first = this->light_[0].get(); + bool uniform = true; + for (int32_t i = 1; i < this->light_.size(); i++) { + if (this->light_[i].get() != first) { + uniform = false; + break; + } + } + if (uniform) { + this->uniform_start_color_ = first; + this->uniform_start_is_uniform_ = true; + } + } + } + if (this->uniform_start_is_uniform_) { + // All LEDs started at the same color: compute the interpolated value once and write it to + // every LED. No read-back, so each LED's stored byte advances through every gamma threshold + // as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values. + // + // Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be + // overwritten on the next apply() here. The fallback path below would have respected them + // via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we + // support, so this is acceptable. + // lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress). + const Color &start = this->uniform_start_color_; + int32_t remaining = int32_t(256.f * (1.f - smoothed_progress)); + uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining); + uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining); + uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining); + uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining); + for (auto led : this->light_) { + led.set_rgbw(r, g, b, w); + } + } else { + int32_t scale = + int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); + for (auto led : this->light_) { + led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), + subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), + subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), + subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + } } this->last_transition_progress_ = smoothed_progress; this->light_.schedule_show(); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 17cdb7d6f..0202ad380 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer { AddressableLight &light_; float last_transition_progress_{0.0f}; Color target_color_{}; + Color uniform_start_color_{}; + bool uniform_start_scanned_{false}; + bool uniform_start_is_uniform_{false}; }; } // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 9d731a2bd..e793226bb 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -22,4 +22,20 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { return (target - a <= b - target) ? lo : lo + 1; } +Color ESPColorCorrection::color_uncorrect(Color color) const { + // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) + return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), + this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); +} + +uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { + if (max_brightness == 0 || this->local_brightness_ == 0) + return 0; + // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, + // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. + uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; + return static_cast(std::min(res, uint32_t(255))); +} + } // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 48ecc4636..4eb5208c9 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -46,38 +46,18 @@ class ESPColorCorrection { uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); return this->gamma_correct_(res); } - inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE { - // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) - return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), - this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); - } + Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.red == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(red, this->max_brightness_.red); } inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.green == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(green, this->max_brightness_.green); } inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.white == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(white, this->max_brightness_.white); } protected: @@ -85,6 +65,9 @@ class ESPColorCorrection { uint8_t gamma_correct_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; + /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line + /// to avoid duplicating two 16-bit divides at every call site. + uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const; const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index a749cd730..7b28065e4 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -10,13 +10,10 @@ namespace esphome::light { static const char *const TAG = "light"; -// Helper functions to reduce code size for logging -static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *param_name, float min = 0.0f, - float max = 1.0f) { - if (value < min || value > max) { - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); - value = clamp(value, min, max); - } +// Cold-path logger; caller handles the clamp so the in-range hot path avoids +// the spill/reload around the call. +static void log_value_out_of_range(const char *name, float value, const LogString *param_name, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max); } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN @@ -57,6 +54,12 @@ static void log_invalid_parameter(const char *name, const LogString *message) { PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature", "Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white"); +// Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature. +// PROGMEM_STRING_TABLE is constexpr-init (no RAM guard variable). +PROGMEM_STRING_TABLE(ValidateFieldNames, "Brightness", "Color brightness", "Red", "Green", "Blue", "White", + "Cold white", "Warm white", "Color temperature"); +static constexpr uint8_t VALIDATE_CT_INDEX = 8; + static const LogString *color_mode_to_human(ColorMode color_mode) { return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0); } @@ -277,25 +280,37 @@ LightColorValues LightCall::validate_() { if (this->has_state()) v.set_state(this->state_); - // clamp_and_log_if_invalid already clamps in-place, so assign directly - // to avoid redundant clamp code from the setter being inlined. -#define VALIDATE_AND_APPLY(field, name_str, ...) \ - if (this->has_##field()) { \ - clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \ - v.field##_ = this->field##_; \ + // FieldFlags bits 0-7 must match unit_fields_ array indices. + static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 && + FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 && + FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7, + "FieldFlags bits 0-7 must match unit_fields_ indices"); + + // Iterate set bits only (ctz + clear-lowest) — HA can drive perform() + // at high frequency so the hot path is O(popcount). + unsigned active = this->flags_ & CLAMP_FLAGS_MASK; + while (active != 0) { + unsigned bit = __builtin_ctz(active); + active &= active - 1; // clear lowest set bit + float &value = this->unit_fields_[bit]; + if (float_out_of_unit_range(value)) { + log_value_out_of_range(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f); + value = clamp_unit_float(value); + } + v.unit_fields_[bit] = value; } - VALIDATE_AND_APPLY(brightness, "Brightness") - VALIDATE_AND_APPLY(color_brightness, "Color brightness") - VALIDATE_AND_APPLY(red, "Red") - VALIDATE_AND_APPLY(green, "Green") - VALIDATE_AND_APPLY(blue, "Blue") - VALIDATE_AND_APPLY(white, "White") - VALIDATE_AND_APPLY(cold_white, "Cold white") - VALIDATE_AND_APPLY(warm_white, "Warm white") - VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) - -#undef VALIDATE_AND_APPLY + // color_temperature: runtime range from traits. + if (this->has_color_temperature()) { + const float ct_min = traits.get_min_mireds(); + const float ct_max = traits.get_max_mireds(); + if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) { + log_value_out_of_range(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0), + ct_min, ct_max); + this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max); + } + v.color_temperature_ = this->color_temperature_; + } v.normalize_color(); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 88d29bd34..e3352de72 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -195,25 +195,26 @@ class LightCall { /// Some color modes also can be set using non-native parameters, transform those calls. void transform_parameters_(const LightTraits &traits); - // Bitfield flags - each flag indicates whether a corresponding value has been set. + // Bits 0-7 index unit_fields_[] in validate_(); don't reorder (asserts in light_call.cpp). enum FieldFlags : uint16_t { - FLAG_HAS_STATE = 1 << 0, - FLAG_HAS_TRANSITION = 1 << 1, - FLAG_HAS_FLASH = 1 << 2, - FLAG_HAS_EFFECT = 1 << 3, - FLAG_HAS_BRIGHTNESS = 1 << 4, - FLAG_HAS_COLOR_BRIGHTNESS = 1 << 5, - FLAG_HAS_RED = 1 << 6, - FLAG_HAS_GREEN = 1 << 7, - FLAG_HAS_BLUE = 1 << 8, - FLAG_HAS_WHITE = 1 << 9, - FLAG_HAS_COLOR_TEMPERATURE = 1 << 10, - FLAG_HAS_COLD_WHITE = 1 << 11, - FLAG_HAS_WARM_WHITE = 1 << 12, + FLAG_HAS_BRIGHTNESS = 1 << 0, + FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1, + FLAG_HAS_RED = 1 << 2, + FLAG_HAS_GREEN = 1 << 3, + FLAG_HAS_BLUE = 1 << 4, + FLAG_HAS_WHITE = 1 << 5, + FLAG_HAS_COLD_WHITE = 1 << 6, + FLAG_HAS_WARM_WHITE = 1 << 7, + FLAG_HAS_COLOR_TEMPERATURE = 1 << 8, + FLAG_HAS_STATE = 1 << 9, + FLAG_HAS_TRANSITION = 1 << 10, + FLAG_HAS_FLASH = 1 << 11, + FLAG_HAS_EFFECT = 1 << 12, FLAG_HAS_COLOR_MODE = 1 << 13, FLAG_PUBLISH = 1 << 14, FLAG_SAVE = 1 << 15, }; + static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } @@ -222,7 +223,7 @@ class LightCall { inline bool get_save_() { return (this->flags_ & FLAG_SAVE) != 0; } // Helper to set flag - defaults to true for common case - void set_flag_(FieldFlags flag, bool value = true) { + void set_flag_(FieldFlags flag, bool value = true) ESPHOME_ALWAYS_INLINE { if (value) { this->flags_ |= flag; } else { @@ -231,7 +232,7 @@ class LightCall { } // Helper to clear flag - reduces code size for common case - void clear_flag_(FieldFlags flag) { this->flags_ &= ~flag; } + void clear_flag_(FieldFlags flag) ESPHOME_ALWAYS_INLINE { this->flags_ &= ~flag; } // Helper to log unsupported feature and clear flag - reduces code duplication void log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log); @@ -239,19 +240,11 @@ class LightCall { LightState *parent_; // Light state values - use flags_ to check if a value has been set. - // Group 4-byte aligned members first uint32_t transition_length_; uint32_t flash_length_; uint32_t effect_; - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; - float cold_white_; - float warm_white_; // Smaller members at the end for better packing uint16_t flags_{FLAG_PUBLISH | FLAG_SAVE}; // Tracks which values are set diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index fa286a394..5cafa9fe8 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -3,11 +3,62 @@ #include "esphome/core/helpers.h" #include "color_mode.h" #include +#include +#include namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } +// IEEE 754 bit patterns. Values in [0.0f, 1.0f] have bits <= ONE_F_BITS; +// negatives have the sign bit set (→ huge unsigned). A single unsigned compare +// replaces two soft-float __ltsf2/__gtsf2 calls on ESP8266. +static constexpr uint32_t ONE_F_BITS = 0x3F800000u; // 1.0f +static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; // -0.0f / sign-bit mask +static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit"); +static_assert(std::numeric_limits::is_iec559, "IEEE 754 float required"); + +// Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h). +// -0.0f is numerically zero so it's reported in range (no warning, no clamp). +inline bool float_out_of_unit_range(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS; +} + +// Clamps to [0.0f, 1.0f] without float compares. Out of range: sign bit set +// (negatives, -NaN, -Inf) → 0.0f; sign bit clear (>1, +NaN, +Inf) → 1.0f. +inline float clamp_unit_float(float x) { + union { + float f; + uint32_t u; + } pun; + pun.f = x; + if (pun.u <= ONE_F_BITS) + return x; + return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0 +} + +// Shared anonymous union: eight unit-range floats alias unit_fields_[8] so +// LightCall::validate_() can iterate them as a real array. GCC/Clang ext. +#define ESPHOME_LIGHT_UNIT_FIELDS_UNION() \ + union { \ + struct { \ + float brightness_; \ + float color_brightness_; \ + float red_; \ + float green_; \ + float blue_; \ + float white_; \ + float cold_white_; \ + float warm_white_; \ + }; \ + float unit_fields_[8]; \ + } + /** This class represents the color state for a light object. * * The representation of the color state is dependent on the active color mode. A color mode consists of multiple @@ -52,9 +103,9 @@ class LightColorValues { green_(1.0f), blue_(1.0f), white_(1.0f), - color_temperature_{0.0f}, cold_white_{1.0f}, warm_white_{1.0f}, + color_temperature_{0.0f}, color_mode_(ColorMode::UNKNOWN) {} LightColorValues(ColorMode color_mode, float state, float brightness, float color_brightness, float red, float green, @@ -220,39 +271,39 @@ class LightColorValues { /// Get the binary true/false state of these light color values. bool is_on() const { return this->get_state() != 0.0f; } /// Set the state of these light color values. In range from 0.0 (off) to 1.0 (on) - void set_state(float state) { this->state_ = clamp(state, 0.0f, 1.0f); } + void set_state(float state) { this->state_ = clamp_unit_float(state); } /// Set the state of these light color values as a binary true/false. void set_state(bool state) { this->state_ = state ? 1.0f : 0.0f; } /// Get the brightness property of these light color values. In range 0.0 to 1.0 float get_brightness() const { return this->brightness_; } /// Set the brightness property of these light color values. In range 0.0 to 1.0 - void set_brightness(float brightness) { this->brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_brightness(float brightness) { this->brightness_ = clamp_unit_float(brightness); } /// Get the color brightness property of these light color values. In range 0.0 to 1.0 float get_color_brightness() const { return this->color_brightness_; } /// Set the color brightness property of these light color values. In range 0.0 to 1.0 - void set_color_brightness(float brightness) { this->color_brightness_ = clamp(brightness, 0.0f, 1.0f); } + void set_color_brightness(float brightness) { this->color_brightness_ = clamp_unit_float(brightness); } /// Get the red property of these light color values. In range 0.0 to 1.0 float get_red() const { return this->red_; } /// Set the red property of these light color values. In range 0.0 to 1.0 - void set_red(float red) { this->red_ = clamp(red, 0.0f, 1.0f); } + void set_red(float red) { this->red_ = clamp_unit_float(red); } /// Get the green property of these light color values. In range 0.0 to 1.0 float get_green() const { return this->green_; } /// Set the green property of these light color values. In range 0.0 to 1.0 - void set_green(float green) { this->green_ = clamp(green, 0.0f, 1.0f); } + void set_green(float green) { this->green_ = clamp_unit_float(green); } /// Get the blue property of these light color values. In range 0.0 to 1.0 float get_blue() const { return this->blue_; } /// Set the blue property of these light color values. In range 0.0 to 1.0 - void set_blue(float blue) { this->blue_ = clamp(blue, 0.0f, 1.0f); } + void set_blue(float blue) { this->blue_ = clamp_unit_float(blue); } /// Get the white property of these light color values. In range 0.0 to 1.0 float get_white() const { return white_; } /// Set the white property of these light color values. In range 0.0 to 1.0 - void set_white(float white) { this->white_ = clamp(white, 0.0f, 1.0f); } + void set_white(float white) { this->white_ = clamp_unit_float(white); } /// Get the color temperature property of these light color values in mired. float get_color_temperature() const { return this->color_temperature_; } @@ -277,26 +328,19 @@ class LightColorValues { /// Get the cold white property of these light color values. In range 0.0 to 1.0. float get_cold_white() const { return this->cold_white_; } /// Set the cold white property of these light color values. In range 0.0 to 1.0. - void set_cold_white(float cold_white) { this->cold_white_ = clamp(cold_white, 0.0f, 1.0f); } + void set_cold_white(float cold_white) { this->cold_white_ = clamp_unit_float(cold_white); } /// Get the warm white property of these light color values. In range 0.0 to 1.0. float get_warm_white() const { return this->warm_white_; } /// Set the warm white property of these light color values. In range 0.0 to 1.0. - void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); } + void set_warm_white(float warm_white) { this->warm_white_ = clamp_unit_float(warm_white); } friend class LightCall; protected: float state_; ///< ON / OFF, float for transition - float brightness_; - float color_brightness_; - float red_; - float green_; - float blue_; - float white_; + ESPHOME_LIGHT_UNIT_FIELDS_UNION(); float color_temperature_; ///< Color Temperature in Mired - float cold_white_; - float warm_white_; ColorMode color_mode_; }; diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf6..9c9182752 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 1a45896ac..a36d52a5d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -35,9 +35,11 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") LOCK_STATES = { + "OPEN": LockState.LOCK_STATE_OPEN, "LOCKED": LockState.LOCK_STATE_LOCKED, "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, "JAMMED": LockState.LOCK_STATE_JAMMED, + "OPENING": LockState.LOCK_STATE_OPENING, "LOCKING": LockState.LOCK_STATE_LOCKING, "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, } diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 3ff131af3..66eb692bd 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -8,9 +8,10 @@ namespace esphome::lock { static const char *const TAG = "lock"; -// Lock state strings indexed by LockState enum (0-5): NONE(UNKNOWN), LOCKED, UNLOCKED, JAMMED, LOCKING, UNLOCKING +// Lock state strings indexed by LockState enum. // Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range -PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING"); +PROGMEM_STRING_TABLE(LockStateStrings, "UNKNOWN", "LOCKED", "UNLOCKED", "JAMMED", "LOCKING", "UNLOCKING", "OPENING", + "OPEN"); const LogString *lock_state_to_string(LockState state) { return LockStateStrings::get_log_str(static_cast(state), 0); @@ -74,12 +75,16 @@ LockCall &LockCall::set_state(optional state) { return *this; } LockCall &LockCall::set_state(const char *state) { - if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { + if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPEN")) == 0) { + this->set_state(LOCK_STATE_OPEN); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKED")) == 0) { this->set_state(LOCK_STATE_LOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKED")) == 0) { this->set_state(LOCK_STATE_UNLOCKED); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("JAMMED")) == 0) { this->set_state(LOCK_STATE_JAMMED); + } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("OPENING")) == 0) { + this->set_state(LOCK_STATE_OPENING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("LOCKING")) == 0) { this->set_state(LOCK_STATE_LOCKING); } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("UNLOCKING")) == 0) { diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 543a4b51a..86a9cdd3f 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -26,7 +26,9 @@ enum LockState : uint8_t { LOCK_STATE_UNLOCKED = 2, LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, - LOCK_STATE_UNLOCKING = 5 + LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; const LogString *lock_state_to_string(LockState state); diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 4144543b8..9d7dc8d92 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None: # esphome implement own fatal error handler which save PC/LR before reset zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False) zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True) - if config[CONF_HARDWARE_UART] == UART0: - zephyr_add_overlay("""&uart0 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == UART1: - zephyr_add_overlay("""&uart1 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == USB_CDC: - cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") - zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) + if has_serial_logging: + if config[CONF_HARDWARE_UART] == UART0: + zephyr_add_overlay("""&uart0 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == UART1: + zephyr_add_overlay("""&uart1 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 6b46b93c6..7fa9e42c6 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -65,10 +65,12 @@ void Logger::pre_setup() { break; #ifdef USE_LOGGER_USB_CDC case UART_SELECTION_USB_CDC: +#ifdef CONFIG_USB_DEVICE_STACK uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0)); if (device_is_ready(uart_dev)) { usb_enable(nullptr); } +#endif break; #endif } diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index ba4a7ea5c..033f31a3d 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -45,6 +45,7 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { uint8_t buffer[num_bytes]; // Wait until data available + constexpr uint32_t max_wait_ms = 25; const uint32_t now = millis(); while (true) { std::bitset<8> status = this->reg(LTR390_MAIN_STATUS).get(); @@ -52,12 +53,12 @@ optional LTR390Component::read_sensor_data_(LTR390MODE mode) { if (available) break; - if (millis() - now > 100) { + if (millis() - now > max_wait_ms) { ESP_LOGW(TAG, "Sensor didn't return any data, aborting"); return {}; } - ESP_LOGD(TAG, "Waiting for data"); - delay(2); + ESP_LOGV(TAG, "Waiting for data"); + delay(1); } if (!this->read_bytes(MODEADDRESSES[mode], buffer, num_bytes)) { diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index f6f6204f4..ac0363ca6 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -44,6 +44,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.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets @@ -341,7 +342,7 @@ async def to_code(configs): df.LOGGER.info("LVGL will use hardware rotation via display driver") else: rotation_type = RotationType.ROTATION_SOFTWARE - if get_esp32_variant() == VARIANT_ESP32P4: + if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4: df.LOGGER.info("LVGL will use software rotation (PPA accelerated)") else: df.LOGGER.info("LVGL will use software rotation") @@ -451,7 +452,8 @@ async def to_code(configs): df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) - write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): + clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") # handle windows paths in a way that doesn't break the generated C++ lv_conf_h_path = Path(lv_conf_h_file).as_posix() diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml index bbbd34e30..7bf068cc5 100644 --- a/esphome/components/lvgl/hello_world.yaml +++ b/esphome/components/lvgl/hello_world.yaml @@ -89,10 +89,12 @@ id: hello_world_label_ text: "Hello World!" align: center - - obj: + - container: id: hello_world_qrcode_ outline_width: 0 border_width: 0 + height: 100 + width: 100 hidden: !lambda |- return lv_obj_get_width(lv_screen_active()) < 300 && lv_obj_get_height(lv_screen_active()) < 400; widgets: diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index ce9b013dc..d8248e4aa 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -642,26 +642,28 @@ void LvglComponent::write_random_() { int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; + int16_t width = lv_display_get_horizontal_resolution(this->disp_); + int16_t height = lv_display_get_vertical_resolution(this->disp_); while (iterations-- != 0) { - int32_t col = random_uint32() % this->width_; + int32_t col = random_uint32() % width; col = col / this->draw_rounding * this->draw_rounding; - int32_t row = random_uint32() % this->height_; + int32_t row = random_uint32() % height; row = row / this->draw_rounding * this->draw_rounding; // size will be between 8 and 32, and a multiple of draw_rounding int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; - lv_area_t area{col, row, col + size - 1, row + size - 1}; + lv_area_t area{.x1 = col, .y1 = row, .x2 = col + size - 1, .y2 = row + size - 1}; // clip to display bounds just in case - if (area.x2 >= this->width_) - area.x2 = this->width_ - 1; - if (area.y2 >= this->height_) - area.y2 = this->height_ - 1; + if (area.x2 >= width) + area.x2 = width - 1; + if (area.y2 >= height) + area.y2 = height - 1; // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); + reinterpret_cast(this->draw_buf_)[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); + this->draw_buffer_(&area, reinterpret_cast(this->draw_buf_)); } } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3ba258b1a..146866f5b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -76,16 +76,23 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { } #endif #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -// Shortcut / overload, so that the source of an image can easily be updated -// from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } +#if LV_USE_IMAGE +// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } +#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); + ::lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); +} +inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bg_image_src(style, image->get_lv_image_dsc()); +} +inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { + ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 9eaf3dadc..ac993cc38 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -77,8 +77,11 @@ class ArcType(NumberType): # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle prop = str(prop) if prop.endswith("_angle"): - prop = "bg_" + prop - await w.set_property(prop, config, processor=validator) + await w.set_property( + "bg_" + prop, await validator.process(config.get(prop)) + ) + else: + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 029ca5f68..c5628cee3 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -52,19 +52,23 @@ class KeyboardType(WidgetType): if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): - # If a textarea is configured, it must be generated before the keyboard can attach it. - # If not yet configured, defer the attachment code. + if not is_widget_completed(textarea): + # Can only happen for an initial config, where the keyboard is configured before the + # textarea, so it's ok to always emit into the global context + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, + (await get_widgets(config, CONF_TEXTAREA))[0].obj, + ) - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) - - if is_widget_completed(textarea): - await add_textarea() - else: CORE.add_job(add_textarea) + else: + # Handles updates in automations, and properly ordered initial config. Code is generated + # into the enclosing context (main or lambda) + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 108bb38df..5e9e0494d 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -22,7 +22,7 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties @@ -83,8 +83,8 @@ class TabviewType(WidgetType): await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] - tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) - tab_widget = Widget.create(w_id, tab_obj, obj_spec) + tab_obj = lv_Pvariable(lv_tab_t, w_id) + tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf) lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 118a77ce3..b7a9cfd0c 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -37,7 +37,10 @@ void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_co void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 6efd04e24..8a87dac14 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -21,7 +21,10 @@ template class MCP23XXXBase : public Component, public gpio_expander: void loop() override { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 79d355e8a..7c36295e8 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -170,7 +170,7 @@ async def to_code(config): cg.add_library("LEAmDNS", None) if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.10.0") + add_idf_component(name="espressif/mdns", ref="1.11.0") cg.add_define("USE_MDNS") diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 1c2c47464..d1db868ac 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -1,20 +1,31 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg +from esphome.components import audio import esphome.config_validation as cv from esphome.const import ( CONF_ENTITY_CATEGORY, + CONF_FORMAT, CONF_ICON, CONF_ID, + CONF_NUM_CHANNELS, CONF_ON_IDLE, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, + CONF_SAMPLE_RATE, CONF_VOLUME, ) from esphome.core import CORE -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + inherit_property_from, + setup_entity, +) from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -34,6 +45,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, } +# Public API for external components. Do not remove. +FORMAT_MAPPING = { + "FLAC": "flac", + "MP3": "mp3", + "OPUS": "opus", + "WAV": "wav", +} + + +def build_supported_format_struct( + format_config: ConfigType, purpose: MockObj +) -> cg.StructInitializer: + """Build a MediaPlayerSupportedFormat struct from a format config and purpose. + + Public API for external components. Do not remove. + """ + args = [ + MediaPlayerSupportedFormat, + ("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]), + ("sample_rate", format_config[CONF_SAMPLE_RATE]), + ("num_channels", format_config[CONF_NUM_CHANNELS]), + ("purpose", purpose), + ] + + # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails + # if the number of bytes per sample is specified for MP3. + if format_config[CONF_FORMAT] != "MP3": + args.append(("sample_bytes", 2)) + + return cg.StructInitializer(*args) + + +def validate_preferred_format( + component_name: str, audio_device_key: str +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that inherits audio device settings and validates format constraints. + + Public API for external components. Do not remove. + """ + + def validator(config: ConfigType) -> ConfigType: + # Inherit settings from audio device if not manually set + inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config) + inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config) + + # Opus only supports 48 kHz + if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: + raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") + + # Validate the settings are compatible with the audio device + audio.final_validate_audio_schema( + component_name, + audio_device=audio_device_key, + bits_per_sample=16, + channels=config.get(CONF_NUM_CHANNELS), + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + return validator + + +def request_codecs_for_format_configs( + config: ConfigType, format_config_keys: list[str] +) -> None: + """Scan format configs for configured formats and request the needed codec support. + + If any config uses "NONE" (accepts any format), all codecs are requested. + + Public API for external components. Do not remove. + """ + needed_formats: set[str] = set() + need_all = False + + for key in format_config_keys: + if format_config := config.get(key): + fmt = format_config[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: + audio.request_flac_support() + audio.request_mp3_support() + audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() + + # Local config key constants CONF_ANNOUNCEMENT = "announcement" CONF_ON_PLAY = "on_play" diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index de95e4961..22d2098de 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -452,14 +452,14 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) - esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1") + esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") + + esp32.add_idf_component(name="esphome/esp-micro-speech-features", ref="1.2.3") cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") cg.add_build_flag("-DESP_NN") - cg.add_library("kahrendt/ESPMicroSpeechFeatures", "1.1.0") - if vad_model := config.get(CONF_VAD): cg.add_define("USE_MICRO_WAKE_WORD_VAD") diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 50521cf23..69e0d46d2 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -28,7 +28,8 @@ void AirConditioner::on_status_change() { if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { // Read existing presets (set by codegen), append frost protection, write back - const auto &existing = this->get_traits().get_supported_custom_presets(); + auto traits = this->get_traits(); + const auto &existing = traits.get_supported_custom_presets(); bool found = false; for (const char *p : existing) { if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py new file mode 100644 index 000000000..290b0e07e --- /dev/null +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -0,0 +1,29 @@ +from esphome.components.mipi import DriverChip +import esphome.config_validation as cv + +# Standalone display +# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html +DriverChip( + "SEEED-RETERMINAL-D1001", + height=1280, + width=800, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=12, + vsync_pulse_width=4, + vsync_front_porch=30, + pclk_frequency="80MHz", + lane_bit_rate="1.5Gbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], + reset_pin={"xl9535": None, "number": 2}, + initsequence=( + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + ), +) diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py new file mode 100644 index 000000000..a33625dfe --- /dev/null +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -0,0 +1,51 @@ +from esphome.components.mipi import DriverChip +from esphome.config_validation import UNDEFINED + +# fmt: off +sunton = DriverChip( + "ESP32-8048S070", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="12.5MHz", + de_pin=41, + hsync_pin=39, + vsync_pin=40, + pclk_pin=42, + hsync_pulse_width=30, + hsync_back_porch=16, + hsync_front_porch=210, + vsync_pulse_width=13, + vsync_back_porch=10, + vsync_front_porch=22, + data_pins={ + "red": [14, 21, 47, 48, 45], + "green": [9, 46, 3, 8, 16, 1], + "blue": [15, 7, 6, 5, 4], + }, +) + +sunton.extend( + "ESP32-8048S050", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="16MHz", + de_pin=40, + hsync_pin=39, + vsync_pin=41, + pclk_pin=42, + hsync_back_porch=8, + hsync_front_porch=8, + hsync_pulse_width=4, + vsync_back_porch=8, + vsync_front_porch=8, + vsync_pulse_width=4, + data_pins={ + "red": [45, 48, 47, 21, 14], + "green": [5, 6, 7, 15, 16, 4], + "blue": [8, 3, 46, 9, 1], + }, +) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 42c7ec222..364ada904 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -195,7 +195,7 @@ def model_schema(config): "big_endian", "little_endian", lower=True ), model.option(CONF_COLOR_DEPTH, 16): cv.one_of(*color_depth, lower=True), - model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_DRAW_ROUNDING, 1): power_of_two, model.option(CONF_PIXEL_MODE, DISPLAY_16BIT): cv.one_of( *pixel_modes, lower=True ), @@ -297,9 +297,9 @@ def _final_validate(config): buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here - fraction = min(20000.0, buffer_size // 16) / buffer_size + fraction = min(20000.0, buffer_size // 4) / buffer_size config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x + (x for x in range(2, 8) if fraction >= 1 / x), 8 ) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 423226b1d..f29234589 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -234,9 +234,9 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, - this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, - this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, + (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, + this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } @@ -305,7 +305,7 @@ class MipiSpi : public display::Display, this->write_command_(BRIGHTNESS, this->brightness_.value()); // calculate new madctl value from base value adjusted for rotation - uint8_t madctl = MADCTL; // lower 8 bits only + uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; @@ -546,13 +546,12 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < this->get_height_internal(); - this->start_line_ += this->get_height_internal() / FRACTION) { + auto increment = (this->get_height_internal() / FRACTION / ROUNDING) * ROUNDING; + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); this->start_line_ = this->end_line_) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = - clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); + this->end_line_ = clamp_at_most(this->start_line_ + increment, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -574,12 +573,13 @@ class MipiSpiBuffer : public MipiSpix_low_ = this->x_low_ / ROUNDING * ROUNDING; this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; - this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; - this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->x_high_ = round_buffer(this->x_high_ + 1) - 1; + this->y_high_ = clamp_at_most(round_buffer(this->y_high_ + 1) - 1, this->end_line_ - 1); int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); + this->y_low_ - this->start_line_, + round_buffer(this->get_width_internal()) - w - this->x_low_); // invalidate watermarks this->x_low_ = this->get_width_internal(); this->y_low_ = this->get_height_internal(); diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 7229412f1..0a35cb4fe 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,4 +1,6 @@ -from .ili import ILI9341, ILI9342, ST7789V +from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER + +from .ili import GC9A01A, ILI9341, ILI9342, ST7789V ILI9341.extend( # ESP32-2432S028 CYD board with Micro USB, has ILI9341 controller @@ -43,3 +45,10 @@ ILI9342.extend( (0xE1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x23, 0x33, 0x0F), # Negative Gamma Correction ) ) + +GC9A01A.extend( + "ESP32-2424S012", + invert_colors=True, + cs_pin=10, + dc_pin={CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 6b672b085..ae6accb90 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -555,7 +555,7 @@ ST7789V = DriverChip( ), ), ) -DriverChip( +GC9A01A = DriverChip( "GC9A01A", mirror_x=True, width=240, diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index cc86101f5..ee8bd0670 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -15,7 +15,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from .amoled import CO5300 -from .ili import ILI9488_A +from .ili import ILI9488_A, ST7789V from .jc import AXS15231 DriverChip( @@ -243,3 +243,15 @@ ST7789P.extend( ), ), ) + +ST7789V.extend( + "WAVESHARE-ESP32-C6-LCD-1.47", + width=172, + height=320, + offset_width=34, + invert_colors=True, + data_rate="40MHz", + reset_pin=21, + cs_pin=14, + dc_pin={"number": 15, "ignore_strapping_warning": True}, +) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 40ddb88a7..284339e57 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.climate"; static constexpr std::array MODE_MAP{ - std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_AUTO}, + std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, std::pair{MitsubishiCN105::Mode::HEAT, climate::CLIMATE_MODE_HEAT}, std::pair{MitsubishiCN105::Mode::DRY, climate::CLIMATE_MODE_DRY}, std::pair{MitsubishiCN105::Mode::COOL, climate::CLIMATE_MODE_COOL}, @@ -76,23 +76,13 @@ void MitsubishiCN105Climate::loop() { climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; - traits.set_supported_modes({ - climate::CLIMATE_MODE_OFF, - climate::CLIMATE_MODE_COOL, - climate::CLIMATE_MODE_HEAT, - climate::CLIMATE_MODE_DRY, - climate::CLIMATE_MODE_FAN_ONLY, - climate::CLIMATE_MODE_AUTO, - }); + for (const auto &p : MODE_MAP) { + traits.add_supported_mode(p.second); + } - traits.set_supported_fan_modes({ - climate::CLIMATE_FAN_AUTO, - climate::CLIMATE_FAN_QUIET, - climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_HIGH, - }); + for (const auto &p : FAN_MODE_MAP) { + traits.add_supported_fan_mode(p.second); + } traits.set_visual_min_temperature(16.0f); traits.set_visual_max_temperature(31.0f); diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 77190b284..89dc3c08b 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -5,6 +5,29 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +static size_t required_payload_size(SensorValueType sensor_value_type) { + switch (sensor_value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + return 2; + case SensorValueType::U_DWORD: + case SensorValueType::FP32: + case SensorValueType::U_DWORD_R: + case SensorValueType::FP32_R: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + return 4; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + return 8; + case SensorValueType::RAW: + default: + return 0; + } +} + void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { switch (value_type) { case SensorValueType::U_WORD: @@ -47,93 +70,70 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits - if (offset > data.size()) { - ESP_LOGE(TAG, "not enough data for value"); + // Validate offset against the buffer for all types, including RAW/unsupported, so + // a malformed or misconfigured frame still produces an error log. + if (static_cast(offset) > data.size()) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), + static_cast(offset), data.size()); + return value; + } + + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return value; + } + + if (data.size() - offset < required_size) { + ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", + static_cast(sensor_value_type), static_cast(offset), data.size(), + required_size); return value; } - size_t size = data.size() - offset; - bool error = false; switch (sensor_value_type) { case SensorValueType::U_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::U_DWORD: case SensorValueType::FP32: - if (size >= 4) { - value = get_data(data, offset); - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::U_DWORD_R: case SensorValueType::FP32_R: - if (size >= 4) { - value = get_data(data, offset); - value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } + value = get_data(data, offset); + value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); break; case SensorValueType::S_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; case SensorValueType::S_DWORD: - if (size >= 4) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); - } else { - error = true; - } + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); break; case SensorValueType::S_DWORD_R: { - if (size >= 4) { - value = get_data(data, offset); - // Currently the high word is at the low position - // the sign bit is therefore at low before the switch - uint32_t sign_bit = (value & 0x8000) << 16; - value = mask_and_shift_by_rightbit( - static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); - } else { - error = true; - } + value = get_data(data, offset); + // Currently the high word is at the low position + // the sign bit is therefore at low before the switch + uint32_t sign_bit = (value & 0x8000) << 16; + value = mask_and_shift_by_rightbit( + static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); } break; case SensorValueType::U_QWORD: case SensorValueType::S_QWORD: // Ignore bitmask for QWORD - if (size >= 8) { - value = get_data(data, offset); - } else { - error = true; - } + value = get_data(data, offset); break; case SensorValueType::U_QWORD_R: case SensorValueType::S_QWORD_R: { // Ignore bitmask for QWORD - if (size >= 8) { - uint64_t tmp = get_data(data, offset); - value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); - } else { - error = true; - } + uint64_t tmp = get_data(data, offset); + value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); } break; case SensorValueType::RAW: default: break; } - if (error) - ESP_LOGE(TAG, "not enough data for value"); return value; } } // namespace esphome::modbus::helpers diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1f75b1217..811e7c875 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -109,21 +109,21 @@ CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( CONF_ENABLE_IPV6, - esp8266=False, - esp32=False, - rp2040=False, bk72xx=False, + esp32=False, + esp8266=False, host=False, + rp2040=False, ): cv.All( cv.boolean, cv.Any( cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), esp_idf=cv.Version(0, 0, 0), esp32_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), - bk72xx_arduino=cv.Version(1, 7, 0), host=cv.Version(0, 0, 0), + rp2040_arduino=cv.Version(0, 0, 0), ), cv.boolean_false, ), @@ -218,9 +218,9 @@ async def to_code(config): elif enable_ipv6: cg.add_build_flag("-DCONFIG_LWIP_IPV6") cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG") - if CORE.is_rp2040: - cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") - if CORE.is_esp8266: - cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") if CORE.is_bk72xx: cg.add_build_flag("-DCONFIG_IPV6") + if CORE.is_esp8266: + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") + if CORE.is_rp2040: + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b0e14b5ea..e42f7ca21 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) { } #ifdef USE_NEXTION_COMMAND_SPACING - if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) { - ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str()); + const uint32_t now = App.get_loop_component_start_time(); + if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) { + ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str()); return false; } #endif // USE_NEXTION_COMMAND_SPACING @@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) { const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF}; this->write_array(to_send, sizeof(to_send)); +#ifdef USE_NEXTION_COMMAND_SPACING + // Mark sent immediately after writing to UART. The pacer enforces inter-command + // spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_ + // at zero indefinitely, making can_send() always return true and spacing a no-op. + // ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally. + if (!this->connection_state_.ignore_is_setup_) { + this->command_pacer_.mark_sent(now); + } +#endif // USE_NEXTION_COMMAND_SPACING + return true; } @@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping()) return false; - if (this->send_command_(command)) { - this->add_no_result_to_queue_("command"); - return true; - } - return false; + this->add_no_result_to_queue_with_command_("command", command); + return true; } bool Nextion::send_command_printf(const char *format, ...) { @@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) { return false; } - if (this->send_command_(buffer)) { - this->add_no_result_to_queue_("command_printf"); - return true; - } - return false; + this->add_no_result_to_queue_with_command_("command_printf", buffer); + return true; } #ifdef NEXTION_PROTOCOL_LOG @@ -349,25 +354,43 @@ void Nextion::loop() { } #ifdef USE_NEXTION_COMMAND_SPACING - // Try to send any pending commands if spacing allows this->process_pending_in_queue_(); +#ifdef USE_NEXTION_WAVEFORM + if (!this->waveform_queue_.empty()) { + this->check_pending_waveform_(); + } +#endif // USE_NEXTION_WAVEFORM #endif // USE_NEXTION_COMMAND_SPACING } #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::process_pending_in_queue_() { - if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) { - return; - } +#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP + size_t commands_sent = 0; +#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP - // Check if first item in queue has a pending command - auto *front_item = this->nextion_queue_.front(); - if (front_item && !front_item->pending_command.empty()) { - if (this->send_command_(front_item->pending_command)) { - // Command sent successfully, clear the pending command - front_item->pending_command.clear(); - ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str()); + for (auto *item : this->nextion_queue_) { + if (item == nullptr || item->pending_command.empty()) { + continue; // Already sent, waiting for ACK — skip, don't stop } + +#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP + if (++commands_sent > this->max_commands_per_loop_) { + ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring"); + break; + } +#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP + + const uint32_t now = App.get_loop_component_start_time(); + if (!this->command_pacer_.can_send(now)) { + break; // Spacing not elapsed, stop for this loop iteration + } + + if (!this->send_command_(item->pending_command)) { + break; // Unexpected send failure, stop + } + item->pending_command.clear(); + ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str()); } } #endif // USE_NEXTION_COMMAND_SPACING @@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() { this->setup_callback_.call(); } } -#ifdef USE_NEXTION_COMMAND_SPACING - this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent - ESP_LOGN(TAG, "Command spacing: marked command sent"); -#endif break; case 0x02: // invalid Component ID or name was used ESP_LOGW(TAG, "Invalid component ID/name"); @@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { } /** - * @brief + * @brief Send a command and enqueue it for response tracking. * - * @param variable_name Variable name for the queue - * @param command + * Callers are responsible for checking is_sleeping() before calling this + * method. The sleep guard is deliberately absent here because some callers + * (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly + * sleep-safe and must bypass it. + * + * If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready, + * the command is saved in the queue entry for retry rather than dropped. + * + * @param variable_name Name of the variable or component associated with the command. + * @param command The raw command string to send. */ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty()) @@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { std::string command = "get " + component->get_variable_name_to_send(); +#ifdef USE_NEXTION_COMMAND_SPACING + // Always enqueue first so the response handler is present when the command + // is eventually sent. Store the command for retry if spacing blocked it; + // process_pending_in_queue_() will transmit it when the pacer allows. + nextion_queue->pending_command = command; + this->nextion_queue_.push_back(nextion_queue); + if (this->send_command_(command)) { + nextion_queue->pending_command.clear(); + } +#else // USE_NEXTION_COMMAND_SPACING if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); + } else { + delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) } +#endif // USE_NEXTION_COMMAND_SPACING } #ifdef USE_NEXTION_WAVEFORM @@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() { char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); - if (!this->send_command_(command)) { - delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop(); - } + // If spacing or setup state blocks the send, leave the entry at the front + // of waveform_queue_ for retry on the next loop iteration via + // check_pending_waveform_(). Only pop on a successful send. + this->send_command_(command); } #endif // USE_NEXTION_WAVEFORM diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c84a5cd49..c62772ac7 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -55,15 +55,20 @@ class NextionCommandPacer { uint8_t get_spacing() const { return spacing_ms_; } /** - * @brief Check if enough time has passed to send next command - * @return true if enough time has passed since last command + * @brief Check if enough time has passed to send the next command. + * @param now Current timestamp in milliseconds (use App.get_loop_component_start_time() + * for consistency with the rest of the queue timing). + * @return true if the spacing interval has elapsed since the last command was sent. */ - bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; } + bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; } /** - * @brief Mark a command as sent, updating the timing + * @brief Record the transmit timestamp for the most recently sent command. + * @param now Current timestamp in milliseconds, as returned by + * App.get_loop_component_start_time(). Must use the same clock + * source as can_send() to avoid unsigned underflow. */ - void mark_sent() { last_command_time_ = millis(); } + void mark_sent(uint32_t now) { last_command_time_ = now; } private: uint8_t spacing_ms_; diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 27b7d58a0..99e1f352f 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -57,8 +57,11 @@ void OneWireBus::search() { } } -void OneWireBus::skip() { +bool OneWireBus::skip() { + if (!this->reset_()) + return false; this->write8(0xCC); // skip ROM + return true; } const LogString *OneWireBus::get_model_str(uint8_t model) { diff --git a/esphome/components/one_wire/one_wire_bus.h b/esphome/components/one_wire/one_wire_bus.h index c88532046..6302fcee7 100644 --- a/esphome/components/one_wire/one_wire_bus.h +++ b/esphome/components/one_wire/one_wire_bus.h @@ -16,7 +16,8 @@ class OneWireBus { virtual void write64(uint64_t val) = 0; /// Write a command to the bus that addresses all devices by skipping the ROM. - void skip(); + /// Returns true if a device presence pulse is detected. + bool skip(); /// Read an 8 bit word from the bus. virtual uint8_t read8() = 0; diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 04db690c6..1b9e03d88 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -8,8 +8,11 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( ContextVars, + ErrList, push_context, + raise_first_undefined, resolve_include, + resolve_substitutions_block, substitute, ) from esphome.components.substitutions.jinja import has_jinja @@ -39,12 +42,29 @@ DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 +PackageCallback = Callable[ + [dict | str | yaml_util.IncludeFile, ContextVars | None, yaml_util.DocumentPath], + dict, +] + def is_remote_package(package_config: dict) -> bool: """Returns True if the package_config is a remote package definition.""" return CONF_URL in package_config +def is_package_definition(value: object) -> bool: + """Returns True if the value looks like a package definition rather than a config fragment. + + Package definitions are IncludeFile objects, git URL shorthand strings, or + remote package dicts (containing a ``url:`` key). Config fragments are + plain dicts that represent component configuration. + """ + return isinstance(value, (yaml_util.IncludeFile, str)) or ( + isinstance(value, dict) and is_remote_package(value) + ) + + def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -266,8 +286,9 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: def _walk_package_dict( packages: dict, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None, + path: yaml_util.DocumentPath, ) -> cv.Invalid | None: """Iterate a packages dict in reverse priority order, invoking callback on each entry. @@ -276,7 +297,9 @@ def _walk_package_dict( for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): try: - packages[package_name] = callback(package_config, context) + packages[package_name] = callback( + package_config, context, path + [package_name] + ) except cv.Invalid as err: return err return None @@ -284,20 +307,22 @@ def _walk_package_dict( def _walk_package_list( packages: list, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None, + path: yaml_util.DocumentPath, ) -> None: """Iterate a packages list in reverse priority order, invoking callback on each entry.""" for idx in reversed(range(len(packages))): with cv.prepend_path(idx): - packages[idx] = callback(packages[idx], context) + packages[idx] = callback(packages[idx], context, path + [idx]) def _walk_packages( config: dict, - callback: Callable[[dict, ContextVars | None], dict], + callback: PackageCallback, context: ContextVars | None = None, validate_deprecated: bool = True, + path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -308,32 +333,44 @@ def _walk_packages( if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] - - if not isinstance(packages, (dict, list)): - raise cv.Invalid( - f"Packages must be a key to value mapping or list, got {type(packages)} instead" - ) + packages_path = (path or []) + [CONF_PACKAGES] with cv.prepend_path(CONF_PACKAGES): + if isinstance(packages, yaml_util.IncludeFile): + # If the packages key is an IncludeFile, resolve it first before processing. + packages = resolve_include( + packages, packages_path, context, strict_undefined=False + ) + if not isinstance(packages, (dict, list)): + raise cv.Invalid( + f"Packages must be a key to value mapping or list, got {type(packages)} instead" + ) + if not isinstance(packages, dict): - _walk_package_list(packages, callback, context) - elif (result := _walk_package_dict(packages, callback, context)) is not None: - if not validate_deprecated: + _walk_package_list(packages, callback, context, packages_path) + elif ( + result := _walk_package_dict(packages, callback, context, packages_path) + ) is not None: + if not validate_deprecated or any( + is_package_definition(v) for v in packages.values() + ): raise result # Fallback: treat the dict as a single deprecated package. - # Note: this catches *any* cv.Invalid from the callback, which may - # mask real validation errors in named package dicts. # This block can be removed once the single-package # deprecation period (2026.7.0) is over. config[CONF_PACKAGES] = [packages] - return _walk_packages(deprecate_single_package(config), callback, context) + return _walk_packages( + deprecate_single_package(config), callback, context, path=path + ) config[CONF_PACKAGES] = packages return config def _substitute_package_definition( - package_config: dict | str, context_vars: ContextVars | None + package_config: dict | str, + context_vars: ContextVars | None, + path: yaml_util.DocumentPath | None = None, ) -> dict | str: """Substitute variables in a package definition string or remote package dict. @@ -341,15 +378,37 @@ def _substitute_package_definition( Local package contents are left untouched — they will be substituted later during the main substitution pass. """ - if isinstance(package_config, str) or ( - isinstance(package_config, dict) and is_remote_package(package_config) - ): + + def do_substitute(package_config: dict | str) -> dict | str: + # Collect undefined-variable errors (rather than raising strict) so the + # path walked through a remote-package dict is preserved and the user + # sees which field (url / path / ref / ...) referenced the undefined + # variable. + errors: ErrList = [] package_config = substitute( item=package_config, - path=[], + path=path or [], parent_context=context_vars or ContextVars(), strict_undefined=False, + errors=errors, ) + raise_first_undefined(errors, "package definition") + return package_config + + if isinstance(package_config, str): + return do_substitute(package_config) + + if isinstance(package_config, dict) and is_remote_package(package_config): + # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be + # passed as-is to the package YAML and may contain their own substitution expressions that should not + # be prematurely evaluated here. + if CONF_FILES in package_config: + for file_def in package_config[CONF_FILES]: + if isinstance(file_def, dict) and CONF_VARS in file_def: + file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS]) + + package_config = do_substitute(package_config) + return package_config @@ -407,6 +466,7 @@ class _PackageProcessor: self, package_config: dict | str | yaml_util.IncludeFile, context_vars: ContextVars | None, + path: yaml_util.DocumentPath, ) -> dict: """Resolve a package definition to a concrete ``dict`` and fetch remote packages. @@ -429,15 +489,15 @@ class _PackageProcessor: """ for _ in range(MAX_INCLUDE_DEPTH): if isinstance(package_config, yaml_util.IncludeFile): - package_config, _ = resolve_include( + package_config = resolve_include( package_config, - [], + path, context_vars or ContextVars(), strict_undefined=False, ) package_config = _substitute_package_definition( - package_config, context_vars + package_config, context_vars, path ) package_config = PACKAGE_SCHEMA(package_config) if isinstance(package_config, dict): @@ -458,10 +518,16 @@ class _PackageProcessor: _update_substitutions_context(self.parent_context, subs) def process_package( - self, package_config: dict | str, context_vars: ContextVars | None + self, + package_config: dict | str, + context_vars: ContextVars | None, + path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" - package_config = self.resolve_package(package_config, context_vars) + from_remote = isinstance(package_config, dict) and is_remote_package( + package_config + ) + package_config = self.resolve_package(package_config, context_vars, path) self.collect_substitutions(package_config) if CONF_PACKAGES not in package_config: @@ -470,7 +536,19 @@ class _PackageProcessor: # Push context from !include vars on the package root and on the packages key context_vars = push_context(package_config, context_vars) context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - return _walk_packages(package_config, self.process_package, context_vars) + # Disable the deprecated single-package fallback for remote + # packages. _process_remote_package returns dicts with + # already-resolved values that is_package_definition cannot + # distinguish from config fragments, so the fallback would + # always fire and mask real errors with wrong paths + # (packages->0 instead of packages->). + return _walk_packages( + package_config, + self.process_package, + context_vars, + validate_deprecated=not from_remote, + path=path, + ) def do_packages_pass( @@ -487,7 +565,12 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + with cv.prepend_path(CONF_SUBSTITUTIONS): + substitutions = UserDict( + resolve_substitutions_block( + config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions + ) + ) processor = _PackageProcessor( substitutions, command_line_substitutions, skip_update ) @@ -521,11 +604,13 @@ def merge_packages(config: dict) -> dict: merge_list: list[dict] = [] def process_package_callback( - package_config: dict, context: ContextVars | None + package_config: dict, + context: ContextVars | None, + path: yaml_util.DocumentPath | None = None, ) -> dict: """This will be called for each package found in the config.""" merge_list.append(package_config) - return _walk_packages(package_config, process_package_callback) + return _walk_packages(package_config, process_package_callback, path=path) _walk_packages(config, process_package_callback, validate_deprecated=False) # Merge all packages into the main config: diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index dc7463b01..d617336e7 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -62,7 +62,10 @@ void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enabl void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index ac4f119df..393bbfd61 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_ void PCA9554Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 1cf28a495..000de1433 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -44,7 +44,7 @@ void PCF85063Component::read_time() { .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index b748f0156..50003ca37 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -44,7 +44,7 @@ void PCF8563Component::read_time() { .year = uint16_t(pcf8563_.reg.year + 10u * pcf8563_.reg.year_10 + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index bf4a9442a..8fe852679 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_ void PCF8574Component::loop() { // Invalidate the cache so the next digital_read() triggers a fresh I2C read this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { - // Interrupt-driven: disable loop until next interrupt fires + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 6e8631022..00f29983b 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { void PI4IOE5V6408Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index bc2adb5cf..44bd006c1 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -24,6 +24,8 @@ static const uint8_t QMC5883L_REGISTER_CONTROL_1 = 0x09; static const uint8_t QMC5883L_REGISTER_CONTROL_2 = 0x0A; static const uint8_t QMC5883L_REGISTER_PERIOD = 0x0B; +void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enable_loop_soon_any_context(); } + void QMC5883LComponent::setup() { // Soft Reset if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) { @@ -35,6 +37,12 @@ void QMC5883LComponent::setup() { if (this->drdy_pin_) { this->drdy_pin_->setup(); + if (this->drdy_pin_->is_internal()) { + static_cast(this->drdy_pin_) + ->attach_interrupt(&QMC5883LComponent::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + this->drdy_use_isr_ = true; + this->stop_poller(); + } } uint8_t control_1 = 0; @@ -65,8 +73,8 @@ void QMC5883LComponent::setup() { return; } - if (this->get_update_interval() < App.get_loop_interval()) { - high_freq_.start(); + if (!this->drdy_use_isr_ && this->get_update_interval() < App.get_loop_interval()) { + this->high_freq_.start(); } } @@ -84,23 +92,39 @@ void QMC5883LComponent::dump_config() { LOG_SENSOR(" ", "Heading", this->heading_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_PIN(" DRDY Pin: ", this->drdy_pin_); + if (this->drdy_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " DRDY mode: %s", + this->drdy_use_isr_ ? LOG_STR_LITERAL("interrupt") : LOG_STR_LITERAL("polling")); + } } void QMC5883LComponent::update() { - i2c::ErrorCode err; - uint8_t status = false; - - // If DRDY pin is configured and the data is not ready return. + // If DRDY is on an external expander we keep the polling path and early-return + // if data is not ready yet. Internal DRDY pins take the ISR path via loop(). if (this->drdy_pin_ && !this->drdy_pin_->digital_read()) { return; } + this->read_sensor_(); +} + +void QMC5883LComponent::loop() { + this->disable_loop(); + if (!this->drdy_use_isr_ || !this->drdy_pin_->digital_read()) { + return; + } + this->read_sensor_(); +} + +void QMC5883LComponent::read_sensor_() { + i2c::ErrorCode err; + uint8_t status = false; // Status byte gets cleared when data is read, so we have to read this first. // If status and two axes are desired, it's possible to save one byte of traffic by enabling // ROL_PNT in setup and reading 7 bytes starting at the status register. // If status and all three axes are desired, using ROL_PNT saves you 3 bytes. // But simply not reading status saves you 4 bytes always and is much simpler. - if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { char buf[32]; @@ -165,7 +189,7 @@ void QMC5883LComponent::update() { temp = int16_t(raw_temp) * 0.01f; } - ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, + ESP_LOGV(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f° temperature=%0.01f°C status=%u", x, y, z, heading, temp, status); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 21ef9c2a1..2ab6aa3e9 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -32,6 +32,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void setup() override; void dump_config() override; void update() override; + void loop() override; void set_drdy_pin(GPIOPin *pin) { drdy_pin_ = pin; } void set_datarate(QMC5883LDatarate datarate) { datarate_ = datarate; } @@ -44,6 +45,9 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } protected: + static void IRAM_ATTR gpio_intr(QMC5883LComponent *arg); + void read_sensor_(); + QMC5883LDatarate datarate_{QMC5883L_DATARATE_10_HZ}; QMC5883LRange range_{QMC5883L_RANGE_200_UT}; QMC5883LOversampling oversampling_{QMC5883L_SAMPLING_512}; @@ -53,6 +57,7 @@ class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *heading_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; GPIOPin *drdy_pin_{nullptr}; + bool drdy_use_isr_{false}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py new file mode 100644 index 000000000..b00590ceb --- /dev/null +++ b/esphome/components/radio_frequency/__init__.py @@ -0,0 +1,77 @@ +""" +Radio Frequency component for ESPHome. + +WARNING: This component is EXPERIMENTAL. The API (both Python configuration +and C++ interfaces) may change at any time without following the normal +breaking changes policy. Use at your own risk. + +Once the API is considered stable, this warning will be removed. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import setup_entity +from esphome.coroutine import CoroPriority +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["remote_base"] + +IS_PLATFORM_COMPONENT = True + +radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency") +RadioFrequency = radio_frequency_ns.class_( + "RadioFrequency", cg.EntityBase, cg.Component +) +RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall") +RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits") +RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation") + +CONF_RADIO_FREQUENCY_ID = "radio_frequency_id" + + +def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema: + """Create a schema for a radio frequency platform. + + :param class_: The radio frequency class to use for this schema. + :return: An extended schema for radio frequency configuration. + """ + entity_schema = cv.ENTITY_BASE_SCHEMA.extend(cv.COMPONENT_SCHEMA) + return entity_schema.extend( + { + cv.GenerateID(): cv.declare_id(class_), + } + ) + + +@setup_entity("radio_frequency") +async def setup_radio_frequency_core_(var: cg.Pvariable, config: ConfigType) -> None: + """Set up core radio frequency configuration.""" + + +async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> None: + """Register a radio frequency device with the core.""" + cg.add_define("USE_RADIO_FREQUENCY") + await cg.register_component(var, config) + await setup_radio_frequency_core_(var, config) + cg.add(cg.App.register_radio_frequency(var)) + CORE.register_platform_component("radio_frequency", var) + + +async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable: + """Create a new RadioFrequency instance. + + :param config: Configuration dictionary. + :param args: Additional arguments to pass to new_Pvariable. + :return: The created RadioFrequency instance. + """ + var = cg.new_Pvariable(config[CONF_ID], *args) + await register_radio_frequency(var, config) + return var + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config: ConfigType) -> None: + cg.add_global(radio_frequency_ns.using) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp new file mode 100644 index 000000000..3c000ae1c --- /dev/null +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -0,0 +1,109 @@ +#include "radio_frequency.h" + +#include + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + +namespace esphome::radio_frequency { + +static const char *const TAG = "radio_frequency"; + +// ========== RadioFrequencyCall ========== + +RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) { + this->frequency_hz_ = frequency_hz; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) { + this->modulation_ = modulation; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector &timings) { + this->raw_timings_ = &timings; + this->packed_data_ = nullptr; + this->base64url_ptr_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) { + this->base64url_ptr_ = &base64url; + this->raw_timings_ = nullptr; + this->packed_data_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { + this->packed_data_ = data; + this->packed_length_ = length; + this->packed_count_ = count; + this->raw_timings_ = nullptr; + this->base64url_ptr_ = nullptr; + return *this; +} + +RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { + this->repeat_count_ = count; + return *this; +} + +void RadioFrequencyCall::perform() { + if (this->parent_ != nullptr) { + this->parent_->control(*this); + } +} + +// ========== RadioFrequency ========== + +void RadioFrequency::dump_config() { + ESP_LOGCONFIG(TAG, + "Radio Frequency '%s'\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + if (this->traits_.get_frequency_min_hz() > 0) { + if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz()); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %" PRIu32 " - %" PRIu32 " Hz", this->traits_.get_frequency_min_hz(), + this->traits_.get_frequency_max_hz()); + } + } +} + +RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); } + +uint32_t RadioFrequency::get_capability_flags() const { + uint32_t flags = 0; + if (this->traits_.get_supports_transmitter()) + flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER; + if (this->traits_.get_supports_receiver()) + flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER; + return flags; +} + +bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { + // Invoke local callbacks + this->receive_callback_.call(data); + + // Forward received RF data to API server +#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) + if (api::global_api_server != nullptr) { +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + } +#endif + return false; // Don't consume the event, allow other listeners to process it +} + +} // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h new file mode 100644 index 000000000..db73a844e --- /dev/null +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -0,0 +1,187 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/components/remote_base/remote_base.h" + +#include + +namespace esphome::radio_frequency { + +/// Capability flags for individual radio frequency instances +enum RadioFrequencyCapability : uint32_t { + CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals + CAPABILITY_RECEIVER = 1 << 1, // Can receive signals +}; + +/// Modulation types supported by radio frequency implementations +enum RadioFrequencyModulation : uint8_t { + RADIO_FREQUENCY_MODULATION_OOK = 0, // On-Off Keying / Amplitude Shift Keying + // Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc. +}; + +/// Forward declarations +class RadioFrequency; + +/// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals +class RadioFrequencyCall { + public: + explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + + /// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz) + RadioFrequencyCall &set_frequency(uint32_t frequency_hz); + + /// Set the modulation type (defaults to OOK) + RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation); + + // ===== Raw Timings Methods ===== + // All set_raw_timings_* methods store pointers/references to external data. + // The referenced data must remain valid until perform() completes. + // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous + // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! + + /// Set the raw timings from a vector (positive = mark, negative = space) + /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). + /// @note Usage: Primarily for lambdas/automations where the vector is in scope. + RadioFrequencyCall &set_raw_timings(const std::vector &timings); + + /// Set the raw timings from base64url-encoded little-endian int32 data + /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). + /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). + /// @note Decoding happens at perform() time, directly into the transmit buffer. + RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url); + + /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) + /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). + /// @note Usage: For API component where data comes directly from the protobuf message. + RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); + + /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) + RadioFrequencyCall &set_repeat_count(uint32_t count); + + /// Perform the transmission + void perform(); + + /// Get the frequency in Hz + const optional &get_frequency() const { return this->frequency_hz_; } + /// Get the modulation type + RadioFrequencyModulation get_modulation() const { return this->modulation_; } + /// Get the raw timings (only valid if set via set_raw_timings) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (any format) + bool has_raw_timings() const { + return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; + } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// Check if using base64url data format + bool is_base64url() const { return this->base64url_ptr_ != nullptr; } + /// Get the base64url data string + const std::string &get_base64url_data() const { return *this->base64url_ptr_; } + /// Get packed data (only valid if set via set_raw_timings_packed) + const uint8_t *get_packed_data() const { return this->packed_data_; } + uint16_t get_packed_length() const { return this->packed_length_; } + uint16_t get_packed_count() const { return this->packed_count_; } + /// Get the repeat count + uint32_t get_repeat_count() const { return this->repeat_count_; } + + protected: + optional frequency_hz_{}; + uint32_t repeat_count_{1}; + RadioFrequency *parent_; + // Pointer to vector-based timings (caller-owned, must outlive perform()) + const std::vector *raw_timings_{nullptr}; + // Pointer to base64url-encoded string (caller-owned, must outlive perform()) + const std::string *base64url_ptr_{nullptr}; + // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) + const uint8_t *packed_data_{nullptr}; + uint16_t packed_length_{0}; + uint16_t packed_count_{0}; + RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK}; +}; + +/// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation +class RadioFrequencyTraits { + public: + bool get_supports_transmitter() const { return this->supports_transmitter_; } + void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } + + bool get_supports_receiver() const { return this->supports_receiver_; } + void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + + /// Hardware-supported tunable frequency range in Hz. + /// If min == max (and both non-zero): fixed-frequency hardware. + /// If both 0: range unspecified. + uint32_t get_frequency_min_hz() const { return this->frequency_min_hz_; } + void set_frequency_min_hz(uint32_t freq) { this->frequency_min_hz_ = freq; } + + uint32_t get_frequency_max_hz() const { return this->frequency_max_hz_; } + void set_frequency_max_hz(uint32_t freq) { this->frequency_max_hz_ = freq; } + + /// Convenience setter for fixed-frequency hardware (sets min == max). + void set_fixed_frequency_hz(uint32_t freq) { + this->frequency_min_hz_ = freq; + this->frequency_max_hz_ = freq; + } + + /// Bitmask of supported RadioFrequencyModulation values (bit N = modulation value N supported). + uint32_t get_supported_modulations() const { return this->supported_modulations_; } + void set_supported_modulations(uint32_t mask) { this->supported_modulations_ = mask; } + void add_supported_modulation(RadioFrequencyModulation mod) { + this->supported_modulations_ |= (1u << static_cast(mod)); + } + + protected: + uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified) + uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified) + uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +}; + +/// RadioFrequency - Base class for radio frequency implementations +class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + public: + RadioFrequency() = default; + + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Get the traits for this radio frequency implementation + RadioFrequencyTraits &get_traits() { return this->traits_; } + const RadioFrequencyTraits &get_traits() const { return this->traits_; } + + /// Create a call object for transmitting + RadioFrequencyCall make_call(); + + /// Get capability flags for this radio frequency instance + uint32_t get_capability_flags() const; + + /// Called when RF data is received (from RemoteReceiverListener) + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Add a callback to invoke when RF data is received + template void add_on_receive_callback(F &&callback) { + this->receive_callback_.add(std::forward(callback)); + } + + protected: + friend class RadioFrequencyCall; + + /// Perform the actual transmission (called by RadioFrequencyCall::perform()) + /// Platforms must override this to implement hardware-specific transmission. + virtual void control(const RadioFrequencyCall &call) = 0; + + // Traits describing capabilities + RadioFrequencyTraits traits_; + + // Callback manager for receive events (lazy: saves memory when no callbacks registered) + LazyCallbackManager receive_callback_; +}; + +} // namespace esphome::radio_frequency diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index e452780d4..ed246416c 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -26,7 +26,7 @@ from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from . import boards -from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -240,6 +240,160 @@ async def to_code(config): cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) cg.add_define("USE_RP2040_CRASH_HANDLER") + _configure_lwip() + + +def _configure_lwip() -> None: + """Configure lwIP options for RP2040 by generating a custom lwipopts.h. + + Arduino-pico's lwipopts.h has no #ifndef guards, so -D flags cannot override + its settings. Instead, we generate a replacement lwipopts.h and place it in an + include directory that shadows the framework's version. + + lwIP is compiled from source on RP2040 (not pre-built), so our replacement + header fully controls the compiled lwIP behavior. + + RP2040 uses NO_SYS=1 (polling, no RTOS thread), LWIP_SOCKET=0, LWIP_NETCONN=0. + DHCP/DNS use raw udp_new() which allocates from MEMP_NUM_UDP_PCB. + + Comparison of arduino-pico defaults vs ESPHome targets (TCP_MSS=1460): + + Setting ESP8266 ESP32 arduino-pico New + ──────────────────────────────────────────────────────────────── + TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS + TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + MEM_LIBC_MALLOC 1 1 0 0* + MEMP_MEM_MALLOC 1 1 0 0** + MEM_SIZE N/A*** N/A*** 16KB 16KB + PBUF_POOL_SIZE 10 16 24 16 + MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_PCB 5 16 5 dynamic + MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic + MEMP_NUM_UDP_PCB 4 16 7 dynamic + TCP_SND_QUEUELEN ~8 17 32 17 + + * MEM_LIBC_MALLOC must stay 0: arduino-pico uses + PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from + a low-priority pendsv IRQ. The pico-sdk explicitly blocks + MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ). + ** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB) + is too small to hold all pools dynamically. The PBUF_POOL alone needs + ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings. + *** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). + **** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. + "dynamic" = auto-calculated from component socket registrations via + socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. + """ + from esphome.components.socket import ( + MIN_TCP_LISTEN_SOCKETS, + MIN_TCP_SOCKETS, + MIN_UDP_SOCKETS, + get_socket_counts, + ) + + sc = get_socket_counts() + # Apply platform minimums — ensure headroom for ESPHome's needs + tcp_sockets = max(MIN_TCP_SOCKETS, sc.tcp) + udp_sockets = max(MIN_UDP_SOCKETS, sc.udp) + # RP2040 has more RAM (264KB) than most LibreTiny boards, so DHCP/DNS + # UDP PCBs (2) are absorbed by the generous minimum of 6. + listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen) + + # TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. + # ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. + tcp_snd_buf = "(4*TCP_MSS)" + + # TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. + tcp_wnd = "(4*TCP_MSS)" + + # TCP_SND_QUEUELEN: max pbufs queued for send buffer + # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS + # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 + tcp_snd_queuelen = 17 + # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) + memp_num_tcp_seg = tcp_snd_queuelen + + # PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. + # 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1, + # this is a max count (allocated on demand from heap). + pbuf_pool_size = 16 + + # Build the lwIP override defines for the Jinja2 template. + # The template uses #include_next to chain to the framework's original + # lwipopts.h, then #undef/#define only the values we need to change. + # + # Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp + # allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE + # is too small to hold all pools dynamically under stress. The PBUF_POOL + # alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate + # the BSS savings. + # + # MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses + # PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from + # a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe. + lwip_defines: dict[str, str] = { + "TCP_SND_BUF": tcp_snd_buf, + "TCP_WND": tcp_wnd, + "TCP_SND_QUEUELEN": str(tcp_snd_queuelen), + "MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg), + "PBUF_POOL_SIZE": str(pbuf_pool_size), + "MEMP_NUM_TCP_PCB": str(tcp_sockets), + "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), + "MEMP_NUM_UDP_PCB": str(udp_sockets), + } + + # Store for copy_files() to generate the header + CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + + # Add a pre-build extra script that injects our lwip_override directory + # into CCFLAGS so our lwipopts.h shadows the framework's version. + # Regular build_flags (-I/-isystem) come after -iwithprefixbefore in GCC's + # search order, so we must prepend via an extra_scripts hook. + cg.add_platformio_option("extra_scripts", ["pre:inject_lwip_include.py"]) + + tcp_min = " (min)" if tcp_sockets > sc.tcp else "" + udp_min = " (min)" if udp_sockets > sc.udp else "" + listen_min = " (min)" if listening_tcp > sc.tcp_listen else "" + _LOGGER.info( + "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + tcp_sockets, + tcp_min, + sc.tcp_details, + udp_sockets, + udp_min, + sc.udp_details, + listening_tcp, + listen_min, + sc.tcp_listen_details, + ) + + +def _generate_lwipopts_h() -> None: + """Generate a custom lwipopts.h that shadows the framework's version. + + Uses Jinja2 to render the template with the lwIP defines calculated + during code generation. The generated header is placed in lwip_override/ + in the build directory, and a pre-build script injects this directory + into the compiler include path before the framework's own include dir. + """ + from jinja2 import Environment, FileSystemLoader + + lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + if not lwip_defines: + return + + template_dir = Path(__file__).parent + jinja_env = Environment( + loader=FileSystemLoader(str(template_dir)), + keep_trailing_newline=True, + ) + template = jinja_env.get_template("lwipopts.h.jinja") + content = template.render(**lwip_defines) + + lwip_dir = CORE.relative_build_path("lwip_override") + lwip_dir.mkdir(parents=True, exist_ok=True) + write_file_if_changed(lwip_dir / "lwipopts.h", content) + def add_pio_file(component: str, key: str, data: str): try: @@ -289,6 +443,12 @@ def copy_files(): post_build_file, CORE.relative_build_path("post_build.py"), ) + inject_lwip_file = dir / "inject_lwip_include.py.script" + copy_file_if_changed( + inject_lwip_file, + CORE.relative_build_path("inject_lwip_include.py"), + ) + _generate_lwipopts_h() if generate_pio_files(): path = CORE.relative_src_path("esphome.h") content = read_file(path).rstrip("\n") diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index ab5f42d75..e381d0482 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -1,6 +1,7 @@ import esphome.codegen as cg KEY_BOARD = "board" +KEY_LWIP_OPTS = "lwip_opts" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2040/inject_lwip_include.py.script new file mode 100644 index 000000000..4ae9863e3 --- /dev/null +++ b/esphome/components/rp2040/inject_lwip_include.py.script @@ -0,0 +1,18 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os + +# PlatformIO pre-build script: inject lwip_override include path so our +# lwipopts.h shadows the framework's version during lwIP compilation. +# +# The arduino-pico builder uses -iprefix + -iwithprefixbefore for includes, +# which takes priority over CPPPATH (-I). We must inject our path into the +# CCFLAGS BEFORE the -iprefix flag to ensure our lwipopts.h is found first. + +lwip_dir = os.path.join(env["PROJECT_DIR"], "lwip_override") + +if os.path.isdir(lwip_dir): + # Insert -I at the beginning of CCFLAGS, before the framework's + # -iprefix/-iwithprefixbefore flags which would otherwise take priority. + env.Prepend(CCFLAGS=["-I", lwip_dir]) diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2040/lwipopts.h.jinja new file mode 100644 index 000000000..36d7d4da1 --- /dev/null +++ b/esphome/components/rp2040/lwipopts.h.jinja @@ -0,0 +1,46 @@ +// ESPHome lwIP configuration override for RP2040. +// Includes the framework's original lwipopts.h, then overrides specific +// settings to tune lwIP for ESPHome's IoT use case. +// +// This file is found first via -I injection (see inject_lwip_include.py.script). +// #include_next chains to the framework's original in include/lwipopts.h. +// Since the original uses #pragma once, it won't be included again later +// (e.g. via tusb_config.h), avoiding duplicate definition warnings. + +// Include the framework's original lwipopts.h first +#include_next "lwipopts.h" + +// --- ESPHome overrides below --- +// Only #undef and redefine values that differ from the framework defaults. + +// TCP send/receive buffers: 4xMSS matches ESP32 (down from 8xMSS) +#undef TCP_SND_BUF +#define TCP_SND_BUF {{ TCP_SND_BUF }} + +#undef TCP_WND +#define TCP_WND {{ TCP_WND }} + +// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +#undef TCP_SND_QUEUELEN +#define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} + +#undef MEMP_NUM_TCP_SEG +#define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} + +// Packet buffer pool: 16 matches ESP32 (down from 24) +#undef PBUF_POOL_SIZE +#define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} + +// PCB pools: sized to actual component needs via socket.get_socket_counts() +#undef MEMP_NUM_TCP_PCB +#define MEMP_NUM_TCP_PCB {{ MEMP_NUM_TCP_PCB }} + +#undef MEMP_NUM_TCP_PCB_LISTEN +#define MEMP_NUM_TCP_PCB_LISTEN {{ MEMP_NUM_TCP_PCB_LISTEN }} + +#undef MEMP_NUM_UDP_PCB +#define MEMP_NUM_UDP_PCB {{ MEMP_NUM_UDP_PCB }} + +// Listen backlog: match component needs +#undef TCP_DEFAULT_LISTEN_BACKLOG +#define TCP_DEFAULT_LISTEN_BACKLOG {{ MEMP_NUM_TCP_PCB_LISTEN }} diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 6fd750d51..a3b1dba4f 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad97..4880f9ac4 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 01f5aad81..a5f8567c9 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -294,57 +294,59 @@ void Rtttl::play(std::string rtttl) { } ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); - // Get default duration - this->position_ = this->rtttl_.find("d=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'd='"); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { - this->default_note_denominator_ = num; - } else { - ESP_LOGE(TAG, "Invalid default duration: %d", num); - return; - } - - // Get default octave - this->position_ = this->rtttl_.find("o=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing 'o="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { - this->default_octave_ = num; - } else { - ESP_LOGE(TAG, "Invalid default octave: %d", num); - return; - } - - // Get BPM - this->position_ = this->rtttl_.find("b=", this->position_); - if (this->position_ == std::string::npos) { - ESP_LOGE(TAG, "Missing b="); - return; - } - this->position_ += 2; - num = this->get_integer_(); - if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow - bpm = num; - } else { - ESP_LOGE(TAG, "Invalid BPM: %d", num); - return; - } - - this->position_ = this->rtttl_.find(':', this->position_); - if (this->position_ == std::string::npos) { + size_t name_end_position = this->position_; + size_t control_end = this->rtttl_.find(':', name_end_position + 1); + if (control_end == std::string::npos) { ESP_LOGE(TAG, "Missing second ':'"); return; } - this->position_++; + + // Get default duration + size_t pos = this->rtttl_.find("d=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { + this->default_note_denominator_ = num; + } else { + ESP_LOGE(TAG, "Invalid default duration: %d", num); + return; + } + } + + // Get default octave + pos = this->rtttl_.find("o=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { + this->default_octave_ = num; + } else { + ESP_LOGE(TAG, "Invalid default octave: %d", num); + return; + } + } + + // Get BPM + pos = this->rtttl_.find("b=", name_end_position); + if (pos == std::string::npos || pos >= control_end) { + ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); + } else { + this->position_ = pos + 2; + num = this->get_integer_(); + if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow + bpm = num; + } else { + ESP_LOGE(TAG, "Invalid BPM: %d", num); + return; + } + } + + this->position_ = control_end + 1; // BPM usually expresses the number of quarter notes per minute this->wholenote_duration_ = 60 * 1000L * 4 / bpm; // This is the time for whole note (in milliseconds) @@ -422,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1b..9dac92be2 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index fa42b5349..4c7f1bfb6 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -127,9 +127,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { uint32_t pos = this->get_position_(x, y); Color mapped_color = color; this->map_chroma_key(mapped_color); - this->buffer_[pos + 0] = mapped_color.r; + this->buffer_[pos + 0] = mapped_color.b; this->buffer_[pos + 1] = mapped_color.g; - this->buffer_[pos + 2] = mapped_color.b; + this->buffer_[pos + 2] = mapped_color.r; if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 3] = color.w; } diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 06714b5a4..d733394b7 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -32,40 +32,101 @@ void RuntimeStatsCollector::log_stats_() { " Period stats (last %" PRIu32 "ms): %zu active components", this->log_interval_, count); - if (count == 0) { - return; + // Sum component time so we can derive main-loop overhead + // (active loop time minus time attributable to component loop()s). + // Period sum iterates the active-in-period subset; total sum must iterate + // all components since total_active_time_us_ includes iterations where + // currently-idle components previously ran. + uint64_t period_component_sum_us = 0; + for (size_t i = 0; i < count; i++) { + period_component_sum_us += sorted[i]->runtime_stats_.period_time_us; + } + uint64_t total_component_sum_us = 0; + for (auto *component : components) { + total_component_sum_us += component->runtime_stats_.total_time_us; } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, compare_period_time); + if (count > 0) { + // Sort by period runtime (descending) + std::sort(sorted, sorted + count, compare_period_time); - // Log top components by period runtime - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, - stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, - stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + // Log top components by period runtime + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); + } + } + + // Main-loop overhead for the period: active wall time minus component time. + // active = sum of per-iteration loop time excluding yield/sleep. + if (this->period_active_count_ > 0) { + uint64_t active = this->period_active_time_us_; + uint64_t overhead = active > period_component_sum_us ? active - period_component_sum_us : 0; + // Use double for µs→ms conversion so multi-day uptimes (where total + // microsecond counters exceed float's ~7-digit mantissa) keep resolution. + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->period_active_count_, + static_cast(active) / static_cast(this->period_active_count_) / 1000.0, + static_cast(this->period_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->period_before_time_us_; + uint64_t tail = this->period_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); - // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, compare_total_time); + if (count > 0) { + // Re-sort by total runtime for all-time stats + std::sort(sorted, sorted + count, compare_total_time); - for (size_t i = 0; i < count; i++) { - const auto &stats = sorted[i]->runtime_stats_; - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, - stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + for (size_t i = 0; i < count; i++) { + const auto &stats = sorted[i]->runtime_stats_; + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + } + } + + if (this->total_active_count_ > 0) { + uint64_t active = this->total_active_time_us_; + uint64_t overhead = active > total_component_sum_us ? active - total_component_sum_us : 0; + ESP_LOGI(TAG, + " main_loop: iters=%" PRIu64 ", active_avg=%.3fms, active_max=%.2fms, active_total=%.1fms, " + "overhead_total=%.1fms", + this->total_active_count_, + static_cast(active) / static_cast(this->total_active_count_) / 1000.0, + static_cast(this->total_active_max_us_) / 1000.0, static_cast(active) / 1000.0, + static_cast(overhead) / 1000.0); + uint64_t before = this->total_before_time_us_; + uint64_t tail = this->total_tail_time_us_; + uint64_t accounted = before + tail; + uint64_t inter = overhead > accounted ? overhead - accounted : 0; + ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms", + static_cast(before) / 1000.0, static_cast(tail) / 1000.0, + static_cast(inter) / 1000.0); } // Reset period stats for (auto *component : components) { component->runtime_stats_.reset_period(); } + this->period_active_count_ = 0; + this->period_active_time_us_ = 0; + this->period_active_max_us_ = 0; + this->period_before_time_us_ = 0; + this->period_tail_time_us_ = 0; } bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { @@ -76,11 +137,12 @@ bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } -void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { - if ((int32_t) (current_time - this->next_log_time_) >= 0) { - this->log_stats_(); - this->next_log_time_ = current_time + this->log_interval_; - } +// Slow path for process_pending_stats — gate already checked by the inline +// wrapper in runtime_stats.h. Out-of-line keeps the log_stats_ machinery out +// of Application::loop(). +void RuntimeStatsCollector::process_pending_stats_slow_(uint32_t current_time) { + this->log_stats_(); + this->next_log_time_ = current_time + this->log_interval_; } } // namespace runtime_stats diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 3c2c9f78a..888d48e67 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -6,6 +6,7 @@ #include #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -26,10 +27,46 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - // Process any pending stats printing (should be called after component loop) - void process_pending_stats(uint32_t current_time); + // Process any pending stats printing. Called on every Application::loop() + // tick, so the common "not yet time to log" path must be cheap — inline + // the gate check and keep the actual logging work out-of-line. + void ESPHOME_ALWAYS_INLINE process_pending_stats(uint32_t current_time) { + if ((int32_t) (current_time - this->next_log_time_) >= 0) [[unlikely]] { + this->process_pending_stats_slow_(current_time); + } + } + + // Record the wall time of one main loop iteration excluding the yield/sleep. + // Called once per loop from Application::loop(). + // active_us = total time between loop start and just before yield. + // before_us = time spent in Phase A (scheduler tick) excluding time + // already attributed to per-component stats. + // tail_us = time spent in after_component_phase_ + the trailing record/stats + // prefix. Only meaningful on component-phase ticks; reported + // as 0 on Phase A-only ticks (no component phase ran, so any + // overhead between Phase A and stats belongs to "residual"). + // Residual overhead at log time = active − Σ(component) − before − tail, + // which captures per-iteration inter-component bookkeeping (set_current_component, + // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // the for-loop itself). + void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { + this->period_active_count_++; + this->period_active_time_us_ += active_us; + if (active_us > this->period_active_max_us_) + this->period_active_max_us_ = active_us; + this->total_active_count_++; + this->total_active_time_us_ += active_us; + if (active_us > this->total_active_max_us_) + this->total_active_max_us_ = active_us; + + this->period_before_time_us_ += before_us; + this->total_before_time_us_ += before_us; + this->period_tail_time_us_ += tail_us; + this->total_tail_time_us_ += tail_us; + } protected: + void process_pending_stats_slow_(uint32_t current_time); void log_stats_(); // Static comparators — member functions have friend access, lambdas do not static bool compare_period_time(Component *a, Component *b); @@ -37,6 +74,22 @@ class RuntimeStatsCollector { uint32_t log_interval_; uint32_t next_log_time_{0}; + + // Main loop active-time stats (wall time per iteration, excluding yield/sleep). + // Counters are uint64_t — at sub-millisecond loop times a uint32_t can wrap in + // a few weeks of uptime, which is well within ESPHome device lifetimes. + uint64_t period_active_count_{0}; + uint64_t period_active_time_us_{0}; + uint32_t period_active_max_us_{0}; + uint64_t total_active_count_{0}; + uint64_t total_active_time_us_{0}; + uint32_t total_active_max_us_{0}; + + // Split of overhead sections — accumulated per iteration. + uint64_t period_before_time_us_{0}; + uint64_t total_before_time_us_{0}; + uint64_t period_tail_time_us_{0}; + uint64_t total_tail_time_us_{0}; }; } // namespace runtime_stats diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 3b704d255..0aa6e86d3 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -81,7 +81,7 @@ void RX8130Component::read_time() { .year = static_cast(bcd2dec(date[6]) + 2000), }; rtc_time.recalc_timestamp_utc(false); - if (!rtc_time.is_valid()) { + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; } diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b..578376258 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 273305496..b458a9a30 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py new file mode 100644 index 000000000..58687ae83 --- /dev/null +++ b/esphome/components/sendspin/__init__.py @@ -0,0 +1,268 @@ +from dataclasses import dataclass + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, network, psram, socket, wifi +import esphome.config_validation as cv +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +# mdns for autodiscovery +AUTO_LOAD = ["mdns"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["network"] +DOMAIN = "sendspin" + +CONF_SENDSPIN_ID = "sendspin_id" + +CONF_INITIAL_STATIC_DELAY = "initial_static_delay" +CONF_FIXED_DELAY = "fixed_delay" + +# sendspin-cpp library lives in the global `sendspin` namespace. +sendspin_library_ns = cg.global_ns.namespace("sendspin") + +# Library Enums +SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True) +CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC") +CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") +CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") +CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") + +# Library Structs +AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") +PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") + +# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. +# Analysis tools strip the trailing underscore (same pattern as `template_`). +sendspin_ns = cg.esphome_ns.namespace("sendspin_") +SendspinHub = sendspin_ns.class_( + "SendspinHub", + cg.Component, +) + + +SendspinSwitchCommandAction = sendspin_ns.class_( + "SendspinSwitchCommandAction", + automation.Action, + cg.Parented.template(SendspinHub), +) + + +@dataclass +class SendspinConfiguration: + artwork_support: bool = False + controller_support: bool = False + metadata_support: bool = False + player_support: bool = False + visualizer_support: bool = False + + player_config: ConfigType | None = None + + +def _get_data() -> SendspinConfiguration: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = SendspinConfiguration() + return CORE.data[DOMAIN] + + +def request_artwork_support() -> None: + """Request artwork role support for Sendspin.""" + _get_data().artwork_support = True + + +def request_controller_support() -> None: + """Request controller role support for Sendspin.""" + _get_data().controller_support = True + + +def request_metadata_support() -> None: + """Request metadata role support for Sendspin.""" + _get_data().metadata_support = True + + +def request_player_support() -> None: + """Request player role support for Sendspin.""" + _get_data().player_support = True + + +def request_visualizer_support() -> None: + """Request visualizer role support for Sendspin.""" + _get_data().visualizer_support = True + + +def register_player_config(config: ConfigType) -> None: + """Register the player role config from the media source subcomponent.""" + data = _get_data() + request_player_support() + if data.player_config is not None: + raise cv.Invalid( + "Only one sendspin media_source player configuration is supported" + ) + data.player_config = config + + +def _validate_task_stack_in_psram(value): + value = cv.boolean(value) + if value: + return cv.requires_component(psram.DOMAIN)(value) + return value + + +def _request_high_performance_networking(config: ConfigType) -> ConfigType: + """Request high performance networking for Sendspin streaming. + + Also enables wake_loop_threadsafe support for fast defer() callbacks + from background threads (WebSocket handler, image decoder). + """ + network.require_high_performance_networking() + # Socket consumption varies by mode: + # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Client mode: 1 outbound connection + socket.consume_sockets( + 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN + )(config) + socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(1, "sendspin_websocket_client")(config) + + wifi.enable_runtime_power_save_control() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ), + cv.only_on_esp32, + _request_high_performance_networking, +) + + +def _request_controller_role(config: ConfigType) -> ConfigType: + """Request the controller role for the sendspin.switch action.""" + request_controller_support() + return config + + +SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) + ), + _request_controller_role, +) + + +@automation.register_action( + "sendspin.switch", + SendspinSwitchCommandAction, + SENDSPIN_SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_switch_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + # sendspin-cpp library + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") + + cg.add_define("USE_SENDSPIN", True) # for MDNS + + data = _get_data() + + # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) + # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). + if data.artwork_support: + cg.add_define("USE_SENDSPIN_ARTWORK", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) + + if data.controller_support: + cg.add_define("USE_SENDSPIN_CONTROLLER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False) + + if data.metadata_support: + cg.add_define("USE_SENDSPIN_METADATA", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False) + + if data.player_support: + cg.add_define("USE_SENDSPIN_PLAYER", True) + + # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate + # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + player_cfg = data.player_config + sample_rate = player_cfg[CONF_SAMPLE_RATE] + + # OPUS only supports 48 kHz audio + codecs = [CODEC_FORMAT_FLAC] + if sample_rate == 48000: + codecs.append(CODEC_FORMAT_OPUS) + codecs.append(CODEC_FORMAT_PCM) + + def _audio_format(codec, channels): + return cg.StructInitializer( + AudioSupportedFormatObject, + ("codec", codec), + ("channels", channels), + ("sample_rate", sample_rate), + ("bit_depth", 16), + ) + + audio_format_structs = [ + _audio_format(codec, channels) for codec in codecs for channels in (2, 1) + ] + + psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) + if psram_stack: + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + player_config_struct = cg.StructInitializer( + PlayerRoleConfig, + ("audio_formats", audio_format_structs), + ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), + ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), + ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + ("psram_stack", psram_stack), + ("priority", 2), + ) + cg.add(var.set_player_config(player_config_struct)) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + + if data.visualizer_support: + cg.add_define("USE_SENDSPIN_VISUALIZER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False) diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h new file mode 100644 index 000000000..be3b1eb39 --- /dev/null +++ b/esphome/components/sendspin/automation.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "sendspin_hub.h" + +namespace esphome::sendspin_ { + +#ifdef USE_SENDSPIN_CONTROLLER +template class SendspinSwitchCommandAction : public Action, public Parented { + public: + void play(const Ts &...x) override { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); + } +}; +#endif // USE_SENDSPIN_CONTROLLER + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/media_player/__init__.py b/esphome/components/sendspin/media_player/__init__.py new file mode 100644 index 000000000..4aaee8cd8 --- /dev/null +++ b/esphome/components/sendspin/media_player/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import media_player +from esphome.components.const import CONF_VOLUME_INCREMENT +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinMediaPlayer = sendspin_ns.class_( + "SendspinMediaPlayer", + media_player.MediaPlayer, + cg.Component, +) + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the media player.""" + request_controller_support() + + return config + + +CONFIG_SCHEMA = cv.All( + media_player.media_player_schema(SendspinMediaPlayer).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await media_player.register_media_player(var, config) + + cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp new file mode 100644 index 000000000..beb202868 --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -0,0 +1,165 @@ +#include "sendspin_media_player.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +#include +#include +#include +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_player"; + +// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop, +// since SendspinHub dispatches group updates and controller state from client_->loop(). +void SendspinMediaPlayer::setup() { + // Register for group updates to sync playback state + this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) { + if (group_obj.playback_state.has_value()) { + media_player::MediaPlayerState new_state; + switch (group_obj.playback_state.value()) { + case sendspin::SendspinPlaybackState::PLAYING: + new_state = media_player::MEDIA_PLAYER_STATE_PLAYING; + break; + case sendspin::SendspinPlaybackState::STOPPED: + default: + new_state = media_player::MEDIA_PLAYER_STATE_IDLE; + break; + } + if (this->state != new_state) { + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } + } + }); + + this->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) { + float new_volume = static_cast(state.volume) / 100.0f; + bool new_muted = state.muted; + if ((new_volume != this->volume) || (new_muted != this->muted_)) { + this->volume = new_volume; + this->muted_ = new_muted; + this->publish_state(); + } + }); + + // Publish an initial state + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + this->publish_state(); +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { + auto traits = media_player::MediaPlayerTraits(); + + // By default, the base media player always enables these traits, but they are not actually supported by this media + // player + traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA | + media_player::MediaPlayerEntityFeature::BROWSE_MEDIA | + media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE); + + traits.add_feature_flags( + media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE | + media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP | + media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE); + + // NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API + // does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if + // we expose the capability. They remain accessible via ESPHome YAML automations. + + return traits; +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { + if (!this->is_ready()) { + // Ignore any commands sent before the media player is setup + return; + } + + auto volume = call.get_volume(); + if (volume.has_value()) { + uint8_t new_volume = static_cast(std::roundf(volume.value() * 100.0f)); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt); + } + + auto command = call.get_command(); + if (!command.has_value()) { + return; + } + switch (command.value()) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: + if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + } else { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + } + break; + case media_player::MEDIA_PLAYER_COMMAND_PLAY: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + break; + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + break; + case media_player::MEDIA_PLAYER_COMMAND_STOP: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL); + break; + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT); + break; + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_MUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false); + break; + default: + break; + } +} + +void SendspinMediaPlayer::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_); +} + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h new file mode 100644 index 000000000..52786d6d7 --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/components/media_player/media_player.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +namespace esphome::sendspin_ { + +class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { + public: + void setup() override; + void dump_config() override; + + // MediaPlayer implementations + media_player::MediaPlayerTraits get_traits() override; + + void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; } + + bool is_muted() const override { return this->muted_; } + + protected: + // Receives commands from HA + void control(const media_player::MediaPlayerCall &call) override; + + float volume_increment_{0.05f}; + bool muted_{false}; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py new file mode 100644 index 000000000..6d61a8a63 --- /dev/null +++ b/esphome/components/sendspin/media_source/__init__.py @@ -0,0 +1,134 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import media_source +import esphome.config_validation as cv +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_FIXED_DELAY, + CONF_INITIAL_STATIC_DELAY, + CONF_SENDSPIN_ID, + SendspinHub, + _validate_task_stack_in_psram, + register_player_config, + request_controller_support, + sendspin_ns, +) + +AUTO_LOAD = ["audio"] +CODEOWNERS = ["@kahrendt"] + +CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable" + + +SendspinMediaSource = sendspin_ns.class_( + "SendspinMediaSource", + cg.Component, + media_source.MediaSource, +) + +EnableStaticDelayAdjustmentAction = sendspin_ns.class_( + "EnableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + +DisableStaticDelayAdjustmentAction = sendspin_ns.class_( + "DisableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + + +def _register(config: ConfigType) -> ConfigType: + request_controller_support() + register_player_config( + { + CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], + CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], + CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], + CONF_FIXED_DELAY: config[CONF_FIXED_DELAY], + CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False), + } + ) + return config + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + SendspinMediaSource, + ).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), + cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=5000)), + ), + cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean, + cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range(max=cv.TimePeriod(microseconds=10000)), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range( + min=16000, max=96000 + ), + } + ), + cv.only_on_esp32, + _register, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID]) + await cg.register_parented(var, sendspin_hub) + + cg.add(sendspin_hub.set_listener(var)) + + cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE])) + + +SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinMediaSource), + } + ) +) + + +@automation.register_action( + "sendspin.media_source.enable_static_delay_adjustment", + EnableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "sendspin.media_source.disable_static_delay_adjustment", + DisableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_static_delay_adjustment_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h new file mode 100644 index 000000000..08d2b2004 --- /dev/null +++ b/esphome/components/sendspin/media_source/automations.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/automation.h" +#include "sendspin_media_source.h" + +namespace esphome::sendspin_ { + +template +class EnableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } +}; + +template +class DisableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp new file mode 100644 index 000000000..0fdfb01c5 --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -0,0 +1,207 @@ +#include "sendspin_media_source.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/audio/audio.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_source"; + +static constexpr char URI_PREFIX[] = "sendspin://"; + +void SendspinMediaSource::setup() { + this->player_role_ = this->parent_->get_player_role(); + if (!this->player_role_) { + ESP_LOGE(TAG, "Failed to get player role from hub"); + this->mark_failed(); + return; + } + + // Push cached states to player role. They may have been set before setup() ran. + this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); + this->player_role_->update_muted(this->cached_muted_); + this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); +} + +void SendspinMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_)); +} + +// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config) +void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { + this->static_delay_adjustable_ = adjustable; + if (this->player_role_) { + this->player_role_->set_static_delay_adjustable(adjustable); + } +} + +// --- MediaSource interface --- + +bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); } + +// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) +bool SendspinMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || !this->has_listener()) { + return false; + } + + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + if (!uri.starts_with(URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1); + + if (sendspin_id.empty()) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str()); + + if (sendspin_id != "current") { + // Connect to a new server as a websocket client + this->parent_->connect_to_server("ws://" + sendspin_id); + } + + // Tell the orchestrator we're now playing so it routes audio output from us + this->pending_start_ = false; + this->set_state_(media_source::MediaSourceState::PLAYING); + + return true; +} + +// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) +void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: { + if (!this->pending_start_) { + // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before + // play_uri + ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); + this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + } + break; + } + case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone) + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt); + break; + default: + break; + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_volume_changed(float volume) { + this->cached_volume_ = volume; + if (this->player_role_) { + this->player_role_->update_volume(std::roundf(volume * 100.0f)); + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_mute_changed(bool is_muted) { + this->cached_muted_ = is_muted; + if (this->player_role_) { + this->player_role_->update_muted(is_muted); + } +} + +// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker). +// PlayerRole::notify_audio_played() is documented as thread-safe for this use. +void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) { + if (this->player_role_) { + this->player_role_->notify_audio_played(frames, timestamp); + } +} + +// --- Sendspin PlayerRoleListener overrides --- + +// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms. +size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + + // PlayerRole::get_current_stream_params() is safe to call from the sync task. + auto ¶ms = this->player_role_->get_current_stream_params(); + if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate); + + return this->write_output(data, length, timeout_ms, stream_info); +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_start() { + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + + if (!this->pending_start_) { + // Dedup rapid on_stream_start() calls + this->pending_start_ = true; + // Request the orchestrator to start this source + this->request_play_uri_("sendspin://current"); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_end() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_clear() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); } + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); } + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h new file mode 100644 index 000000000..3b3171612 --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/components/media_source/media_source.h" + +#include + +namespace esphome::sendspin_ { + +/// @brief Thin adapter media source for Sendspin. +/// +/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's +/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards +/// transport commands to the hub's controller role. +class SendspinMediaSource : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { + public: + void setup() override; + void dump_config() override; + + void set_static_delay_adjustable(bool adjustable); + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + bool has_internal_playlist() const override { return true; } + + void notify_volume_changed(float volume) override; + void notify_mute_changed(bool is_muted) override; + void notify_audio_played(uint32_t frames, int64_t timestamp) override; + + protected: + // --- Sendspin PlayerRoleListener overrides --- + + /// @brief Writes decoded PCM audio to ESPHome's media source output pipeline. + /// Called from the sync task's background thread. + size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override; + + /// @brief Called when a new audio stream starts (main loop thread). + void on_stream_start() override; + + /// @brief Called when the audio stream ends (main loop thread). + void on_stream_end() override; + + /// @brief Called when the audio stream is cleared (main loop thread). + void on_stream_clear() override; + + /// @brief Called when volume changes (main loop thread). + void on_volume_changed(uint8_t volume) override; + + /// @brief Called when mute state changes (main loop thread). + void on_mute_changed(bool muted) override; + + sendspin::PlayerRole *player_role_{nullptr}; + + float cached_volume_{0.0f}; + + bool cached_muted_{false}; + bool pending_start_{false}; + bool static_delay_adjustable_{false}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp new file mode 100644 index 000000000..d27c5672e --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -0,0 +1,225 @@ +#include "sendspin_hub.h" + +#ifdef USE_ESP32 + +#include "esphome/components/network/util.h" +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/version.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.hub"; + +void SendspinHub::setup() { + auto config = this->build_client_config_(); + this->client_ = std::make_unique(std::move(config)); + + // Set up persistence (preferences must be initialized before providers are added to the client) + this->last_played_server_pref_ = + global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); +#ifdef USE_SENDSPIN_PLAYER + this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); +#endif + + // Wire providers and client listener + this->client_->set_listener(this); + this->client_->set_network_provider(this); + this->client_->set_persistence_provider(this); + +#ifdef USE_SENDSPIN_CONTROLLER + this->controller_role_ = &this->client_->add_controller(); + this->controller_role_->set_listener(this); +#endif + +#ifdef USE_SENDSPIN_METADATA + this->metadata_role_ = &this->client_->add_metadata(); + this->metadata_role_->set_listener(this); +#endif + +#ifdef USE_SENDSPIN_PLAYER + this->client_->add_player(this->player_config_).set_listener(this->player_listener_); +#endif + + if (!this->client_->start_server()) { + ESP_LOGE(TAG, "Failed to start Sendspin server"); + this->mark_failed(); + return; + } +} + +void SendspinHub::loop() { this->client_->loop(); } + +void SendspinHub::dump_config() { + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Sendspin Hub:\n" + " Client ID: %s\n" + " Task stack in PSRAM: %s", + get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); +} + +// --- Delegating methods --- + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::connect_to_server(const std::string &url) { + if (this->is_ready()) { + this->client_->connect_to(url); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { + if (this->is_ready()) { + this->client_->disconnect(reason); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::update_state(sendspin::SendspinClientState state) { + if (this->is_ready()) { + this->client_->update_state(state); + } +} + +sendspin::SendspinClientConfig SendspinHub::build_client_config_() { + sendspin::SendspinClientConfig config; + + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + config.client_id = get_mac_address_pretty_into_buffer(mac_buf); + config.name = App.get_friendly_name(); + config.product_name = App.get_name(); + config.manufacturer = "ESPHome"; + config.software_version = ESPHOME_VERSION; + config.httpd_psram_stack = this->task_stack_in_psram_; + + return config; +} + +// --- SendspinClientListener overrides --- +// THREAD CONTEXT: Main loop (fired from client_->loop()) + +void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) { + this->group_update_callbacks_.call(group); +} + +void SendspinHub::on_request_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->request_high_performance(); + } +#endif +} + +void SendspinHub::on_release_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->release_high_performance(); + } +#endif +} + +// --- SendspinNetworkProvider override --- + +// THREAD CONTEXT: Main loop (polled by client_->loop()) +bool SendspinHub::is_network_ready() { return network::is_connected(); } + +// --- SendspinPersistenceProvider overrides --- + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +bool SendspinHub::save_last_server_hash(uint32_t hash) { + LastPlayedServerPref pref{.server_id_hash = hash}; + bool ok = this->last_played_server_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + } else { + ESP_LOGW(TAG, "Failed to persist last played server hash"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +std::optional SendspinHub::load_last_server_hash() { + LastPlayedServerPref pref{}; + if (this->last_played_server_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + return pref.server_id_hash; + } + return std::nullopt; +} + +// --- Sendspin role specific methods/overrides --- + +#ifdef USE_SENDSPIN_CONTROLLER +// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) +void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, + std::optional mute) { + if (this->is_ready()) { + this->controller_role_->send_command(command, volume, mute); + } +} + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { + this->controller_state_callbacks_.call(state); +} +#endif + +#ifdef USE_SENDSPIN_METADATA +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { + this->metadata_update_callbacks_.call(metadata); +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +uint32_t SendspinHub::get_track_progress_ms() const { + if (this->is_ready()) { + return this->metadata_role_->get_track_progress_ms(); + } + return 0; +} +#endif + +#ifdef USE_SENDSPIN_PLAYER +// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured +sendspin::PlayerRole *SendspinHub::get_player_role() { + if (this->is_ready()) { + return this->client_->player(); + } + return nullptr; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +bool SendspinHub::save_static_delay(uint16_t delay_ms) { + StaticDelayPref pref{.delay_ms = delay_ms}; + bool ok = this->static_delay_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); + } else { + ESP_LOGW(TAG, "Failed to persist static delay"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +std::optional SendspinHub::load_static_delay() { + StaticDelayPref pref{}; + if (this->static_delay_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); + return pref.delay_ms; + } + return std::nullopt; +} + +#endif + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h new file mode 100644 index 000000000..12fbf156e --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.h @@ -0,0 +1,231 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include +#include +#include + +#ifdef USE_SENDSPIN_CONTROLLER +#include +#endif +#ifdef USE_SENDSPIN_METADATA +#include +#endif +#ifdef USE_SENDSPIN_PLAYER +#include +#endif + +#include +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Setup priorities for the sendspin hub and its child components. +/// +/// Centralized here so every sendspin component orders itself relative to the hub +/// without each subcomponent having to pick a priority independently. Children run +/// one step later than hub so they can assume hub's setup() has already completed. +namespace sendspin_priority { +inline constexpr float HUB = esphome::setup_priority::PROCESSOR; +inline constexpr float CHILD = HUB - 1.0f; +} // namespace sendspin_priority + +/// @brief Persistent storage structure for last played server hash. +struct LastPlayedServerPref { + uint32_t server_id_hash; +}; + +#ifdef USE_SENDSPIN_PLAYER +/// @brief Persistent storage structure for player static delay. +struct StaticDelayPref { + uint16_t delay_ms; +}; +#endif + +/// @brief Thin adapter over sendspin::SendspinClient. +/// +/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for +/// fan-out to child components. +/// - Provides persistence via ESPPreferenceObject and WiFi power management integration. +/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events +/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the +/// child thereafter, since no fan-out is needed. +/// +/// The sendspin-cpp library follows this design: +/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`. +/// - Library -> user code communication happens via two interface types the user implements and registers in our +/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces +/// (for services the library pulls; e.g., persistence, network readiness). +/// - User -> library communication uses exposed functions on the client and role objects that the user calls. +class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_CONTROLLER + public sendspin::ControllerRoleListener, +#endif +#ifdef USE_SENDSPIN_METADATA + public sendspin::MetadataRoleListener, +#endif + public sendspin::SendspinClientListener, + public sendspin::SendspinNetworkProvider, + public sendspin::SendspinPersistenceProvider { + public: + float get_setup_priority() const override { return sendspin_priority::HUB; } + void setup() override; + void loop() override; + void dump_config() override; + + /// @brief Connects the underlying client to the given Sendspin server. + /// + /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// Must be called from the main loop thread. + /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). + void connect_to_server(const std::string &url); + + /// @brief Disconnects the underlying client from the current server. + /// + /// Sends a `client/goodbye` message with the given reason before closing the connection. + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param reason Reason reported to the server: + /// - `ANOTHER_SERVER`: client is switching to another server. + /// - `SHUTDOWN`: client is shutting down. + /// - `RESTART`: client is restarting. + /// - `USER_REQUEST`: user explicitly requested disconnect. + void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); + + /// @brief Updates the client's reported playback state on the server. + /// + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param state New client state: + /// - `SYNCHRONIZED`: client is synchronized and playing from the server. + /// - `ERROR`: client encountered a playback error. + /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. + void update_state(sendspin::SendspinClientState state); + + // --- Configuration setters (called from codegen) --- + + template void add_group_update_callback(F &&callback) { + this->group_update_callbacks_.add(std::forward(callback)); + } + + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + + // --- Sendspin role specific methods --- + +#ifdef USE_SENDSPIN_CONTROLLER + void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, + std::optional mute = std::nullopt); + + template void add_controller_state_callback(F &&callback) { + this->controller_state_callbacks_.add(std::forward(callback)); + } +#endif + +#ifdef USE_SENDSPIN_METADATA + template void add_metadata_update_callback(F &&callback) { + this->metadata_update_callbacks_.add(std::forward(callback)); + } + + /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. + uint32_t get_track_progress_ms() const; +#endif + +#ifdef USE_SENDSPIN_PLAYER + void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } + void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } + + /// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it. + sendspin::PlayerRole *get_player_role(); +#endif + + protected: + /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. + sendspin::SendspinClientConfig build_client_config_(); + + // --- SendspinClientListener overrides --- + void on_group_update(const sendspin::GroupUpdateObject &group) override; + + void on_request_high_performance() override; + + void on_release_high_performance() override; + + // --- SendspinNetworkProvider override --- + bool is_network_ready() override; + + // --- SendspinPersistenceProvider overrides --- + bool save_last_server_hash(uint32_t hash) override; + std::optional load_last_server_hash() override; + + // --- Sendspin role specific methods/overrides/member variables --- + +#ifdef USE_SENDSPIN_CONTROLLER + sendspin::ControllerRole *controller_role_{nullptr}; + + void on_controller_state(const sendspin::ServerStateControllerObject &state) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager controller_state_callbacks_{}; +#endif + +#ifdef USE_SENDSPIN_METADATA + sendspin::MetadataRole *metadata_role_{nullptr}; + + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager metadata_update_callbacks_{}; +#endif + +#ifdef USE_SENDSPIN_PLAYER + sendspin::PlayerRoleListener *player_listener_{nullptr}; + sendspin::PlayerRoleConfig player_config_{}; + + // Part of SendspinPersistenceProvider overrides + ESPPreferenceObject static_delay_pref_; + std::optional load_static_delay() override; + bool save_static_delay(uint16_t delay_ms) override; +#endif + + // --- Core member variables --- + + ESPPreferenceObject last_played_server_pref_; + + std::unique_ptr client_; + + // Callback fan-out to child components + CallbackManager group_update_callbacks_{}; + + bool task_stack_in_psram_{false}; +}; + +/// @brief Base class for all sendspin subcomponents. +/// +/// Consolidates the Component + Parented inheritance and pins the setup +/// priority so the hub's setup() always runs before any child. Subcomponents should +/// inherit from this instead of listing Component/Parented individually and must not +/// override get_setup_priority(). +class SendspinChild : public Component, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + +/// @brief Base class for sendspin subcomponents that need polling behavior. +/// +/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents +/// that poll on a fixed interval. Subcomponents should inherit from this instead of +/// listing PollingComponent/Parented individually and must not override get_setup_priority(). +class SendspinPollingChild : public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py new file mode 100644 index 000000000..dc9b86c2a --- /dev/null +++ b/esphome/components/sendspin/sensor/__init__.py @@ -0,0 +1,98 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_YEAR, + STATE_CLASS_MEASUREMENT, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +CONF_TRACK = "track" +CONF_TRACK_PROGRESS = "track_progress" +CONF_TRACK_DURATION = "track_duration" + +SendspinTrackProgressSensor = sendspin_ns.class_( + "SendspinTrackProgressSensor", + sensor.Sensor, + cg.PollingComponent, +) +SendspinMetadataSensor = sendspin_ns.class_( + "SendspinMetadataSensor", + sensor.Sensor, + cg.Component, +) + +SendspinNumericMetadataTypes = sendspin_ns.enum( + "SendspinNumericMetadataTypes", is_class=True +) +_METADATA_TYPE_ENUM = { + CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION, + CONF_YEAR: SendspinNumericMetadataTypes.YEAR, + CONF_TRACK: SendspinNumericMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the sensor.""" + request_metadata_support() + + return config + + +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + + +def _metadata_schema(**sensor_kwargs): + """Schema for event-driven numeric metadata sensors (duration/year/track).""" + return ( + sensor.sensor_schema( + SendspinMetadataSensor, + accuracy_decimals=0, + **sensor_kwargs, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + CONF_TRACK_PROGRESS: sensor.sensor_schema( + SendspinTrackProgressSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.polling_component_schema("1s")), + CONF_TRACK_DURATION: _metadata_schema( + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ), + CONF_YEAR: _metadata_schema(), + CONF_TRACK: _metadata_schema(), + }, + key=CONF_TYPE, + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await sensor.register_sensor(var, config) + + if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp new file mode 100644 index 000000000..68848a6f3 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -0,0 +1,98 @@ +#include "sendspin_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.sensor"; + +// --- SendspinTrackProgressSensor --- + +void SendspinTrackProgressSensor::dump_config() { + LOG_SENSOR("", "Track Progress", this); + LOG_UPDATE_INTERVAL(this); +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackProgressSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (!metadata.progress.has_value()) { + return; + } + const auto &progress = metadata.progress.value(); + if (progress.playback_speed == 0) { + // Paused: freeze progress at the reported position and stop polling to save cycles. + this->stop_poller(); + this->publish_state(progress.track_progress); + } else { + // Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale + // paused value until the next poll tick. + this->publish_state(this->parent_->get_track_progress_ms()); + this->start_poller(); + } + }); +} + +// THREAD CONTEXT: Main loop. +// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during +// playback. The hub helper interpolates the current position from the last server update and the playback +// speed, giving us a fresh value on every poll. +void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } + +// --- SendspinMetadataSensor --- + +void SendspinMetadataSensor::dump_config() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + LOG_SENSOR("", "Track Duration", this); + break; + case SendspinNumericMetadataTypes::YEAR: + LOG_SENSOR("", "Year", this); + break; + case SendspinNumericMetadataTypes::TRACK: + LOG_SENSOR("", "Track", this); + break; + } +} + +std::optional SendspinMetadataSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + if (metadata.progress.has_value()) + return metadata.progress.value().track_duration; + return std::nullopt; + case SendspinNumericMetadataTypes::YEAR: + if (metadata.year.has_value()) + return metadata.year.value(); + return std::nullopt; + case SendspinNumericMetadataTypes::TRACK: + if (metadata.track.has_value()) + return metadata.track.value(); + return std::nullopt; + } + return std::nullopt; +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinMetadataSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (auto value = this->extract_value_(metadata)) { + this->publish_if_changed_(*value); + } + }); +} + +// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. +void SendspinMetadataSensor::publish_if_changed_(float value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h new file mode 100644 index 000000000..cbfe1742c --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::sendspin_ { + +class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { + public: + void dump_config() override; + void setup() override; + void update() override; +}; + +enum class SendspinNumericMetadataTypes { + TRACK_DURATION, + YEAR, + TRACK, +}; + +class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + std::optional extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; + void publish_if_changed_(float value); + + SendspinNumericMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py new file mode 100644 index 000000000..87f6c9b93 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTextSensor = sendspin_ns.class_( + "SendspinTextSensor", + text_sensor.TextSensor, + cg.Component, +) + +SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) +SENDSPIN_TEXT_METADATA_TYPES = { + "title": SendspinTextMetadataTypes.TITLE, + "artist": SendspinTextMetadataTypes.ARTIST, + "album": SendspinTextMetadataTypes.ALBUM, + "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the text sensor.""" + request_metadata_support() + + return config + + +CONFIG_SCHEMA = cv.All( + text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(SendspinTextSensor), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await text_sensor.register_text_sensor(var, config) + + cg.add(var.set_metadata_type(config[CONF_TYPE])) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp new file mode 100644 index 000000000..9843fb966 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -0,0 +1,56 @@ +#include "sendspin_text_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.text_sensor"; + +void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } + +const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { + switch (this->metadata_type_) { + case SendspinTextMetadataTypes::TITLE: + if (metadata.title.has_value()) + return metadata.title.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ARTIST: + if (metadata.artist.has_value()) + return metadata.artist.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM: + if (metadata.album.has_value()) + return metadata.album.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM_ARTIST: + if (metadata.album_artist.has_value()) + return metadata.album_artist.value().c_str(); + return nullptr; + } + return nullptr; +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTextSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (const char *value = this->extract_value_(metadata)) { + this->publish_if_changed_(value); + } + }); +} + +// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. +void SendspinTextSensor::publish_if_changed_(const char *value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h new file mode 100644 index 000000000..203b01d02 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::sendspin_ { + +enum class SendspinTextMetadataTypes { + TITLE, + ARTIST, + ALBUM, + ALBUM_ARTIST, +}; + +class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + const char *extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; + void publish_if_changed_(const char *value); + + SendspinTextMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b658ff705..43fbc9895 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -118,6 +118,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + DEVICE_CLASSES = [ DEVICE_CLASS_ABSOLUTE_HUMIDITY, DEVICE_CLASS_APPARENT_POWER, @@ -275,6 +276,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) +ThrottleWithPriorityNanFilter = sensor_ns.class_( + "ThrottleWithPriorityNanFilter", Filter +) TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) @@ -290,6 +294,7 @@ SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter) ClampFilter = sensor_ns.class_("ClampFilter", Filter) RoundFilter = sensor_ns.class_("RoundFilter", Filter) RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter) +RoundSignificantDigitsFilter = sensor_ns.class_("RoundSignificantDigitsFilter", Filter) validate_unit_of_measurement = cv.All( cv.string_strict, @@ -656,9 +661,18 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): - if not isinstance(config[CONF_VALUE], list): - config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] + values = config[CONF_VALUE] + if not isinstance(values, list): + values = [values] + # Specialize the common "NaN-only" case (the schema default when the user + # omits `value:`) to avoid the TemplatableFn array + NaN lambda the + # generic ValueListFilter path requires. Behavior is identical: NaN sensor + # readings always bypass the throttle. + if values and all(isinstance(v, float) and math.isnan(v) for v in values): + filter_id = filter_id.copy() + filter_id.type = ThrottleWithPriorityNanFilter + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + template_ = [await cg.templatable(x, [], cg.float_) for x in values] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) @@ -888,6 +902,18 @@ async def round_multiple_filter_to_code(config, filter_id): ) +@FILTER_REGISTRY.register( + "round_to_significant_digits", + RoundSignificantDigitsFilter, + cv.int_range(min=1, max=6), +) +async def round_significant_digits_filter_to_code(config, filter_id): + return cg.new_Pvariable( + filter_id, + cg.TemplateArguments(config), + ) + + async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index fbac7d353..4896757d3 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -269,6 +269,18 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co return {}; } +// ThrottleWithPriorityNanFilter +ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) + : min_time_between_inputs_(min_time_between_inputs) {} +optional ThrottleWithPriorityNanFilter::new_value(float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 0dbbc33ab..917a1ce7d 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -399,6 +399,19 @@ template class ThrottleWithPriorityFilter : public ValueListFilter uint32_t min_time_between_inputs_; }; +/// Specialization of ThrottleWithPriorityFilter for the common "prioritize NaN" +/// case: skips the TemplatableFn array + lambda and inlines the check. +class ThrottleWithPriorityNanFilter : public Filter { + public: + explicit ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; +}; + // Base class for timeout filters - contains common loop logic class TimeoutFilterBase : public Filter, public Component { public: @@ -591,6 +604,19 @@ class RoundMultipleFilter : public Filter { float multiple_; }; +template class RoundSignificantDigitsFilter : public Filter { + public: + optional new_value(float value) override { + if (std::isfinite(value)) { + if (value == 0.0f) + return 0.0f; + float factor = pow10_int(Digits - 1 - ilog10(value)); + return roundf(value * factor) / factor; + } + return value; + } +}; + class ToNTCResistanceFilter : public Filter { public: ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {} diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index 1688f9d6a..97538e13c 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -84,7 +84,7 @@ def get_firmware(value): req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") + raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e h = hashlib.new("sha256") h.update(req.content) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index aea7c776c..8e9968e05 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -14,38 +17,34 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } -BSDSocketImpl::~BSDSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +BSDSocketImpl::~BSDSocketImpl() { this->close(); } int BSDSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = ::close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as ::close() returns, so anything that might + // dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would otherwise + // touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + wake_unregister_fd(this->fd_); + } +#endif + int ret = ::close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int BSDSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e52078470..57c1a430a 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -119,12 +119,21 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 86131d3dd..c6692b016 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,6 +11,10 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" +#ifdef USE_OTA_PLATFORM_ESPHOME +extern "C" void esphome_wake_ota_component_any_context(); +#endif + #ifdef USE_ESP8266 #include // For esp_schedule() #elif defined(USE_RP2040) @@ -854,6 +858,10 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); +#ifdef USE_OTA_PLATFORM_ESPHOME + // Must run before wake_loop_any_context() so flags are visible when the main task wakes. + esphome_wake_ota_component_any_context(); +#endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); return ERR_OK; diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 2fad429e0..a6bd639c1 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -6,6 +6,9 @@ #include #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { @@ -14,38 +17,34 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { if (!monitor_loop || this->fd_ < 0) return; #ifdef USE_LWIP_FAST_SELECT - // Cache lwip_sock pointer and register for monitoring (hooks callback internally) - this->cached_sock_ = esphome_lwip_get_sock(this->fd_); - this->loop_monitored_ = App.register_socket(this->cached_sock_); + this->cached_sock_ = hook_fd_for_fast_select(this->fd_); #else - this->loop_monitored_ = App.register_socket_fd(this->fd_); + this->loop_monitored_ = wake_register_fd(this->fd_); #endif } -LwIPSocketImpl::~LwIPSocketImpl() { - if (!this->closed_) { - this->close(); - } -} +LwIPSocketImpl::~LwIPSocketImpl() { this->close(); } int LwIPSocketImpl::close() { - if (!this->closed_) { - // Unregister before closing to avoid dangling pointer in monitored set -#ifdef USE_LWIP_FAST_SELECT - if (this->loop_monitored_) { - App.unregister_socket(this->cached_sock_); - this->cached_sock_ = nullptr; - } -#else - if (this->loop_monitored_) { - App.unregister_socket_fd(this->fd_); - } -#endif - int ret = lwip_close(this->fd_); - this->closed_ = true; - return ret; + if (this->fd_ < 0) { + // Already closed, or never opened. + return 0; } - return 0; +#ifdef USE_LWIP_FAST_SELECT + // Null the cached lwip_sock pointer before closing. The underlying lwip slot can be + // recycled for a new connection as soon as lwip_close() returns, so anything that + // might dereference cached_sock_ post-close (e.g. setsockopt(TCP_NODELAY)) would + // otherwise touch an unrelated socket's pcb. No per-socket callback unhook is needed — + // all LwIP sockets share the same static event_callback. + this->cached_sock_ = nullptr; +#else + if (this->loop_monitored_) { + wake_unregister_fd(this->fd_); + } +#endif + int ret = lwip_close(this->fd_); + this->fd_ = -1; // Sentinel for "closed" — prevents double-close and makes use-after-close visible. + return ret; } int LwIPSocketImpl::setblocking(bool blocking) { diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf8..7f3b706cd 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -85,12 +85,21 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } protected: + // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This + // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the + // destructor / double-close path just check fd_ < 0. int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT - struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() -#endif - bool closed_{false}; + // Cached lwip_sock pointer used for direct rcvevent reads in ready() on the + // fast-select path. Replaces loop_monitored_: null means this socket is not being + // monitored for read events — either monitoring was not requested, the fd was + // invalid, or esphome_lwip_get_sock() failed. Non-null means the netconn event + // callback was hooked and notifications are flowing. close() nulls this to prevent + // use-after-free via a recycled lwip slot. + struct lwip_sock *cached_sock_{nullptr}; +#else bool loop_monitored_{false}; +#endif }; } // namespace esphome::socket diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bc43b2746..f14ac1e2d 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -5,13 +5,16 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#ifdef USE_HOST +#include "esphome/core/wake.h" +#endif namespace esphome::socket { #ifdef USE_HOST // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the Application's select() loop has marked this fd as ready. -bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } +// Checks if the host wake select() loop has marked this fd as ready. +bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } #endif // Platform-specific inet_ntop wrappers diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index ad55e889e..204113e4b 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -42,8 +42,23 @@ using ListenSocket = LWIPRawListenImpl; #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. -inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { - return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +/// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd +/// was invalid, or esphome_lwip_get_sock() failed) — in that case return true so the +/// caller attempts the read and handles blocking itself. +inline bool socket_ready(struct lwip_sock *cached_sock) { + return cached_sock == nullptr || esphome_lwip_socket_has_data(cached_sock); +} + +/// Resolve an fd to its lwip_sock and install the netconn event-callback hook so the +/// main loop is woken by FreeRTOS task notifications when data arrives. Shared between +/// BSD and LwIP socket impls on the fast-select path. Returns the cached lwip_sock +/// pointer (or nullptr if the fd does not map to a valid lwip_sock). +inline struct lwip_sock *hook_fd_for_fast_select(int fd) { + struct lwip_sock *sock = esphome_lwip_get_sock(fd); + if (sock != nullptr) { + esphome_lwip_hook_socket(sock); + } + return sock; } #elif defined(USE_HOST) /// Shared ready() helper for fd-based socket implementations. @@ -69,7 +84,7 @@ bool socket_ready_fd(int fd, bool loop_monitored); #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT - return socket_ready(this->cached_sock_, this->loop_monitored_); + return socket_ready(this->cached_sock_); #else return socket_ready_fd(this->fd_, this->loop_monitored_); #endif diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 320e96c89..abfd59980 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.core.entity_helpers import inherit_property_from from esphome.external_files import download_content _LOGGER = logging.getLogger(__name__) @@ -44,16 +43,12 @@ DEPENDENCIES = ["network"] CODEOWNERS = ["@kahrendt", "@synesthesiam"] DOMAIN = "media_player" -CODEC_SUPPORT_ALL = "all" -CODEC_SUPPORT_NEEDED = "needed" -CODEC_SUPPORT_NONE = "none" - TYPE_LOCAL = "local" TYPE_WEB = "web" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -106,43 +101,10 @@ def _download_web_file(value): return value -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline, type): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - if pipeline[CONF_FORMAT] == "FLAC": - args.append(("format", "flac")) - elif pipeline[CONF_FORMAT] == "MP3": - args.append(("format", "mp3")) - elif pipeline[CONF_FORMAT] == "OPUS": - args.append(("format", "opus")) - elif pipeline[CONF_FORMAT] == "WAV": - args.append(("format", "wav")) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - - if type == "MEDIA": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], - ) - ) - elif type == "ANNOUNCEMENT": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], - ) - ) - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) +_PURPOSE_MAP = { + "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], +} def _file_schema(value): @@ -173,7 +135,7 @@ def _read_audio_file_and_type(file_config): raise cv.Invalid( f"Unable to determine audio file type of '{path}'. " f"Try re-encoding the file into a supported format. Details: {e}" - ) + ) from e media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type in ("wav"): @@ -210,25 +172,9 @@ def _validate_file_shorthand(value): ) -def _validate_pipeline(config): - # Inherit transcoder settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - # Validate the transcoder settings is compatible with the speaker - audio.final_validate_audio_schema( - "speaker media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker media_player", CONF_SPEAKER +) def _validate_repeated_speaker(config): @@ -245,59 +191,34 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Normalize boolean values to string equivalents - codec_mode = config[CONF_CODEC_SUPPORT_ENABLED] - if codec_mode is True: - codec_mode = CODEC_SUPPORT_ALL - elif codec_mode is False: - codec_mode = CODEC_SUPPORT_NONE + # Remove before 2026.10.0 + if CONF_CODEC_SUPPORT_ENABLED in config: + _LOGGER.warning( + "'%s' is deprecated and will be removed in 2026.10.0. " + "Codec support is now automatically determined from the pipeline " + "'format' setting. Set format to 'NONE' to enable all codecs.", + CONF_CODEC_SUPPORT_ENABLED, + ) - use_codec = codec_mode != CODEC_SUPPORT_NONE - - # In "needed" mode, collect formats from pipelines and files - needed_formats = set() - need_all = False - if codec_mode == CODEC_SUPPORT_NEEDED: - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - # No preferred format means any format could arrive - need_all = True - else: - needed_formats.add(fmt) + # Request codecs based on pipeline formats + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) + # Validate local files and request any additional codecs they need for file_config in config.get(CONF_FILES, []): _, media_file_type = _read_audio_file_and_type(file_config) if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): raise cv.Invalid("Unsupported local media file") - if not use_codec and str(media_file_type) != str( - audio.AUDIO_FILE_TYPE_ENUM["WAV"] - ): - # Only wav files are supported - raise cv.Invalid( - f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav" - ) - # In "needed" mode, add file format to needed codecs - if codec_mode == CODEC_SUPPORT_NEEDED: - for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): - if str(media_file_type) == str(fmt_enum): - if fmt_name not in ("WAV", "NONE"): - needed_formats.add(fmt_name) - break - - # Request codec support - if codec_mode == CODEC_SUPPORT_ALL or need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - elif codec_mode == CODEC_SUPPORT_NEEDED: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() + for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): + if str(media_file_type) == str(fmt_enum): + if fmt_name == "FLAC": + audio.request_flac_support() + elif fmt_name == "MP3": + audio.request_mp3_support() + elif fmt_name == "OPUS": + audio.request_opus_support() + break return config @@ -362,17 +283,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - cv.Optional( - CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED - ): cv.Any( - cv.boolean, - cv.one_of( - CODEC_SUPPORT_ALL, - CODEC_SUPPORT_NEEDED, - CODEC_SUPPORT_NONE, - lower=True, - ), - ), + # Remove before 2026.10.0 + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) @@ -432,8 +344,8 @@ async def to_code(config): if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( - _get_supported_format_struct( - announcement_pipeline_config, "ANNOUNCEMENT" + media_player.build_supported_format_struct( + announcement_pipeline_config, _PURPOSE_MAP["ANNOUNCEMENT"] ) ) ) @@ -444,7 +356,9 @@ async def to_code(config): if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( - _get_supported_format_struct(media_pipeline_config, "MEDIA") + media_player.build_supported_format_struct( + media_pipeline_config, _PURPOSE_MAP["MEDIA"] + ) ) ) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 70feeac31..b6653fe54 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -17,7 +17,6 @@ from esphome.const import ( CONF_SPEAKER, ) from esphome.core import ID -from esphome.core.entity_helpers import inherit_property_from from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType @@ -65,53 +64,9 @@ SetPlaylistDelayAction = speaker_source_ns.class_( ) -FORMAT_MAPPING = { - "FLAC": "flac", - "MP3": "mp3", - "OPUS": "opus", - "WAV": "wav", -} - - -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]])) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - args.append(("purpose", purpose)) - - # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails - # if the number of bytes per sample is specified for MP3. - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) - - -def _validate_pipeline(config: ConfigType) -> ConfigType: - # Inherit settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - audio.final_validate_audio_schema( - "speaker_source media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker_source media_player", CONF_SPEAKER +) PIPELINE_SCHEMA = cv.Schema( @@ -198,31 +153,9 @@ CONFIG_SCHEMA = cv.All( def _final_validate_codecs(config: ConfigType) -> ConfigType: - # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. - # When a specific format is set, only that codec is requested. - needed_formats: set[str] = set() - need_all = False - - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - need_all = True - else: - needed_formats.add(fmt) - - if need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - else: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() - + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) return config @@ -264,7 +197,9 @@ async def to_code(config: ConfigType) -> None: cg.add( var.set_format( pipeline_enum, - _get_supported_format_struct(pipeline_config, purpose), + media_player.build_supported_format_struct( + pipeline_config, purpose + ), ) ) diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 85414237c..745c37f47 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -45,8 +45,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO5", CONF_DC_PIN: "GPIO16", CONF_RESET_PIN: "GPIO23", @@ -68,8 +68,8 @@ MODELS = { presets={ CONF_HEIGHT: 280, CONF_WIDTH: 240, - CONF_OFFSET_HEIGHT: 0, - CONF_OFFSET_WIDTH: 20, + CONF_OFFSET_HEIGHT: 20, + CONF_OFFSET_WIDTH: 0, } ), "ADAFRUIT_S2_TFT_FEATHER_240X135": model_spec( @@ -77,8 +77,8 @@ MODELS = { presets={ CONF_HEIGHT: 240, CONF_WIDTH: 135, - CONF_OFFSET_HEIGHT: 52, - CONF_OFFSET_WIDTH: 40, + CONF_OFFSET_HEIGHT: 40, + CONF_OFFSET_WIDTH: 52, CONF_CS_PIN: "GPIO7", CONF_DC_PIN: "GPIO39", CONF_RESET_PIN: "GPIO40", @@ -89,8 +89,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 170, - CONF_OFFSET_HEIGHT: 35, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 35, CONF_ROTATION: 270, CONF_CS_PIN: "GPIO10", CONF_DC_PIN: "GPIO13", @@ -102,8 +102,8 @@ MODELS = { presets={ CONF_HEIGHT: 320, CONF_WIDTH: 172, - CONF_OFFSET_HEIGHT: 34, - CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + CONF_OFFSET_WIDTH: 34, CONF_ROTATION: 90, CONF_CS_PIN: "GPIO21", CONF_DC_PIN: "GPIO22", diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index a792110ee..48762a733 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,6 +7,11 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_PERIOD_MS = 250; +static constexpr uint32_t ERROR_ON_MS = 150; +static constexpr uint32_t WARNING_PERIOD_MS = 1500; +static constexpr uint32_t WARNING_ON_MS = 250; + StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) StatusLED::StatusLED(GPIOPin *pin) : pin_(pin) { global_status_led = this; } @@ -19,12 +24,18 @@ void StatusLED::dump_config() { LOG_PIN(" Pin: ", this->pin_); } void StatusLED::loop() { - if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(millis() % 250u < 150u); - } else if ((App.get_app_state() & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(millis() % 1500u < 250u); + const uint32_t app_state = App.get_app_state(); + // Use millis() rather than App.get_loop_component_start_time() because this loop is also + // dispatched from Application::feed_wdt() during long blocking operations, where the cached + // per-component timestamp doesn't advance and would freeze the blink pattern. + const uint32_t now = millis(); + if ((app_state & STATUS_LED_ERROR) != 0u) { + this->pin_->digital_write(now % ERROR_PERIOD_MS < ERROR_ON_MS); + } else if ((app_state & STATUS_LED_WARNING) != 0u) { + this->pin_->digital_write(now % WARNING_PERIOD_MS < WARNING_ON_MS); } else { this->pin_->digital_write(false); + this->disable_loop(); } } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8acacc3b4..8e8018766 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -35,8 +35,9 @@ def validate_acceleration(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected acceleration as floating point number, got {value}") + raise cv.Invalid( + f"Expected acceleration as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Acceleration must be larger than 0 steps/s^2!") @@ -55,8 +56,9 @@ def validate_speed(value): try: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(f"Expected speed as floating point number, got {value}") + raise cv.Invalid( + f"Expected speed as floating point number, got {value}" + ) from None if value <= 0: raise cv.Invalid("Speed must be larger than 0 steps/s!") diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index c0bd9d7be..fb7cd7c51 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -11,9 +11,11 @@ from esphome.types import ConfigType from esphome.util import OrderedDict from esphome.yaml_util import ( ConfigContext, + DocumentPath, ESPHomeDataBase, ESPLiteralValue, IncludeFile, + format_path, make_data_base, ) @@ -23,13 +25,42 @@ CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] -SubstitutionPath = list[int | str] -ErrList = list[tuple[UndefinedError, SubstitutionPath, Any]] +ErrList = list[tuple[UndefinedError, DocumentPath, Any]] + # Module-level instance is safe: context_vars is passed per-call, and context_trace # is stack-saved/restored within expand(). Not thread-safe — only use from one thread. jinja = Jinja() +def raise_first_undefined( + errors: ErrList, + context_label: str, +) -> None: + """If *errors* is non-empty, raise ``cv.Invalid`` for the first undefined variable. + + The raised error names the missing variable and its location in the include + stack. Only the first error is surfaced; the user will re-run after fixing it + and any remaining undefined variables will be reported then. + + ``context_label`` is the noun describing where the undefined variable + appeared (e.g. ``"package definition"``). + """ + if not errors: + return + err, err_path, err_value = errors[0] + if len(errors) > 1: + # Log any further undefined variables so debug-level output covers + # the full set, even though only the first is surfaced to the user. + extras = ", ".join( + f"{e.message} at '{'->'.join(str(p) for p in p_path)}'" + for e, p_path, _ in errors[1:] + ) + _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) + raise cv.Invalid( + f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" + ) + + def validate_substitution_key(value: Any) -> str: """Validate and normalize a substitution key, stripping a leading ``$`` if present.""" value = cv.string(value) @@ -95,7 +126,7 @@ def _resolve_var(name: str, context_vars: ContextVars) -> Any: def _handle_undefined( err: UndefinedError, - path: SubstitutionPath, + path: DocumentPath, value: Any, strict_undefined: bool, errors: ErrList | None, @@ -113,7 +144,7 @@ def _handle_undefined( def _expand_substitutions( value: str, - path: SubstitutionPath, + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool, errors: ErrList | None, @@ -186,9 +217,9 @@ def _expand_substitutions( f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" f"\nRelevant context:\n{err.context_trace_str()}" - f"\nSee {'->'.join(str(x) for x in path)}", + f"\n{format_path(path, orig_value)}", path, - ) + ) from err else: if isinstance(orig_value, ESPHomeDataBase): value = _restore_data_base(value, orig_value) @@ -295,15 +326,13 @@ def push_context( def resolve_include( include: IncludeFile, - path: list[int | str], + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool = True, errors: ErrList | None = None, -) -> tuple[Any, str]: +) -> Any: """Resolve an include, substituting the filename if needed. - Returns the loaded content and the resolved filename. - Note: no path-traversal validation is performed on the resolved filename. A substitution that resolves to an absolute path will bypass the parent directory (Path.__truediv__ ignores the left operand for absolute paths). @@ -311,44 +340,44 @@ def resolve_include( values (including command-line substitutions), so path restrictions are an explicit non-goal here. """ - original = str(include.file) + original = include.file + original_str = str(original) filename = str( _expand_substitutions( - original, path + ["file"], context_vars, strict_undefined, errors + original_str, path + ["file"], context_vars, strict_undefined, errors ) ) - if filename != original: + substituted = filename != original_str + if substituted: include = IncludeFile( include.parent_file, filename, include.vars, include.yaml_loader ) try: - return include.load(), filename + return include.load() except esphome.core.EsphomeError as err: + resolved = f" (expanded from '{original}')" if substituted else "" raise cv.Invalid( - f"Error including file '{filename}': {err}", + f"Error including file '{filename}'{resolved}: {err}" + f"\n{format_path(path, original)}", path + [f"<{filename}>"], ) from err def _substitute_include( include: IncludeFile, - path: list[int | str], + path: DocumentPath, context_vars: ContextVars, strict_undefined: bool, errors: ErrList | None, ) -> Any: """Resolve an include and substitute its content.""" - content, filename = resolve_include( - include, path, context_vars, strict_undefined, errors - ) - return substitute( - content, path + [f"<{filename}>"], context_vars, strict_undefined, errors - ) + content = resolve_include(include, path, context_vars, strict_undefined, errors) + return substitute(content, path, context_vars, strict_undefined, errors) def substitute( item: Any, - path: SubstitutionPath, + path: DocumentPath, parent_context: ContextVars, strict_undefined: bool, errors: ErrList | None = None, @@ -401,19 +430,43 @@ def _warn_unresolved_variables(errors: ErrList) -> None: for err, path, expression in errors: if "password" in path: continue - location: str = "->".join(str(x) for x in path) - if isinstance(expression, ESPHomeDataBase) and expression.esp_range is not None: - location += f" in {str(expression.esp_range.start_mark)}" - _LOGGER.warning( "The string '%s' looks like an expression," - " but could not resolve all the variables: %s (see %s)", + " but could not resolve all the variables: %s\n%s", expression, err.message, - location, + format_path(path, expression), ) +def resolve_substitutions_block( + substitutions: Any, + command_line_substitutions: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. + + The caller is responsible for wrapping the call in + ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``command_line_substitutions`` seeds the filename context so + ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. + """ + if isinstance(substitutions, IncludeFile): + # Single-shot resolution — matches ``_walk_packages`` for the + # ``packages: !include`` entry point. Chained includes (an include that + # itself loads another ``!include`` at the top level) are not supported. + substitutions = resolve_include( + substitutions, + [], + ContextVars(command_line_substitutions or {}), + strict_undefined=False, + ) + if not isinstance(substitutions, dict): + raise cv.Invalid( + f"Substitutions must be a key to value mapping, got {type(substitutions)}" + ) + return substitutions + + def do_substitution_pass( config: OrderedDict, command_line_substitutions: dict[str, Any] | None = None ) -> OrderedDict: @@ -429,10 +482,9 @@ def do_substitution_pass( # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) with cv.prepend_path(CONF_SUBSTITUTIONS): - if not isinstance(substitutions, dict): - raise cv.Invalid( - f"Substitutions must be a key to value mapping, got {type(substitutions)}" - ) + substitutions = resolve_substitutions_block( + substitutions, command_line_substitutions + ) substitutions = merge_dicts_ordered( substitutions, command_line_substitutions or {} ) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 08f4c0fb8..b8696158f 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -200,11 +200,11 @@ CONFIG_SCHEMA = ( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 7f554fbf8..8fa724719 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -197,11 +197,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( - cv.frequency, cv.float_range(min=0, max=100000) + cv.frequency, cv.int_range(min=0, max=100000) ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.All( - cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + cv.frequency, cv.int_range(min=int(137e6), max=int(1020e6)) ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 3eb794df4..2fefe08c0 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -57,7 +57,10 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { } void TCA9555Component::loop() { this->reset_pin_cache_(); - if (this->interrupt_pin_ != nullptr) { + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { this->disable_loop(); } } diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 4fe87de0c..1098d8de5 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration my_integration_time_regval = integration_time; this->integration_time_auto_ = false; } - this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f; + this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f; ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_); } void TCS34725Component::set_gain(TCS34725Gain gain) { diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 572b5ba0f..1266370cb 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import text import esphome.config_validation as cv from esphome.const import ( + CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, CONF_MAX_LENGTH, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, ) +from esphome.core import ID from .. import template_ns @@ -84,8 +86,15 @@ async def to_code(config): if initial_value_config := config.get(CONF_INITIAL_VALUE): cg.add(var.set_initial_value(initial_value_config)) if config[CONF_RESTORE_VALUE]: - args = cg.TemplateArguments(config[CONF_MAX_LENGTH]) - saver = TextSaverTemplate.template(args).new() + saver_id = ID( + f"{config[CONF_ID].id}_value_saver", + is_declaration=True, + type=TextSaverBase, + ) + saver_type = TextSaverTemplate.template( + cg.TemplateArguments(config[CONF_MAX_LENGTH]) + ) + saver = cg.Pvariable(saver_id, saver_type.new()) cg.add(var.set_value_saver(saver)) if CONF_SET_ACTION in config: diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ac0abeee..3295366fe 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,3 +1,4 @@ +import errno from importlib import resources import logging @@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None: return (resources.files(package) / resource).read_bytes() except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError): return None + except OSError as e: + # Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>' + # in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here). + if e.errno == errno.EINVAL: + return None + raise def _extract_tz_string(tzfile: bytes) -> str: @@ -109,8 +116,7 @@ def _parse_cron_int(value, special_mapping, message): try: return int(value) except ValueError: - # pylint: disable=raise-missing-from - raise cv.Invalid(message.format(value)) + raise cv.Invalid(message.format(value)) from None def _parse_cron_part(part, min_value, max_value, special_mapping): @@ -134,10 +140,9 @@ def _parse_cron_part(part, min_value, max_value, special_mapping): try: repeat_n = int(repeat) except ValueError: - # pylint: disable=raise-missing-from raise cv.Invalid( f"Repeat for '/' time expression must be an integer, got {repeat}" - ) + ) from None return set(range(offset_n, max_value + 1, repeat_n)) if "-" in part: data = part.split("-") diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index da9adb59a..4814d5b1c 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -347,6 +347,13 @@ uint8_t TM1637Display::print(uint8_t start_pos, const char *str) { } return pos - start_pos; } + +void TM1637Display::set_brightness(float brightness) { + auto intensity = clamp(brightness, 0.f, 1.f) * 7; + this->set_on(intensity > 0); + this->set_intensity(intensity); +} + uint8_t TM1637Display::print(const char *str) { return this->print(0, str); } void TM1637Display::set_buffer(const uint8_t *data, uint8_t length) { diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index c1fbabb21..1738d3710 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -50,6 +50,9 @@ class TM1637Display : public PollingComponent { /// Set raw buffer bytes from data array up to length bytes. void set_buffer(const uint8_t *data, uint8_t length); + /// Set the display brightness. Accepts a value between 0.0 and 1.0; 0 will turn off + /// the display and 1.0 will set it to the maximum brightness. + void set_brightness(float brightness); void set_intensity(uint8_t intensity) { this->intensity_ = intensity; } void set_inverted(bool inverted) { this->inverted_ = inverted; } void set_length(uint8_t length) { this->length_ = length; } diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0e8994a3e..d542788fb 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -116,12 +116,23 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): + # The output chunk pool/queue are compile-time-sized templates shared by all + # USBUartChannel instances, so use the largest buffer_size across every channel + # of every device. Each chunk is 64 bytes (USB FS MPS); add one extra slot + # because LockFreeQueue is a ring buffer that wastes one entry. + max_buffer_size = max( + channel[CONF_BUFFER_SIZE] + for device in config + for channel in device[CONF_CHANNELS] + ) + output_chunk_count = max_buffer_size // 64 + 1 + cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) + for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) - cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE])) cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS])) cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS])) cg.add(chvar.set_parity(channel[CONF_PARITY])) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 8e8e65032..f9648b795 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -132,8 +132,9 @@ class USBUartChannel : public uart::UARTComponent, public Parentedevents_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator); + return true; +} +#endif #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 6a8406610..9cfc6c7e3 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -75,6 +75,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_VALVE bool on_valve(valve::Valve *obj) override; #endif +#ifdef USE_MEDIA_PLAYER + bool on_media_player(media_player::MediaPlayer *obj) override { return true; } +#endif #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; #endif @@ -84,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator { #ifdef USE_INFRARED bool on_infrared(infrared::Infrared *obj) override; #endif +#ifdef USE_RADIO_FREQUENCY + bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *obj) override; #endif diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901..9812714ec 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1daec1786..198267204 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -40,6 +40,9 @@ #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 @@ -2102,6 +2105,104 @@ json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, J } #endif +#ifdef USE_RADIO_FREQUENCY +void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (radio_frequency::RadioFrequency *obj : App.get_radio_frequencies()) { + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) + continue; + + if (request->method() == HTTP_GET && entity_match.action_is_empty) { + auto detail = get_request_detail(request); + auto data = this->radio_frequency_json_(obj, detail); + request->send(200, ESPHOME_F("application/json"), data.c_str()); + return; + } + if (!match.method_equals(ESPHOME_F("transmit"))) { + request->send(404); + return; + } + + // Only allow transmit if the device supports it + if (!(obj->get_capability_flags() & radio_frequency::CAPABILITY_TRANSMITTER)) { + request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Device does not support transmission")); + return; + } + + auto call = obj->make_call(); + + // Parse carrier frequency (optional — overrides IC default) + { + auto value = parse_number(request->arg(ESPHOME_F("frequency")).c_str()); + if (value.has_value()) { + call.set_frequency(*value); + } + } + + // Parse repeat count (optional, defaults to 1) + { + auto value = parse_number(request->arg(ESPHOME_F("repeat_count")).c_str()); + if (value.has_value()) { + call.set_repeat_count(*value); + } + } + + // Parse base64url-encoded raw timings (required) + // Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping) + const auto &data_arg = request->arg(ESPHOME_F("data")); + + // Validate base64url is not empty (also catches missing parameter since arg() returns empty string) + // Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility + if (data_arg.length() == 0) { // NOLINT(readability-container-size-empty) + request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Missing or empty 'data' parameter")); + return; + } + + // Defer to main loop for thread safety. Move encoded string into lambda to ensure + // it outlives the call - set_raw_timings_base64url stores a pointer, so the string + // must remain valid until perform() completes. + // ESP8266 also needs this because ESPAsyncWebServer callbacks run in "sys" context. + this->defer([call, encoded = std::string(data_arg.c_str(), data_arg.length())]() mutable { + call.set_raw_timings_base64url(encoded); + call.perform(); + }); + + request->send(200); + return; + } + request->send(404); +} + +json::SerializationBuffer<> WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + return web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL); +} + +json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, + JsonDetail start_config) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "radio_frequency", "", 0, start_config); + + const auto &traits = obj->get_traits(); + auto caps = obj->get_capability_flags(); + + root[ESPHOME_F("supports_transmitter")] = bool(caps & radio_frequency::CAPABILITY_TRANSMITTER); + root[ESPHOME_F("supports_receiver")] = bool(caps & radio_frequency::CAPABILITY_RECEIVER); + if (traits.get_frequency_min_hz() != 0) { + root[ESPHOME_F("frequency_min")] = traits.get_frequency_min_hz(); + root[ESPHOME_F("frequency_max")] = traits.get_frequency_max_hz(); + } + + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); +} +#endif + #ifdef USE_EVENT void WebServer::on_event(event::Event *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -2357,6 +2458,10 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #ifdef USE_INFRARED if (match.domain_equals(ESPHOME_F("infrared"))) return true; +#endif +#ifdef USE_RADIO_FREQUENCY + if (match.domain_equals(ESPHOME_F("radio_frequency"))) + return true; #endif } @@ -2516,6 +2621,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { else if (match.domain_equals(ESPHOME_F("infrared"))) { this->handle_infrared_request(request, match); } +#endif +#ifdef USE_RADIO_FREQUENCY + else if (match.domain_equals(ESPHOME_F("radio_frequency"))) { + this->handle_radio_frequency_request(request, match); + } #endif else { // No matching handler found - send 404 diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 8e8b1de8c..25f8f8212 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -462,6 +462,12 @@ class WebServer final : public Controller, public Component, public AsyncWebHand static json::SerializationBuffer<> infrared_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_RADIO_FREQUENCY + /// Handle a radio frequency request under '/radio_frequency//transmit'. + void handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match); + + static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); +#endif #ifdef USE_EVENT void on_event(event::Event *obj) override; @@ -654,6 +660,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #ifdef USE_INFRARED json::SerializationBuffer<> infrared_json_(infrared::Infrared *obj, JsonDetail start_config); #endif +#ifdef USE_RADIO_FREQUENCY + json::SerializationBuffer<> radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config); +#endif #ifdef USE_UPDATE json::SerializationBuffer<> update_json_(update::UpdateEntity *obj, JsonDetail start_config); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 8f464ae91..e1d3e4bf3 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -472,24 +472,36 @@ void AsyncResponseStream::printf(const char *fmt, ...) { #ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { - for (auto *ses : this->sessions_) { - delete ses; // NOLINT(cppcoreguidelines-owning-memory) + LockGuard guard{this->pending_mutex_}; + for (auto *vec : {&this->sessions_, &this->pending_sessions_}) { + for (auto *ses : *vec) { + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } } } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { + // Httpd task: set up the live httpd_req_t and park the session; main loop does the rest. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); - if (this->on_connect_) { - this->on_connect_(rsp); + { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + this->has_pending_sessions_.store(true, std::memory_order_release); } - this->sessions_.push_back(rsp); - // Wake up WebServer::loop() to drain deferred event queues for this client. - // Safe from httpd task context via the pending_enable_loop_ flag. this->web_server_->enable_loop_soon_any_context(); } +// clang-analyzer traces a false-positive leak path from loop() through +// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and +// finally ArduinoJson. Suppress along the entire in-our-code call chain. +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) bool AsyncEventSource::loop() { + // Fast path: one atomic load per tick. Slow path is out-of-line on connect. + if (this->has_pending_sessions_.load(std::memory_order_acquire)) { + this->adopt_pending_sessions_main_loop_(); + } + // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -497,7 +509,7 @@ bool AsyncEventSource::loop() { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) if (ses->fd_.load() == 0) { - ESP_LOGD(TAG, "Removing dead event source session"); + // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) this->sessions_[i] = this->sessions_.back(); @@ -510,6 +522,30 @@ bool AsyncEventSource::loop() { return !this->sessions_.empty(); } +void AsyncEventSource::adopt_pending_sessions_main_loop_() { + std::vector incoming; + { + LockGuard guard{this->pending_mutex_}; + incoming.swap(this->pending_sessions_); + this->has_pending_sessions_.store(false, std::memory_order_relaxed); + } + for (auto *rsp : incoming) { + // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + if (rsp->fd_.load() == 0) { + delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + continue; + } + this->sessions_.push_back(rsp); + // Prime first so on_connect_ observes a session that has already sent its + // initial ping/config/sorting_groups, matching the pre-refactor ordering. + rsp->start_session_main_loop_(); + if (this->on_connect_) { + this->on_connect_(rsp); + } + } +} +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) + void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions @@ -534,6 +570,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { + // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -555,21 +592,23 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * // Use non-blocking send to prevent watchdog timeouts when TCP buffers are full httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); +} - // Configure reconnect timeout and send config - // this should always go through since the tcp send buffer is empty on connect +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +void AsyncEventSourceResponse::start_session_main_loop_() { + auto *ws = this->web_server_; + + // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson json::JsonBuilder builder; JsonObject root = builder.root(); root["name"] = group.second.name; root["sorting_weight"] = group.second.weight; message = builder.serialize(); - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config @@ -578,13 +617,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #endif this->entities_iterator_.begin(ws->include_internal_); - - // just dump them all up-front and take advantage of the deferred queue - // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!this->entities_iterator_.completed()) { - // this->entities_iterator_.advance(); - //} } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f2931fb50..cdb58c2f0 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -299,6 +299,9 @@ class AsyncEventSourceResponse { AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws); + // Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator. + void start_session_main_loop_(); + void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); @@ -335,6 +338,8 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; + // Callback runs on the main loop (not the httpd task) after the session's + // initial ping/config/sorting_groups have been sent. // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } @@ -347,13 +352,18 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: + // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. + void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); + std::string url_; - // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). - // Linear search is faster than red-black tree overhead for this small dataset. - // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. + // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; + // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. + std::vector pending_sessions_; + Mutex pending_mutex_; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; + std::atomic has_pending_sessions_{false}; }; #endif // USE_WEBSERVER diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 33557f03c..bc4e17721 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -289,12 +289,12 @@ def final_validate(config): def _consume_wifi_sockets(config: ConfigType) -> ConfigType: """Register UDP PCBs used internally by lwIP for DHCP and DNS. - Only needed on LibreTiny where we directly set MEMP_NUM_UDP_PCB (the raw - PCB pool shared by both application sockets and lwIP internals like DHCP/DNS). - On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — - DHCP/DNS use raw udp_new() which bypasses it entirely. + Needed on LibreTiny and RP2040 where we directly set MEMP_NUM_UDP_PCB (the + raw PCB pool shared by both application sockets and lwIP internals like + DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket + layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): return config from esphome.components import socket diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed..f7c70b114 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -308,6 +308,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) @@ -326,6 +327,7 @@ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { return LOG_STR("RESTARTING"); return LOG_STR("UNKNOWN"); } +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO bool WiFiComponent::went_through_explicit_hidden_phase_() const { // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase @@ -730,9 +732,16 @@ void WiFiComponent::restart_adapter() { } void WiFiComponent::loop() { - this->wifi_loop_(); + bool events_processed = this->wifi_loop_(); const uint32_t now = App.get_loop_component_start_time(); - this->update_connected_state_(); + // Connection state can only change when events are processed (ESP-IDF/LibreTiny) + // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call + // when no events arrived and we're already in steady state. + // Must also run when connected_ is false — after state transitions to STA_CONNECTED, + // connected_ won't be set until update_connected_state_() runs. + if (events_processed || !this->connected_) { + this->update_connected_state_(); + } if (this->has_sta()) { #if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER) @@ -1570,6 +1579,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 665dec37d..53fb0728f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -9,6 +9,11 @@ #ifdef USE_ESP32 #include "esphome/core/lock_free_queue.h" #endif +#if defined(USE_LIBRETINY) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#include "esphome/core/lock_free_queue.h" +#elif defined(USE_LIBRETINY) && defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) +#include "esphome/core/freertos_queue.h" +#endif #include "esphome/core/string_ref.h" #include @@ -657,7 +662,7 @@ class WiFiComponent final : public Component { void connect_soon_(); - void wifi_loop_(); + bool wifi_loop_(); #ifdef USE_ESP8266 void process_pending_callbacks_(); #endif @@ -882,6 +887,19 @@ class WiFiComponent final : public Component { LockFreeQueue event_queue_; #endif +#ifdef USE_LIBRETINY + // Thread-safe queue for WiFi events from LibreTiny callback thread. + // LockFreeQueue on platforms with hardware atomics (RTL87xx, LN882x), + // FreeRTOSQueue on platforms without (BK72xx). + static constexpr uint8_t LT_EVENT_QUEUE_SIZE = 16; +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Ring buffer reserves one slot, so +1 for 16 usable slots + LockFreeQueue event_queue_; +#else + FreeRTOSQueue event_queue_; +#endif +#endif + private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cb53d3ac1..bf3a0d294 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -938,7 +938,10 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() { return network::IPAddress(&ip.gw); } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); } -void WiFiComponent::wifi_loop_() { this->process_pending_callbacks_(); } +bool WiFiComponent::wifi_loop_() { + this->process_pending_callbacks_(); + return true; +} void WiFiComponent::process_pending_callbacks_() { // Process callbacks deferred from ESP8266 SDK system context (~2KB stack) @@ -948,6 +951,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4097df80a..29d135ce9 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -715,17 +715,25 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } -void WiFiComponent::wifi_loop_() { +bool WiFiComponent::wifi_loop_() { + // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), + // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. + IDFWiFiEvent *data = this->event_queue_.pop(); + if (data == nullptr) + return false; + + do { + wifi_process_event_(data); + delete data; // NOLINT(cppcoreguidelines-owning-memory) + } while ((data = this->event_queue_.pop()) != nullptr); + + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); } - - IDFWiFiEvent *data; - while ((data = this->event_queue_.pop()) != nullptr) { - wifi_process_event_(data); - delete data; // NOLINT(cppcoreguidelines-owning-memory) - } + return true; } // Events are processed from queue in main loop context, but listener notifications // must be deferred until after the state machine transitions (in check_connecting_finished) @@ -796,6 +804,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9565ffa74..59efa4f84 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -10,12 +10,14 @@ #include "lwip/err.h" #include "lwip/dns.h" -#include -#include - #ifdef USE_BK72XX extern "C" { +// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t, +// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the +// BDK version, so rename it across this include to avoid the collision. +#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t #include +#undef wifi_event_sta_disconnected_t } #endif @@ -43,16 +45,13 @@ static const char *const TAG = "wifi_lt"; // (like connection status flags) from the callback causes race conditions: // - The main loop may never see state changes (values cached in registers) // - State changes may be visible in inconsistent order -// - LibreTiny targets (BK7231, RTL8720) lack atomic instructions (no LDREX/STREX) // // Solution: Queue events in the callback and process them in the main loop. // This is the same approach used by ESP32 IDF's wifi_process_event_(). // All state modifications happen in the main loop context, eliminating races. - -static constexpr size_t EVENT_QUEUE_SIZE = 16; // Max pending WiFi events before overflow -static QueueHandle_t s_event_queue = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static volatile uint32_t s_event_queue_overflow_count = - 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// +// On platforms with hardware atomics (RTL87xx, LN882x): LockFreeQueue (SPSC ring buffer) +// On platforms without (BK72xx): FreeRTOSQueue (xQueue wrapper with critical sections) // Event structure for queued WiFi events - contains a copy of event data // to avoid lifetime issues with the original event data from the callback @@ -352,10 +351,6 @@ using esphome_wifi_event_info_t = arduino_event_info_t; // Event callback - runs in WiFi driver thread context // Only queues events for processing in main loop, no logging or state changes here void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_wifi_event_info_t info) { - if (s_event_queue == nullptr) { - return; - } - // Allocate on heap and fill directly to avoid extra memcpy auto *to_send = new LTWiFiEvent{}; // NOLINT(cppcoreguidelines-owning-memory) to_send->event_id = event; @@ -428,9 +423,8 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } // Queue event (don't block if queue is full) - if (xQueueSend(s_event_queue, &to_send, 0) != pdPASS) { + if (!this->event_queue_.push(to_send)) { delete to_send; // NOLINT(cppcoreguidelines-owning-memory) - s_event_queue_overflow_count++; } } @@ -536,6 +530,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif @@ -620,14 +616,6 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { } } void WiFiComponent::wifi_pre_setup_() { - // Create event queue for thread-safe event handling - // Events are pushed from WiFi callback thread and processed in main loop - s_event_queue = xQueueCreate(EVENT_QUEUE_SIZE, sizeof(LTWiFiEvent *)); - if (s_event_queue == nullptr) { - ESP_LOGE(TAG, "Failed to create event queue"); - return; - } - WiFi.onEvent( [this](arduino_event_id_t event, arduino_event_info_t info) { this->wifi_event_callback_(event, info); }); // Make sure WiFi is in clean state before anything starts @@ -796,28 +784,26 @@ int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {WiFi.dnsIP(num)}; } -void WiFiComponent::wifi_loop_() { - // Process all pending events from the queue - if (s_event_queue == nullptr) { - return; - } - - // Check for dropped events due to queue overflow - if (s_event_queue_overflow_count > 0) { - ESP_LOGW(TAG, "Event queue overflow, %" PRIu32 " events dropped", s_event_queue_overflow_count); - s_event_queue_overflow_count = 0; - } - - while (true) { - LTWiFiEvent *event; - if (xQueueReceive(s_event_queue, &event, 0) != pdTRUE) { - // No more events - break; - } +bool WiFiComponent::wifi_loop_() { + // Use pop() directly instead of empty() — avoids redundant synchronization. + // LockFreeQueue: pop() costs 1 memw vs empty()'s 2 memw on Xtensa. + // FreeRTOSQueue: pop() is 1 critical section vs empty() + pop() = 2. + LTWiFiEvent *event = this->event_queue_.pop(); + if (event == nullptr) + return false; + do { wifi_process_event_(event); delete event; // NOLINT(cppcoreguidelines-owning-memory) + } while ((event = this->event_queue_.pop()) != nullptr); + + // Drops only occur when the queue is full, and only this loop drains it, + // so if pop() returned nullptr above we can skip this check. + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } + return true; } } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1cfeee3c1..596fd2729 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -303,7 +303,7 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { // Connect state listener notifications are deferred until after the state machine // transitions (in check_connecting_finished) so that conditions like wifi.connected // return correct values in automations. -void WiFiComponent::wifi_loop_() { +bool WiFiComponent::wifi_loop_() { // Handle scan completion if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; @@ -342,6 +342,8 @@ void WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif @@ -365,6 +367,7 @@ void WiFiComponent::wifi_loop_() { #endif } } + return true; } void WiFiComponent::wifi_pre_setup_() {} diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 9da349432..51971a122 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -67,7 +67,7 @@ def _validate_load_certificate(value): contents = read_relative_config_path(value) return wrapped_load_pem_x509_certificate(contents) except ValueError as err: - raise cv.Invalid(f"Invalid certificate: {err}") + raise cv.Invalid(f"Invalid certificate: {err}") from err def validate_certificate(value): @@ -86,9 +86,9 @@ def _validate_load_private_key(key, cert_pw): except ValueError as e: raise cv.Invalid( f"There was an error with the EAP 'password:' provided for 'key' {e}" - ) + ) from e except TypeError as e: - raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") + raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") from e def _check_private_key_cert_match(key, cert): diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 1b5439137..e128b8476 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -53,7 +53,7 @@ def _cidr_network(value): try: ipaddress.ip_network(value, strict=False) except ValueError as err: - raise cv.Invalid(f"Invalid network in CIDR notation: {err}") + raise cv.Invalid(f"Invalid network in CIDR notation: {err}") from err return value @@ -137,7 +137,7 @@ async def to_code(config): # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.4") + cg.add_library("droscy/esp_wireguard", "0.4.5") await cg.register_component(var, config) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d3cc6b2cf..5dccecc09 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -15,6 +15,7 @@ from .const import ( KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, @@ -54,6 +55,7 @@ class ZephyrData(TypedDict): extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] + kconfig: str def zephyr_set_core_data(config: ConfigType) -> None: @@ -65,6 +67,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: extra_build_files={}, pm_static=[], user={}, + kconfig="", ) @@ -185,8 +188,12 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: ) -def zephyr_add_pm_static(section: Section): - CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_kconfig(kconfig: str) -> None: + zephyr_data()[KEY_KCONFIG] += textwrap.dedent(kconfig) + "\n" + + +def zephyr_add_pm_static(sections: list[Section]) -> None: + zephyr_data()[KEY_PM_STATIC].extend(sections) def zephyr_add_user(key, value): @@ -273,3 +280,18 @@ def copy_files(): write_file_if_changed( CORE.relative_build_path("zephyr/pm_static.yml"), pm_static ) + + kconfig = zephyr_data()[KEY_KCONFIG] + if kconfig: + kconfig = ( + textwrap.dedent( + """ + menu "Zephyr" + source "Kconfig.zephyr" + endmenu + """ + ) + + "\n" + + kconfig + ) + write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f67b058ed..f2de861e3 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -8,6 +8,7 @@ KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" +KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index a3b0471eb..93a9a1ae8 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -99,7 +99,6 @@ int main() { setup(); while (true) { loop(); - esphome::yield(); } return 0; } diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 211941e98..658137d1a 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,28 +1,35 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, CONF_ID, CONF_NAME, Framework -import esphome.final_validate as fv +from esphome.const import CONF_ID, Framework +from esphome.core import CORE zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) +CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request" +CONF_ACCEPT = "accept" + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BLEServer), + cv.Optional( + CONF_ON_NUMERIC_COMPARISON_REQUEST + ): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), cv.only_with_framework(Framework.ZEPHYR), ) - -def _final_validate(_): - full_config = fv.full_config.get() - zephyr_add_prj_conf("BT_DEVICE_NAME", full_config[CONF_ESPHOME][CONF_NAME]) - - -FINAL_VALIDATE_SCHEMA = _final_validate +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_NUMERIC_COMPARISON_REQUEST, + "add_passkey_callback", + [(cg.uint32, "passkey")], + ), +) async def to_code(config): @@ -30,5 +37,39 @@ async def to_code(config): zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) - # zephyr_add_prj_conf("BT_LL_SW_SPLIT", True) + zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) await cg.register_component(var, config) + if config.get(CONF_ON_NUMERIC_COMPARISON_REQUEST): + zephyr_add_prj_conf("BT_SMP", True) + zephyr_add_prj_conf("BT_SETTINGS", True) + zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) + zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( + "BLENumericComparisonReplyAction", automation.Action +) + +BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEServer), + cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "ble_server.numeric_comparison_reply", + BLENumericComparisonReplyAction, + BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, + synchronous=True, +) +async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) + cg.add(var.set_accept(templ)) + + return var diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index 9f7e606a9..15993abcc 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -3,32 +3,34 @@ #include "esphome/core/defines.h" #include "esphome/core/log.h" #include -#include +#include namespace esphome::zephyr_ble_server { static const char *const TAG = "zephyr_ble_server"; -static struct k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #define DEVICE_NAME CONFIG_BT_DEVICE_NAME #define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) -static const struct bt_data AD[] = { +static const bt_data AD[] = { BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), }; -static const struct bt_data SD[] = { +static const bt_data SD[] = { #ifdef USE_OTA BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c, 0xb7, 0x1d, 0x1d, 0xdc, 0x53, 0x8d), #endif }; -const struct bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; +const bt_le_adv_param *const ADV_PARAM = BT_LE_ADV_CONN; -static void advertise(struct k_work *work) { +static void advertise(k_work *work) { int rc = bt_le_adv_stop(); if (rc) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); @@ -42,57 +44,276 @@ static void advertise(struct k_work *work) { ESP_LOGI(TAG, "Advertising successfully started"); } -static void connected(struct bt_conn *conn, uint8_t err) { +void BLEServer::connected(bt_conn *conn, uint8_t err) { + char addr[BT_ADDR_LE_STR_LEN]; + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); if (err) { - ESP_LOGE(TAG, "Connection failed (err 0x%02x)", err); - } else { - ESP_LOGI(TAG, "Connected"); + ESP_LOGE(TAG, "Failed to connect to %s (%u)", addr, err); + return; } + ESP_LOGI(TAG, "Connected %s", addr); +#ifdef CONFIG_BT_SMP + if (bt_conn_set_security(conn, BT_SECURITY_L4)) { + ESP_LOGE(TAG, "Failed to set security"); + } +#endif + conn = bt_conn_ref(conn); + global_ble_server->defer([conn]() { global_ble_server->conn_ = conn; }); } -static void disconnected(struct bt_conn *conn, uint8_t reason) { - ESP_LOGI(TAG, "Disconnected (reason 0x%02x)", reason); +void BLEServer::disconnected(bt_conn *conn, uint8_t reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Disconnected from %s (reason 0x%02x)", addr, reason); + global_ble_server->defer([]() { + if (global_ble_server->conn_) { + bt_conn_unref(global_ble_server->conn_); + global_ble_server->conn_ = nullptr; + } + }); k_work_submit(&advertise_work); } -static void bt_ready(int err) { - if (err != 0) { - ESP_LOGE(TAG, "Bluetooth failed to initialise: %d", err); +#ifdef CONFIG_BT_SMP +static void identity_resolved(bt_conn *conn, const bt_addr_le_t *rpa, const bt_addr_le_t *identity) { + char addr_identity[BT_ADDR_LE_STR_LEN]; + char addr_rpa[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(identity, addr_identity, sizeof(addr_identity)); + bt_addr_le_to_str(rpa, addr_rpa, sizeof(addr_rpa)); + + ESP_LOGD(TAG, "Identity resolved %s -> %s", addr_rpa, addr_identity); +} + +static void security_changed(bt_conn *conn, bt_security_t level, bt_security_err err) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + if (!err) { + ESP_LOGD(TAG, "Security changed: %s level %u", addr, level); } else { - k_work_submit(&advertise_work); + ESP_LOGE(TAG, "Security failed: %s level %u err %d", addr, level, err); } } -BT_CONN_CB_DEFINE(conn_callbacks) = { - .connected = connected, - .disconnected = disconnected, -}; +static void pairing_complete(bt_conn *conn, bool bonded) { + char addr[BT_ADDR_LE_STR_LEN]; -void BLEServer::setup() { - k_work_init(&advertise_work, advertise); - resume_(); + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGD(TAG, "Pairing completed: %s, bonded: %d", addr, bonded); } -void BLEServer::loop() { - if (this->suspended_) { - resume_(); - this->suspended_ = false; - } +static void pairing_failed(bt_conn *conn, bt_security_err reason) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGE(TAG, "Pairing failed conn: %s, reason %d", addr, reason); + + bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); } -void BLEServer::resume_() { - int rc = bt_enable(bt_ready); - if (rc != 0) { - ESP_LOGE(TAG, "Bluetooth enable failed: %d", rc); +static void bond_deleted(uint8_t id, const bt_addr_le_t *peer) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(peer, addr, sizeof(addr)); + ESP_LOGD(TAG, "Bond deleted for %s, id %u", addr, id); +} + +static void auth_passkey_display(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Passkey for %s: %s", addr, passkey_str); +} + +static void conn_addr_str(bt_conn *conn, char *addr, size_t len) { + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + addr[0] = '\0'; return; } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, len); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + addr[0] = '\0'; + break; + } } -void BLEServer::on_shutdown() { - struct k_work_sync sync; - k_work_cancel_sync(&advertise_work, &sync); - bt_disable(); - this->suspended_ = true; +static void auth_cancel(bt_conn *conn) { + char addr[BT_ADDR_LE_STR_LEN]; + + conn_addr_str(conn, addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing cancelled: %s", addr); +} + +void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) { + char addr[BT_ADDR_LE_STR_LEN]; + char passkey_str[7]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + snprintk(passkey_str, 7, "%06u", passkey); + + ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str); + global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); }); +} + +static void auth_pairing_confirm(bt_conn *conn) { + /* Automatically confirm pairing request from the device side. */ + auto err = bt_conn_auth_pairing_confirm(conn); + if (err) { + ESP_LOGE(TAG, "Can't confirm pairing (err: %d)", err); + return; + } + + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + ESP_LOGI(TAG, "Pairing confirmed: %s", addr); +} + +#endif + +void BLEServer::setup() { + global_ble_server = this; + int err = 0; + k_work_init(&advertise_work, advertise); + + static bt_conn_cb conn_callbacks = { + .connected = connected, + .disconnected = disconnected, +#ifdef CONFIG_BT_SMP + .identity_resolved = identity_resolved, + .security_changed = security_changed, +#endif + }; + + bt_conn_cb_register(&conn_callbacks); +#ifdef CONFIG_BT_SMP + static struct bt_conn_auth_info_cb conn_auth_info_callbacks = { + .pairing_complete = pairing_complete, .pairing_failed = pairing_failed, .bond_deleted = bond_deleted}; + err = bt_conn_auth_info_cb_register(&conn_auth_info_callbacks); + if (err) { + ESP_LOGE(TAG, "Failed to register authorization info callbacks."); + } + static struct bt_conn_auth_cb auth_cb = { + .passkey_display = auth_passkey_display, + .passkey_confirm = auth_passkey_confirm, + .cancel = auth_cancel, + .pairing_confirm = auth_pairing_confirm, + }; + err = bt_conn_auth_cb_register(&auth_cb); + if (err) { + ESP_LOGE(TAG, "Failed to set auth handlers (%d)", err); + } +#endif + // callback cannot be used to start scanning due to race conditions with BT_SETTINGS + err = bt_enable(nullptr); + if (err) { + ESP_LOGE(TAG, "Bluetooth enable failed: %d", err); + return; + } +#ifdef CONFIG_BT_SETTINGS + err = settings_load(); + if (err) { + ESP_LOGE(TAG, "Cannot load settings, err: %d", err); + } +#endif + k_work_submit(&advertise_work); +} + +#ifdef ESPHOME_LOG_HAS_DEBUG +static const char *role_str(uint8_t role) { + switch (role) { + case BT_CONN_ROLE_CENTRAL: + return "Central"; + case BT_CONN_ROLE_PERIPHERAL: + return "Peripheral"; + } + + return "Unknown"; +} + +static void connection_info(bt_conn *conn, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + struct bt_conn_info info; + + if (bt_conn_get_info(conn, &info) < 0) { + ESP_LOGE(TAG, "Unable to get info: conn %p", conn); + return; + } + + switch (info.type) { + case BT_CONN_TYPE_LE: + bt_addr_le_to_str(info.le.dst, addr, sizeof(addr)); + ESP_LOGD(TAG, " %u [LE][%s] %s: Interval %u latency %u timeout %u security L%u", info.id, role_str(info.role), + addr, info.le.interval, info.le.latency, info.le.timeout, info.security.level); + break; + default: + ESP_LOGE(TAG, "Not implemented"); + break; + } +} +#ifdef CONFIG_BT_BONDABLE +static void bond_info(const struct bt_bond_info *info, void *user_data) { + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(&info->addr, addr, sizeof(addr)); + ESP_LOGD(TAG, " Bond remote identity: %s", addr); +} +#endif +#endif + +void BLEServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ble server:\n" + " connected: %s\n" + " name: %s\n" + " appearance: %u\n" + " ready: %s\n" +#ifdef CONFIG_BT_SMP + " security manager: YES", +#else + " security manager: NO", +#endif + YESNO(this->conn_), bt_get_name(), bt_get_appearance(), YESNO(bt_is_ready())); + +#ifdef ESPHOME_LOG_HAS_DEBUG + bt_conn_foreach(BT_CONN_TYPE_ALL, connection_info, nullptr); +#ifdef CONFIG_BT_BONDABLE + bt_foreach_bond(BT_ID_DEFAULT, bond_info, nullptr); +#endif +#endif +} + +void BLEServer::numeric_comparison_reply(bool accept) { + if (this->conn_ == nullptr) { + ESP_LOGE(TAG, "Not connected"); + return; + } + ESP_LOGD(TAG, "Numeric comparison %s", accept ? "accepted" : "rejected"); + if (accept) { + bt_conn_auth_passkey_confirm(this->conn_); + } else { + bt_conn_auth_cancel(this->conn_); + } } } // namespace esphome::zephyr_ble_server diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 1b32e9b58..bf69c52b1 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -1,18 +1,36 @@ #pragma once #ifdef USE_ZEPHYR #include "esphome/core/component.h" +#include +#include "esphome/core/automation.h" namespace esphome::zephyr_ble_server { class BLEServer : public Component { public: void setup() override; - void loop() override; - void on_shutdown() override; + void dump_config() override; + template void add_passkey_callback(F &&callback) { this->passkey_cb_.add(std::forward(callback)); } + void numeric_comparison_reply(bool accept); protected: - void resume_(); - bool suspended_ = false; + static void connected(bt_conn *conn, uint8_t err); + static void disconnected(bt_conn *conn, uint8_t reason); + static void auth_passkey_confirm(bt_conn *conn, unsigned int passkey); + bt_conn *conn_{}; + CallbackManager passkey_cb_; +}; + +template class BLENumericComparisonReplyAction : public Action { + public: + explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(bool, accept) + + void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } + + protected: + BLEServer *parent_; }; } // namespace esphome::zephyr_ble_server diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 280ff6b50..0bb5f95bb 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,26 +3,43 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg +from esphome.components.esp32 import only_on_variant +from esphome.components.esp32.const import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data from esphome.components.zephyr.const import KEY_BOOTLOADER import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +from .const import ( + CONF_ON_JOIN, + CONF_POWER_SOURCE, + CONF_REPORT, + CONF_ROUTER, + CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + REPORT, + ZigbeeComponent, + zigbee_ns, +) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, - CONF_ON_JOIN, - CONF_POWER_SOURCE, - CONF_WIPE_ON_BOOT, + CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, - ZigbeeComponent, - zigbee_ns, +) +from .zigbee_esp32 import ( + final_validate_esp32, + validate_binary_sensor_esp32, + zigbee_require_vfs_select, ) from .zigbee_zephyr import ( zephyr_binary_sensor, @@ -33,11 +50,11 @@ from .zigbee_zephyr import ( _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@tomaszduda23"] +CODEOWNERS = ["@luar123", "@tomaszduda23"] def zigbee_set_core_data(config: ConfigType) -> ConfigType: - if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: + if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: zephyr_add_pm_static( [Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")] ) @@ -45,7 +62,15 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType: return config -BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) +BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_REPORT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.enum(REPORT, lower=True), + ) + } +).extend(zephyr_binary_sensor) SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) @@ -54,16 +79,27 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), - cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True), - cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All( + cv.Optional(CONF_MODEL, default=CORE.name): cv.All( + cv.string, cv.Length(max=31) + ), + cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( + cv.requires_component("esp32"), + cv.boolean, + ), + cv.Optional(CONF_ON_JOIN): cv.All( + cv.requires_component("nrf52"), + automation.validate_automation(single=True), + ), + cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, cv.one_of(*["once"], lower=True), ), cv.requires_component("nrf52"), ), - cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum( - POWER_SOURCE, upper=True + cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All( + cv.enum(POWER_SOURCE, upper=True), + cv.requires_component("nrf52"), ), cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All( cv.Any( @@ -72,14 +108,32 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), + cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), + zigbee_require_vfs_select, zigbee_set_core_data, - cv.only_with_framework("zephyr"), + cv.Any( + cv.All( + cv.only_on_esp32, + only_on_variant( + supported=[ + VARIANT_ESP32H2, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + ] + ), + ), + cv.only_with_framework("zephyr"), + ), ) -def validate_number_of_ep(config: ConfigType) -> None: +def validate_number_of_ep(config: ConfigType) -> ConfigType: + if not CORE.is_nrf52: + return config if KEY_ZIGBEE not in CORE.data: raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) @@ -90,9 +144,12 @@ def validate_number_of_ep(config: ConfigType) -> None: if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + return config + FINAL_VALIDATE_SCHEMA = cv.All( validate_number_of_ep, + final_validate_esp32, ) @@ -103,6 +160,10 @@ async def to_code(config: ConfigType) -> None: from .zigbee_zephyr import zephyr_to_code await zephyr_to_code(config) + if CORE.is_esp32: + from .zigbee_esp32 import esp32_to_code + + await esp32_to_code(config) async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: @@ -148,7 +209,7 @@ async def setup_number( def consume_endpoint(config: ConfigType) -> ConfigType: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if not config.get(CONF_ZIGBEE_ID): return config if CONF_NAME in config and " " in config[CONF_NAME]: _LOGGER.warning( @@ -163,18 +224,34 @@ def consume_endpoint(config: ConfigType) -> ConfigType: def validate_binary_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return validate_binary_sensor_esp32(config) return consume_endpoint(config) def validate_sensor(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_switch(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) def validate_number(config: ConfigType) -> ConfigType: + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + return config + if CORE.is_esp32: + return config return consume_endpoint(config) diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 1822e6a02..55ee9746e 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -1,6 +1,9 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_ZIGBEE +#ifdef USE_ESP32 +#include "zigbee_esp32.h" +#endif #ifdef USE_NRF52 #include "zigbee_zephyr.h" #endif diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py new file mode 100644 index 000000000..26ae2cc0e --- /dev/null +++ b/esphome/components/zigbee/const.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg + +zigbee_ns = cg.esphome_ns.namespace("zigbee") +ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) +ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component) +BinaryAttrs = zigbee_ns.struct("BinaryAttrs") +AnalogAttrs = zigbee_ns.struct("AnalogAttrs") +AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") + +report = zigbee_ns.enum("ZigbeeReportT") +REPORT = { + "coordinator": report.ZIGBEE_REPORT_COORDINATOR, + "enable": report.ZIGBEE_REPORT_ENABLE, + "force": report.ZIGBEE_REPORT_FORCE, +} + +CONF_ON_JOIN = "on_join" +CONF_WIPE_ON_BOOT = "wipe_on_boot" +CONF_REPORT = "report" +CONF_ROUTER = "router" +CONF_POWER_SOURCE = "power_source" +POWER_SOURCE = { + "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", + "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", + "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", + "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", + "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", + "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", + "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", +} + +KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py new file mode 100644 index 000000000..682638439 --- /dev/null +++ b/esphome/components/zigbee/const_esp32.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg + +DEVICE_TYPE = "device_type" +ROLE = "role" +CONF_MAX_EP_NUMBER = 239 +CONF_NUM = "num" +CONF_CLUSTERS = "clusters" +CONF_ATTRIBUTES = "attributes" +CONF_ENDPOINT = "endpoint" +CONF_CLUSTER = "cluster" +SCALE = "scale" +CONF_ATTRIBUTE_ID = "attribute_id" +KEY_BS_EP = "binary_sensor_ep" + +ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") +DEVICE_ID = { + "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, + "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, + "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, +} +cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +CLUSTER_ID = { + "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, +} +cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") +CLUSTER_ROLE = { + "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, +} +attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +ATTR_TYPE = { + "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, + "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, + "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, +} diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 2d233755a..63d03c795 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,33 +1,13 @@ -import esphome.codegen as cg - -zigbee_ns = cg.esphome_ns.namespace("zigbee") -ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) -BinaryAttrs = zigbee_ns.struct("BinaryAttrs") -AnalogAttrs = zigbee_ns.struct("AnalogAttrs") -AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") - CONF_MAX_EP_NUMBER = 8 CONF_ZIGBEE_ID = "zigbee_id" -CONF_ON_JOIN = "on_join" -CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_POWER_SOURCE = "power_source" -POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", -} +CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage -KEY_ZIGBEE = "zigbee" KEY_EP_NUMBER = "ep_number" # External ZBOSS SDK types (just strings for codegen) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 82f94c837..3acab0076 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -6,7 +6,8 @@ from esphome.core import CORE from esphome.types import ConfigType from .. import consume_endpoint -from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns +from ..const import zigbee_ns +from ..const_zephyr import CONF_ZIGBEE_ID from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp new file mode 100644 index 000000000..4d7360017 --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -0,0 +1,89 @@ +#include "zigbee_attribute_esp32.h" +#include "esphome/core/log.h" +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.attribute"; + +void ZigbeeAttribute::set_attr_() { + if (!this->zb_->is_connected()) { + return; + } + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, + this->attr_id_, this->value_p_, false); + if (this->force_report_) { + this->report_(true); + } + this->set_attr_requested_ = false; + // Check for error + if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); + } + esp_zb_lock_release(); + } +} + +void ZigbeeAttribute::report_(bool has_lock) { + if (!this->zb_->is_connected()) { + return; + } + if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + esp_zb_zcl_report_attr_cmd_t cmd = { + .address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT, + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI, + }; + cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; + cmd.zcl_basic_cmd.dst_endpoint = 1; + cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; + cmd.clusterID = this->cluster_id_; + cmd.attributeID = this->attr_id_; + + esp_zb_zcl_report_attr_cmd_req(&cmd); + if (!has_lock) { + esp_zb_lock_release(); + } + } +} + +esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { + esp_zb_zcl_reporting_info_t reporting_info = { + .direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV, + .ep = this->endpoint_id_, + .cluster_id = this->cluster_id_, + .cluster_role = this->role_, + .attr_id = this->attr_id_, + .manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC, + }; + reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; + reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ + reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ + reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ + reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ + reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ + + return reporting_info; +} + +void ZigbeeAttribute::set_report(bool force) { + this->report_enabled = true; + this->force_report_ = force; +} + +void ZigbeeAttribute::loop() { + if (this->set_attr_requested_) { + this->set_attr_(); + } + + if (!this->set_attr_requested_) { + this->disable_loop(); + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h new file mode 100644 index 000000000..5a0cfc4fb --- /dev/null +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "esp_zigbee_core.h" +#include "zigbee_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +enum ZigbeeReportT { + ZIGBEE_REPORT_COORDINATOR, + ZIGBEE_REPORT_ENABLE, + ZIGBEE_REPORT_FORCE, +}; + +class ZigbeeAttribute : public Component { + public: + ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t attr_type, float scale, uint8_t max_size) + : zb_(parent), + endpoint_id_(endpoint_id), + cluster_id_(cluster_id), + role_(role), + attr_id_(attr_id), + attr_type_(attr_type), + scale_(scale), + max_size_(max_size) {} + void loop() override; + template void add_attr(T value); + esp_zb_zcl_reporting_info_t get_reporting_info(); + template void set_attr(const T &value); + uint8_t attr_type() { return attr_type_; } + void set_report(bool force); +#ifdef USE_BINARY_SENSOR + template void connect(binary_sensor::BinarySensor *sensor); +#endif + bool report_enabled = false; + + protected: + void set_attr_(); + void report_(bool has_lock); + ZigbeeComponent *zb_; + uint8_t endpoint_id_; + uint16_t cluster_id_; + uint8_t role_; + uint16_t attr_id_; + uint8_t attr_type_; + uint8_t max_size_; + float scale_; + void *value_p_{nullptr}; + bool set_attr_requested_{false}; + bool force_report_{false}; +}; + +template void ZigbeeAttribute::add_attr(T value) { + // Attribute type does never change and add_attr is only called once during startup, so this is safe. + // For now we need to support only simple numeric/bool types for (binary) sensors. + // For strings and arrays we would need to allocate a buffer of the maximum size. + this->value_p_ = (void *) (new T); + this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_, + std::move(value)); +} + +template void ZigbeeAttribute::set_attr(const T &value) { + *static_cast(this->value_p_) = value; + this->set_attr_requested_ = true; + this->enable_loop(); +} + +#ifdef USE_BINARY_SENSOR +template void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) { + sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py new file mode 100644 index 000000000..791232d46 --- /dev/null +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -0,0 +1,70 @@ +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE + +from .const import CONF_REPORT, REPORT +from .const_esp32 import ( + CLUSTER_ROLE, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_MAX_EP_NUMBER, + CONF_NUM, + DEVICE_TYPE, + ROLE, +) + +# endpoint configs: +ep_configs: dict[str, dict[str, Any]] = { + "binary_input": { + DEVICE_TYPE: "SIMPLE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_INPUT", + ROLE: CLUSTER_ROLE["SERVER"], + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "BOOL", + CONF_REPORT: REPORT["enable"], + CONF_DEVICE: None, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "8BITMAP", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "CHAR_STRING", + }, + ], + }, + ], + }, +} + + +def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: + # create dummy endpoint if list is empty + if not ep_list: + ep_type = "CUSTOM_ATTR" + if router: + ep_type = "RANGE_EXTENDER" + ep_list = [ + { + DEVICE_TYPE: ep_type, + } + ] + # enumerate endpoints + for i, ep in enumerate(ep_list, 1): + ep[CONF_NUM] = i + if len(ep_list) > CONF_MAX_EP_NUMBER: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) + return ep_list diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp new file mode 100644 index 000000000..c16736236 --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -0,0 +1,313 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_check.h" +#include "nvs_flash.h" +#include "zigbee_attribute_esp32.h" +#include "zigbee_esp32.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include "zigbee_helpers_esp32.h" +#ifdef USE_WIFI +#include "esp_coexist.h" +#endif + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee"; + +static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { + uint8_t str_len = static_cast(strlen(str)); + uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len); + uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet + zcl_str[0] = zcl_str_size; + + // Initialize payload to avoid leaking uninitialized heap contents and clamp copy length + memset(zcl_str + 1, 0, zcl_str_size); + uint8_t copy_len = std::min(zcl_str_size, str_len); + if (copy_len > 0) { + memcpy(zcl_str + 1, str, copy_len); + } + return zcl_str; +} + +static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { + if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { + ESP_LOGE(TAG, "Start network steering failed!"); + } +} + +void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { + static uint8_t steering_retry_count = 0; + uint32_t *p_sg_p = signal_struct->p_app_signal; + esp_err_t err_status = signal_struct->esp_err_status; + esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; + esp_zb_zdo_signal_leave_params_t *leave_params = NULL; + switch (sig_type) { + case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ESP_LOGD(TAG, "Zigbee stack initialized"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + break; + case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: + case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: + if (err_status == ESP_OK) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + global_zigbee->started = true; + if (esp_zb_bdb_is_factory_new()) { + ESP_LOGD(TAG, "Start network steering"); + esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + } else { + ESP_LOGD(TAG, "Device rebooted"); + global_zigbee->connected = true; + } + } else { + ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", + esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); + ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, + 1000); + } + break; + case ESP_ZB_BDB_SIGNAL_STEERING: + if (err_status == ESP_OK) { + steering_retry_count = 0; + ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), + esp_zb_get_current_channel()); + global_zigbee->connected = true; + } else { + ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + if (steering_retry_count < 10) { + steering_retry_count++; + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + } else { + esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, + ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + } + } + break; + case ESP_ZB_ZDO_SIGNAL_LEAVE: + leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); + if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { + esp_zb_factory_reset(); + } + break; + default: + ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, + esp_err_to_name(err_status)); + break; + } +} + +static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { + esp_err_t ret = ESP_OK; + ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, + "Received message: error status(%d)", message->info.status); + ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", + message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); + return ret; +} + +static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { + esp_err_t ret = ESP_OK; + switch (callback_id) { + case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: + ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + break; + default: + ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + break; + } + return ret; +} + +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { + esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); + this->endpoint_list_[endpoint_id] = + std::tuple(device_id, cluster_list); + // Add basic cluster + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + // Add identify cluster if not already present + if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == + nullptr) { + this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); + } +} + +void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { + esp_zb_attribute_list_t *attr_list; + if (cluster_id == 0) { + attr_list = create_basic_cluster_(); + } else { + attr_list = esphome_zb_default_attr_list_create(cluster_id); + } + this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; +} + +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) { + char date_buf[16]; + time_t time_val = App.get_build_time(); + struct tm *timeinfo = localtime(&time_val); + strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo); + this->basic_cluster_data_ = { + .model = get_zcl_string(model, 31), + .manufacturer = get_zcl_string(manufacturer, 31), + .date = get_zcl_string(date_buf, 15), + }; +} + +esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { + esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { + .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = 0, + }; + esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); + esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + return attr_list; +} + +esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list) { + esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, + .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0}; + return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +} + +static void esp_zb_task_(void *pvParameters) { + if (esp_zb_start(false) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + vTaskDelete(NULL); + } + esp_zb_set_node_descriptor_power_source(1); + esp_zb_stack_main_loop(); +} + +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zb_platform_config_t config = { + .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(), + .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(), + }; +#ifdef USE_WIFI + if (esp_coex_wifi_i154_enable() != ESP_OK) { + this->mark_failed(); + return; + } +#endif + if (esp_zb_platform_config(&config) != ESP_OK) { + this->mark_failed(); + return; + } + + esp_zb_zed_cfg_t zb_zed_cfg = { + .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + esp_zb_zczr_cfg_t zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + esp_zb_cfg_t zb_nwk_cfg = { + .esp_zb_role = this->device_role_, + .install_code_policy = false, + }; +#ifdef ZB_ROUTER_ROLE + zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; +#else + zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; +#endif + esp_zb_init(&zb_nwk_cfg); + + esp_err_t ret; + for (auto const &[key, val] : this->attribute_list_) { + esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); + ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), + esp_err_to_name(ret)); + } else { + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), + std::get<2>(key)); +#ifdef ESPHOME_LOG_HAS_VERBOSE + // Dump cluster attributes in verbose log + ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); + esp_zb_attribute_list_t *attr_list = val; + while (attr_list) { + esp_zb_zcl_attr_t *attr = &attr_list->attribute; + ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); + attr_list = attr_list->next; + } +#endif + } + } + this->attribute_list_.clear(); + + for (auto const &[ep_id, dev_id] : this->endpoint_list_) { + if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { + ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); + } + } + this->endpoint_list_.clear(); + + if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return; + } + + esp_zb_core_action_handler_register(zb_action_handler); + + if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + this->mark_failed(); + return; + } + for (auto &[_, attribute] : this->attributes_) { + if (attribute->report_enabled) { + esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); + ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); + if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { + ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", + reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); + } + } + } + xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); +} + +void ZigbeeComponent::dump_config() { + if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n" + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), + YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), + esp_zb_get_pan_id()); + esp_zb_lock_release(); + } else { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %s\n" + " Router: %s\n", + this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + } +} +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h new file mode 100644 index 000000000..80ecbfd63 --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -0,0 +1,134 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include +#include +#include + +#include "esp_zigbee_core.h" +#include "zboss_api.h" +#include "ha/esp_zigbee_ha_standard.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "zigbee_helpers_esp32.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::zigbee { + +/* Zigbee configuration */ +static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ +static const uint8_t MAX_CHILDREN = 10; + +#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ZB_RADIO_MODE_NATIVE, } + +#define ESP_ZB_DEFAULT_HOST_CONFIG() \ + { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } + +uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); + +class ZigbeeAttribute; + +class ZigbeeComponent : public Component { + public: + void setup() override; + void dump_config() override; + esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, + esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer); + void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); + void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + + template + void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value); + + template + void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + + void factory_reset() { + esp_zb_lock_acquire(portMAX_DELAY); + esp_zb_factory_reset(); // triggers a reboot + esp_zb_lock_release(); + } + + bool is_started() { return this->started; } + bool is_connected() { return this->connected; } + std::atomic connected = false; + std::atomic started = false; + + protected: + struct { + uint8_t *model; + uint8_t *manufacturer; + uint8_t *date; + } basic_cluster_data_; +#ifdef ZB_ED_ROLE + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#else + esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; +#endif + esp_zb_attribute_list_t *create_basic_cluster_(); + template + void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + T *value_p); + // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards + // value tuple could be replaced by struct + std::map> endpoint_list_; + // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role + std::map, esp_zb_attribute_list_t *> attribute_list_; + // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger + // automations + // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id + std::map, ZigbeeAttribute *> attributes_; + esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); +}; + +extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct); + +template +void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, + uint8_t max_size, T value) { + this->add_attr(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value); +} + +template +void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, uint8_t max_size, T value) { + // The size byte of the zcl_str must be set to the maximum value, + // even though the initial string may be shorter. + if constexpr (std::is_same::value) { + auto zcl_str = get_zcl_string(value.c_str(), max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else if constexpr (std::is_convertible::value) { + auto zcl_str = get_zcl_string(value, max_size, true); + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); + delete[] zcl_str; + } else { + add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value); + } +} + +template +void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, + uint16_t attr_id, T *value_p) { + esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; + esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } +} + +} // namespace esphome::zigbee + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py new file mode 100644 index 000000000..1b98df6c0 --- /dev/null +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -0,0 +1,274 @@ +import copy +import logging +import re +from typing import Any + +import esphome.codegen as cg +from esphome.components.esp32 import ( + CONF_PARTITIONS, + add_idf_component, + add_idf_sdkconfig_option, + add_partition, + require_vfs_select, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_DEVICE, + CONF_ID, + CONF_MAX_LENGTH, + CONF_MODEL, + CONF_NAME, + CONF_TYPE, + CONF_VALUE, + CONF_WIFI, +) +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute +from .const_esp32 import ( + ATTR_TYPE, + CLUSTER_ID, + CONF_ATTRIBUTE_ID, + CONF_ATTRIBUTES, + CONF_CLUSTERS, + CONF_NUM, + DEVICE_ID, + DEVICE_TYPE, + KEY_BS_EP, + ROLE, + SCALE, +) +from .zigbee_ep_esp32 import create_ep, ep_configs + +_LOGGER = logging.getLogger(__name__) + + +def get_c_size(bits: str, options: list[int]) -> str: + return str([n for n in options if n >= int(bits)][0]) + + +def get_c_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cg.bool_ + if "STRING" in attr_type: + return cg.std_string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) + return None + + +def get_cv_by_type(attr_type: str) -> Any | None: + if attr_type == "BOOL": + return cv.boolean + if "STRING" in attr_type: + return cv.string + test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + if test and test.group(2): + return cv.positive_int + return None + + +def get_default_by_type(attr_type: str) -> str | bool | int: + if attr_type == "CHAR_STRING": + return "" + if attr_type == "BOOL": + return False + return 0 + + +def validate_attributes(config: ConfigType) -> ConfigType: + if CONF_VALUE not in config: + config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE]) + config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE]) + + return config + + +def final_validate_esp32(config: ConfigType) -> ConfigType: + if not CORE.is_esp32: + return config + if CONF_WIFI in fv.full_config.get(): + if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]: + raise cv.Invalid( + "Only Zigbee End Device can be used together with a Wifi Access Point." + ) + if CONF_AP in fv.full_config.get()[CONF_WIFI]: + _LOGGER.warning( + "Wifi Access Point might be unstable while Zigbee is active, use only as fallback." + ) + elif config[CONF_ROUTER]: + _LOGGER.warning( + "The Zigbee Router might miss packets while Wifi is active and could destabilize " + "your network. Use only if Wifi is off most of the time." + ) + if CONF_PARTITIONS in fv.full_config.get() and not isinstance( + fv.full_config.get()[CONF_PARTITIONS], list + ): + with open( + CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]), + encoding="utf8", + ) as f: + partitions_tab = f.read() + for partition, types in [ + ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), + ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), + ]: + if partition not in partitions_tab: + raise cv.Invalid( + f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table." + ) + if not re.search( + rf"^{partition},\s*{types['type']},\s*{types['subtype']}", + partitions_tab, + re.MULTILINE, + ): + raise cv.Invalid( + f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" + ) + return config + + +def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: + ep = copy.deepcopy(ep_configs["binary_input"]) + for cl in ep.get(CONF_CLUSTERS, []): + for attr in cl[CONF_ATTRIBUTES]: + if ( + attr[CONF_ATTRIBUTE_ID] == 0x1C + and CONF_VALUE not in attr + and CONF_NAME in config + ): # set name + name = ( + config[CONF_NAME].encode("ascii", "ignore").decode() + ) # or use unidecode + attr[CONF_VALUE] = str(name) + attr[CONF_MAX_LENGTH] = len(str(name)) + if CONF_DEVICE in attr: # connect device + attr[CONF_DEVICE] = config[CONF_ID] + if CONF_REPORT in config: + attr[CONF_REPORT] = config[CONF_REPORT] + attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) + if "zb_attr_ids" not in config: + config["zb_attr_ids"] = [] + config["zb_attr_ids"].append(attr[CONF_ID]) + else: + attr[CONF_ID] = None + validate_attributes(attr) + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) + binary_sensor_ep.append(ep) + return config + + +def zigbee_require_vfs_select(config: ConfigType) -> ConfigType: + """Register VFS select requirement during config validation.""" + # Zigbee uses esp_vfs_eventfd which requires VFS select support + if CORE.is_esp32: + require_vfs_select() + return config + + +@coroutine_with_priority(CoroPriority.WORKAROUNDS) +async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: + """Add sdkconfigs late so they can overwrite esp32 defaults""" + add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True) + if config.get(CONF_ROUTER): + add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) + else: + add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) + add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) + if CONF_WIFI in CORE.config: + add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) + # The pre-built Zigbee library uses esp_log_default_level which requires + # dynamic log level control to be enabled + add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) + + +async def attributes_to_code( + var: cg.Pvariable, ep_num: int, cl: dict[str, Any] +) -> None: + for attr in cl.get(CONF_ATTRIBUTES, []): + if attr.get(CONF_ID) is None: + cg.add( + var.add_attr( + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + attr.get(CONF_MAX_LENGTH, 0), + attr[CONF_VALUE], + ) + ) + continue + attr_var = cg.new_Pvariable( + attr[CONF_ID], + var, + ep_num, + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + attr[CONF_ATTRIBUTE_ID], + ATTR_TYPE[attr[CONF_TYPE]], + attr.get(SCALE, 1), + attr.get(CONF_MAX_LENGTH, 0), + ) + await cg.register_component(attr_var, attr) + + cg.add(attr_var.add_attr(attr[CONF_VALUE])) + if CONF_REPORT in attr and attr[CONF_REPORT] in [ + REPORT["enable"], + REPORT["force"], + ]: + cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + + if CONF_DEVICE in attr: + device = await cg.get_variable(attr[CONF_DEVICE]) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + cg.add(attr_var.connect(template_arg, device)) + + +async def esp32_to_code(config: ConfigType) -> None: + add_idf_component( + name="espressif/esp-zboss-lib", + ref="1.6.4", + ) + add_idf_component( + name="espressif/esp-zigbee-lib", + ref="1.6.8", + ) + + # add sdkconfigs later so they can overwrite esp32 defaults + CORE.add_job(_zigbee_add_sdkconfigs, config) + + # add partitions for zigbee + add_partition("zb_storage", "data", "fat", 0x4000) # 16KB + add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size + + # create endpoints + zb_data = CORE.data.get(KEY_ZIGBEE, {}) + binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) + ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER)) + + # setup zigbee components + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add( + var.set_basic_cluster( + config[CONF_MODEL], + "esphome", + ) + ) + for ep in ep_list: + cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for cl in ep.get(CONF_CLUSTERS, []): + cg.add( + var.add_cluster( + ep[CONF_NUM], + CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), + cl[ROLE], + ) + ) + await attributes_to_code(var, ep[CONF_NUM], cl) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c new file mode 100644 index 000000000..4ba71ec60 --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -0,0 +1,74 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include "ha/esp_zigbee_ha_standard.h" +#include "zigbee_helpers_esp32.h" + +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p) { + esp_err_t ret; + ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); + ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + } + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, + esp_err_to_name(ret)); + } + return ret; +} + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { + esp_err_t ret; + ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); + if (ret != ESP_OK) { + ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); + break; + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); + break; + default: + ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); + } + } + return ret; +} + +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_create(NULL); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_create(NULL); + default: + return esp_zb_zcl_attr_list_create(cluster_id); + } +} + +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p) { + switch (cluster_id) { + case ESP_ZB_ZCL_CLUSTER_ID_BASIC: + return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: + return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); + case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + default: + return ESP_FAIL; + } +} + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h new file mode 100644 index 000000000..0650c1689 --- /dev/null +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_zigbee_core.h" + +esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, + esp_zb_attribute_list_t *attr_list, uint8_t role_mask); +esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); +esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, + void *value_p); +esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, + uint16_t attr_id, void *value_p); + +#ifdef __cplusplus +} +namespace esphome::zigbee {} // namespace esphome::zigbee +#endif + +#endif +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 047c30300..90bb66c91 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -4,6 +4,9 @@ #include #include #include "esphome/core/hal.h" +#ifdef USE_DEEP_SLEEP +#include "esphome/components/deep_sleep/deep_sleep_component.h" +#endif extern "C" { #include @@ -116,6 +119,12 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; +#ifdef USE_DEEP_SLEEP + if (auto *ds = deep_sleep::global_deep_sleep.load()) { + ds->wakeup(); + } +#endif + // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -181,9 +190,11 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } + zigbee_configure_sleepy_behavior(this->sleepy_); zigbee_enable(); } +#ifdef ESPHOME_LOG_HAS_CONFIG static const char *role() { switch (zb_get_network_role()) { case ZB_NWK_DEVICE_TYPE_COORDINATOR: @@ -207,6 +218,7 @@ static const char *get_wipe_on_boot() { return "NO"; #endif } +#endif void ZigbeeComponent::dump_config() { char ieee_addr_buf[IEEE_ADDR_BUF_SIZE] = {0}; @@ -222,6 +234,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" " Sleep threshold: %ums\n" @@ -230,9 +243,9 @@ void ZigbeeComponent::dump_config() { " Short addr: 0x%04X\n" " Long pan id: 0x%s\n" " Short pan id: 0x%04X", - get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, zb_get_current_channel(), - zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), - extended_pan_id_buf, zb_get_pan_id()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()), + zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, + zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); dump_reporting_(); } @@ -302,6 +315,12 @@ void ZigbeeComponent::after_reporting_info(zb_zcl_configure_reporting_req_t *con extern "C" { void zboss_signal_handler(zb_uint8_t param) { esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); } +void zb_osif_serial_put_bytes(const zb_uint8_t *buf, zb_short_t len) { + (void) buf; + (void) len; +} +void zb_osif_serial_flush() {} +void zb_osif_serial_init() {} // NOLINTBEGIN(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern zb_ret_t __real_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req, diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index eeb142eff..0a189ac1e 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,6 +81,7 @@ class ZigbeeComponent : public Component { Trigger<> *get_join_trigger() { return &this->join_trigger_; }; void force_report(); void loop() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } protected: static void zcl_device_cb(zb_bufid_t bufid); @@ -95,6 +96,7 @@ class ZigbeeComponent : public Component { bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + bool sleepy_{}; }; class ZigbeeEntity { @@ -107,5 +109,7 @@ class ZigbeeEntity { ZigbeeComponent *parent_{nullptr}; }; +extern ZigbeeComponent *global_zigbee; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3288d9248..7d904b608 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime import random from esphome import automation @@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, UNIT_AMPERE, @@ -48,19 +49,27 @@ from esphome.cpp_generator import ( ) from esphome.types import ConfigType -from .const_zephyr import ( - CONF_IEEE802154_VENDOR_OUI, +from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_WIPE_ON_BOOT, + KEY_ZIGBEE, + POWER_SOURCE, + AnalogAttrs, + AnalogAttrsOutput, + BinaryAttrs, + ZigbeeComponent, + zigbee_ns, +) +from .const_zephyr import ( + CONF_IEEE802154_VENDOR_OUI, + CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, CONF_ZIGBEE_SENSOR, CONF_ZIGBEE_SWITCH, KEY_EP_NUMBER, - KEY_ZIGBEE, - POWER_SOURCE, ZB_ZCL_BASIC_ATTRS_EXT_T, ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, @@ -69,11 +78,6 @@ from .const_zephyr import ( ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_ID_IDENTIFY, ZB_ZCL_IDENTIFY_ATTRS_T, - AnalogAttrs, - AnalogAttrsOutput, - BinaryAttrs, - ZigbeeComponent, - zigbee_ns, ) ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) @@ -166,6 +170,11 @@ async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) zephyr_add_prj_conf("NET_UDP", False) + # disable all extra to reduce power and save flash + zephyr_add_prj_conf("ZIGBEE_HAVE_SERIAL", False) + zephyr_add_prj_conf("ZBOSS_ERROR_PRINT_TO_LOG", False) + zephyr_add_prj_conf("DK_LIBRARY", False) + cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") if CONF_IEEE802154_VENDOR_OUI in config: @@ -197,6 +206,8 @@ async def zephyr_to_code(config: ConfigType) -> None: CORE.add_job(_ctx_to_code, config) + cg.add(var.set_sleepy(config[CONF_SLEEPY])) + async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list @@ -209,9 +220,9 @@ async def _attr_to_code(config: ConfigType) -> None: zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), zigbee_set_string(basic_attrs.mf_name, "esphome"), - zigbee_set_string(basic_attrs.model_id, CORE.name), + zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( - basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M") + basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S") ), zigbee_assign( basic_attrs.power_source, diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ecb38b25e..8a24bd57d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -101,8 +101,10 @@ void ZWaveProxy::loop() { this->status_clear_warning(); } -void ZWaveProxy::process_uart_() { - while (this->available()) { +void ZWaveProxy::process_uart_slow_() { + // Caller (inline process_uart_) has already confirmed available() > 0, so use do/while to + // drain bytes — available() is still checked at the tail, but not redundantly on entry. + do { uint8_t byte; if (!this->read_byte(&byte)) { this->status_set_warning(LOG_STR("UART read failed")); @@ -137,7 +139,7 @@ void ZWaveProxy::process_uart_() { this->api_connection_->send_message(this->outgoing_proto_msg_); } } - } + } while (this->available()); } void ZWaveProxy::dump_config() { @@ -414,7 +416,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } } -bool ZWaveProxy::response_handler_() { +bool ZWaveProxy::response_handler_slow_() { switch (this->parsing_state_) { case ZWAVE_PARSING_STATE_SEND_ACK: this->last_response_ = ZWAVE_FRAME_TYPE_ACK; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 0b810de29..dc5dc46ab 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -38,6 +38,13 @@ enum ZWaveParsingState : uint8_t { ZWAVE_PARSING_STATE_READ_BL_MENU, }; +// response_handler_()'s inline fast-path relies on SEND_ACK/CAN/NAK being contiguous in this +// enum so a single range check (state - SEND_ACK < 3) is equivalent to three equality checks. +static_assert(ZWAVE_PARSING_STATE_SEND_CAN == ZWAVE_PARSING_STATE_SEND_ACK + 1, + "SEND_CAN must immediately follow SEND_ACK for response_handler_ fast-path"); +static_assert(ZWAVE_PARSING_STATE_SEND_NAK == ZWAVE_PARSING_STATE_SEND_ACK + 2, + "SEND_NAK must immediately follow SEND_CAN for response_handler_ fast-path"); + enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; @@ -72,8 +79,31 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); - bool response_handler_(); - void process_uart_(); // Process all available UART data + // Inline fast-path: most calls happen with parsing_state_ outside the SEND_* range, so skip the + // out-of-line call entirely in the hot path (e.g. every loop() tick) and only pay for the real + // work when a response is actually pending. ESPHOME_ALWAYS_INLINE is required because with -Os + // gcc otherwise clones the wrapper into a shared $isra$ outline and keeps the call8. + ESPHOME_ALWAYS_INLINE bool response_handler_() { + if (this->parsing_state_ < ZWAVE_PARSING_STATE_SEND_ACK || this->parsing_state_ > ZWAVE_PARSING_STATE_SEND_NAK) { + return false; + } + return this->response_handler_slow_(); + } + bool response_handler_slow_(); + // Inline fast-path: UART::available() is cheap (ring-buffer head/tail compare on most backends). + // On an idle loop tick we want to skip the call to process_uart_ entirely. When bytes are + // pending we fall into the slow path, which drains the UART with a do/while so available() is + // only checked once per byte — no redundant re-check on entry. + ESPHOME_ALWAYS_INLINE void process_uart_() { + if (!this->available()) { + return; + } + this->process_uart_slow_(); + } + // Precondition: caller must guarantee available() > 0 before invoking (see inline + // process_uart_ above). The slow path uses do/while and would otherwise set a spurious UART + // warning on entry if called with no bytes pending. + void process_uart_slow_(); // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 31cfb41a6..fbafc5cb0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -544,8 +544,9 @@ def int_(value): try: return int(value, base) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"Expected integer, but cannot parse {value} as an integer") + raise Invalid( + f"Expected integer, but cannot parse {value} as an integer" + ) from None def int_range(min=None, max=None, min_included=True, max_included=True): @@ -844,8 +845,7 @@ def time_period_str_colon(value): try: parsed = [int(x) for x in value.split(":")] except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(TIME_PERIOD_ERROR.format(value)) + raise Invalid(TIME_PERIOD_ERROR.format(value)) from None if len(parsed) == 2: hour, minute = parsed @@ -943,7 +943,26 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) - return positive_time_period_milliseconds(value) + result = positive_time_period_milliseconds(value) + # 0ms was historically (mis)used as a pseudo-loop() mechanism for + # PollingComponents. Under the hood it calls set_interval(0), which + # causes Scheduler::call() to spin (WDT reset in the field). Coerce + # to 1ms so existing configs keep working at ~1kHz instead of + # spinning. Don't hard-fail so configs don't break on upgrade; + # authors should migrate to HighFrequencyLoopRequester (C++) for + # true run-every-loop behaviour. + if result.total_milliseconds == 0: + _LOGGER.warning( + "update_interval of 0ms is not supported - coercing to 1ms. " + "A literal 0ms schedule would spin the main loop (the scheduled " + "item would always be due, so the scheduler would never yield " + "back) and trigger a watchdog reset. Set update_interval to a " + "non-zero value such as 1ms or higher. (Custom C++ components " + "that need true run-every-loop behaviour should override loop() " + "with HighFrequencyLoopRequester instead.)" + ) + return TimePeriodMilliseconds(milliseconds=1) + return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) @@ -1047,8 +1066,7 @@ def date_time(date: bool, time: bool): try: date_obj = datetime.strptime(value, format) except ValueError as err: - # pylint: disable=raise-missing-from - raise Invalid(f"Invalid {exc_message}: {err}") + raise Invalid(f"Invalid {exc_message}: {err}") from err return_value = {} if date: @@ -1078,8 +1096,9 @@ def mac_address(value): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("MAC Address parts must be hexadecimal values from 00 to FF") + raise Invalid( + "MAC Address parts must be hexadecimal values from 00 to FF" + ) from None return core.MACAddress(*parts_int) @@ -1096,8 +1115,7 @@ def bind_key(value, *, name="Bind key"): try: parts_int.append(int(part, 16)) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid(f"{name} must be hex values from 00 to FF") + raise Invalid(f"{name} must be hex values from 00 to FF") from None return "".join(f"{part:02X}" for part in parts_int) @@ -1425,8 +1443,7 @@ def mqtt_qos(value): try: value = int(value) except (TypeError, ValueError): - # pylint: disable=raise-missing-from - raise Invalid(f"MQTT Quality of Service must be integer, got {value}") + raise Invalid(f"MQTT Quality of Service must be integer, got {value}") from None return one_of(0, 1, 2)(value) @@ -1518,8 +1535,7 @@ def _parse_percentage(value: object) -> float: else: value = float(value) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("invalid number") + raise Invalid("invalid number") from None try: if not has_percent_sign and (value > 1 or value < -1): raise Invalid( @@ -1527,9 +1543,7 @@ def _parse_percentage(value: object) -> float: "outside -1.0 to 1.0. Please put a percent sign after the number!" ) except TypeError: - raise Invalid( # pylint: disable=raise-missing-from - "Expected percentage or float" - ) + raise Invalid("Expected percentage or float") from None return float(value) @@ -1702,8 +1716,7 @@ def dimensions(value): try: width, height = int(value[0]), int(value[1]) except ValueError: - # pylint: disable=raise-missing-from - raise Invalid("Width and height dimensions must be integers") + raise Invalid("Width and height dimensions must be integers") from None if width <= 0 or height <= 0: raise Invalid("Width and height must at least be 1") return [width, height] diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp new file mode 100644 index 000000000..11c7abe3f --- /dev/null +++ b/esphome/core/alloc_helpers.cpp @@ -0,0 +1,229 @@ +#include "esphome/core/alloc_helpers.h" + +#include "esphome/core/helpers.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers --- + +std::string str_truncate(const std::string &str, size_t length) { + return str.length() > length ? str.substr(0, length) : str; +} + +std::string str_until(const char *str, char ch) { + const char *pos = strchr(str, ch); + return pos == nullptr ? std::string(str) : std::string(str, pos - str); +} +std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } + +// wrapper around std::transform to run safely on functions from the ctype.h header +// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes +template std::string str_ctype_transform(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); + return result; +} +std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } + +std::string str_upper_case(const std::string &str) { + std::string result; + result.resize(str.length()); + std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return std::toupper(ch); }); + return result; +} + +std::string str_snake_case(const std::string &str) { + std::string result = str; + for (char &c : result) { + c = to_snake_case_char(c); + } + return result; +} + +std::string str_sanitize(const std::string &str) { + std::string result; + result.resize(str.size()); + str_sanitize_to(&result[0], str.size() + 1, str.c_str()); + return result; +} + +std::string str_snprintf(const char *fmt, size_t len, ...) { + std::string str; + va_list args; + + str.resize(len); + va_start(args, len); + size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); + va_end(args); + + if (out_length < len) + str.resize(out_length); + + return str; +} + +std::string str_sprintf(const char *fmt, ...) { + std::string str; + va_list args; + + va_start(args, fmt); + size_t length = vsnprintf(nullptr, 0, fmt, args); + va_end(args); + + str.resize(length); + va_start(args, fmt); + vsnprintf(&str[0], length + 1, fmt, args); + va_end(args); + + return str; +} + +// --- Value formatting helpers --- + +std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + value_accuracy_to_buf(buf, value, accuracy_decimals); + return std::string(buf); +} + +// --- Base64 helpers --- + +static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + +// Encode 3 input bytes to 4 base64 characters, append 'count' to ret. +static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { + char char_array_4[4]; + char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; + char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); + char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); + char_array_4[3] = char_array_3[2] & 0x3f; + + for (int j = 0; j < count; j++) + ret += BASE64_CHARS[static_cast(char_array_4[j])]; +} + +std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } + +std::string base64_encode(const uint8_t *buf, size_t buf_len) { + std::string ret; + int i = 0; + char char_array_3[3]; + + while (buf_len--) { + char_array_3[i++] = *(buf++); + if (i == 3) { + base64_encode_triple(char_array_3, 4, ret); + i = 0; + } + } + + if (i) { + for (int j = i; j < 3; j++) + char_array_3[j] = '\0'; + + base64_encode_triple(char_array_3, i + 1, ret); + + while ((i++ < 3)) + ret += '='; + } + + return ret; +} + +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; +} + +// --- Hex/binary formatting helpers --- + +std::string format_mac_address_pretty(const uint8_t *mac) { + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); +} + +std::string format_hex(const uint8_t *data, size_t length) { + std::string ret; + ret.resize(length * 2); + format_hex_to(&ret[0], length * 2 + 1, data, length); + return ret; +} + +std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } + +// Shared implementation for uint8_t and string hex pretty formatting +static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 3 - 1) : (length * 2); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} + +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { + return format_hex_pretty_uint8(data, length, separator, show_length); +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} + +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) + return ""; + std::string ret; + size_t hex_len = separator ? (length * 5 - 1) : (length * 4); + ret.resize(hex_len); + format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; + return ret; +} +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} +std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { + return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); +} + +std::string format_bin(const uint8_t *data, size_t length) { + std::string result; + result.resize(length * 8); + format_bin_to(&result[0], length * 8 + 1, data, length); + return result; +} + +// --- MAC address helpers --- + +std::string get_mac_address() { + uint8_t mac[6]; + get_mac_address_raw(mac); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); +} + +std::string get_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(get_mac_address_pretty_into_buffer(buf)); +} + +} // namespace esphome diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h new file mode 100644 index 000000000..fe350886b --- /dev/null +++ b/esphome/core/alloc_helpers.h @@ -0,0 +1,128 @@ +#pragma once + +/// @file alloc_helpers.h +/// @brief Heap-allocating helper functions. +/// +/// These functions return std::string and allocate heap memory on every call. +/// On long-running embedded devices, repeated heap allocations fragment memory +/// over time, eventually causing crashes even with free memory available. +/// +/// Prefer the stack-based alternatives documented on each function instead. +/// New code should avoid using these functions. + +#include +#include +#include +#include +#include + +namespace esphome { + +// --- String helpers (allocating) --- + +/// Truncate a string to a specific length. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_truncate(const std::string &str, size_t length); + +/// Extract the part of the string until either the first occurrence of the specified character, or the end +/// (requires str to be null-terminated). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const char *str, char ch); +/// Extract the part of the string until either the first occurrence of the specified character, or the end. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_until(const std::string &str, char ch); + +/// Convert the string to lower case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_lower_case(const std::string &str); + +/// Convert the string to upper case. +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_upper_case(const std::string &str); + +/// Convert the string to snake case (lowercase with underscores). +/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. +std::string str_snake_case(const std::string &str); + +/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. +/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. +std::string str_sanitize(const std::string &str); + +/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); + +/// sprintf-like function returning std::string. +/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. +std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); + +// --- Hex/binary formatting helpers (allocating) --- + +/// Format the six-byte array \p mac into a MAC address string. +/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. +std::string format_mac_address_pretty(const uint8_t mac[6]); + +/// Format the byte array \p data of length \p len in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const uint8_t *data, size_t length); + +/// Format the vector \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +std::string format_hex(const std::vector &data); + +/// Format a byte array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a 16-bit word array in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); + +/// Format a byte vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a 16-bit word vector in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/// Format a string's bytes in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); + +/// Format the byte array \p data of length \p len in binary. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +std::string format_bin(const uint8_t *data, size_t length); + +// --- Value formatting helpers (allocating) --- + +/// Format a float value with accuracy decimals to a string. +/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. +__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) +std::string +value_accuracy_to_string(float value, int8_t accuracy_decimals); + +// --- Base64 helpers (allocating) --- + +/// Encode a byte buffer to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const uint8_t *buf, size_t buf_len); +/// Encode a byte vector to base64 string. +/// @warning Allocates heap memory. +std::string base64_encode(const std::vector &buf); + +/// Decode a base64 string to a byte vector. +/// @warning Allocates heap memory. Use base64_decode(data, len, buf, buf_len) with a pre-allocated buffer instead. +std::vector base64_decode(const std::string &encoded_string); + +// --- MAC address helpers (allocating) --- + +/// Get the device MAC address as a string, in lowercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_into_buffer() instead. +std::string get_mac_address(); + +/// Get the device MAC address as a string, in colon-separated uppercase hex notation. +/// @warning Allocates heap memory. Use get_mac_address_pretty_into_buffer() instead. +std::string get_mac_address_pretty(); + +} // namespace esphome diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd7585988..d03696fbb 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,9 +12,6 @@ #include #include #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif // USE_LWIP_FAST_SELECT #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -24,14 +21,6 @@ #include "esphome/components/status_led/status_led.h" #endif -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) -#include "esphome/components/socket/socket.h" -#endif - -#ifdef USE_HOST -#include -#endif - namespace esphome { static const char *const TAG = "app"; @@ -78,35 +67,49 @@ void Application::setup() { Component *component = this->components_[i]; // Update loop_component_start_time_ before calling each component during setup - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); component->call(); this->scheduler.process_to_add(); this->feed_wdt(); if (component->can_proceed()) continue; + // Force the status LED to blink WARNING while we wait for a slow + // component to come up. Cleared after setup() finishes if no real + // component has warning set. + this->app_state_ |= STATUS_LED_WARNING; + do { - uint8_t new_app_state = STATUS_LED_WARNING; - uint32_t now = millis(); + // Service scheduler and process pending loop enables to handle GPIO + // interrupts during setup. During setup we always run the component + // phase (no loop_interval_ gate), so call both helpers unconditionally. + this->scheduler_tick_(MillisInternal::get()); + { + ComponentPhaseGuard phase_guard{*this}; - // Process pending loop enables to handle GPIO interrupts during setup - this->before_loop_tasks_(now); - - for (uint32_t j = 0; j <= i; j++) { - // Update loop_component_start_time_ right before calling each component - this->loop_component_start_time_ = millis(); - this->components_[j]->call(); - new_app_state |= this->components_[j]->get_component_state(); - this->app_state_ |= new_app_state; - this->feed_wdt(); + for (uint32_t j = 0; j <= i; j++) { + // Update loop_component_start_time_ right before calling each component + this->loop_component_start_time_ = MillisInternal::get(); + this->components_[j]->call(); + this->feed_wdt(); + } } - - this->after_loop_tasks_(); - this->app_state_ = new_app_state; yield(); } while (!component->can_proceed() && !component->is_failed()); } + // Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path + // above may have forced it on, and any status_clear_warning() calls + // from components during setup were intentional no-ops (gated by + // APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the + // real state. STATUS_LED_ERROR is never artificially forced, so its + // clear path always works and needs no reconciliation. Finally, set + // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through + // the normal walk-and-clear path. + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + this->app_state_ &= ~STATUS_LED_WARNING; + this->app_state_ |= APP_STATE_SETUP_COMPLETE; + ESP_LOGI(TAG, "setup() finished successfully!"); #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -119,8 +122,8 @@ void Application::setup() { esphome_main_task_handle = xTaskGetCurrentTaskHandle(); #endif #ifdef USE_HOST - // Set up wake socket for waking main loop from tasks (platforms without fast select only) - this->setup_wake_loop_threadsafe_(); + // Set up wake socket for waking main loop from tasks (host platform select() loop). + wake_setup(); #endif // Ensure all active looping components are in LOOP state. @@ -196,21 +199,60 @@ void Application::process_dump_config_() { this->dump_config_at_++; } -void HOT Application::feed_wdt(uint32_t time) { - static uint32_t last_feed = 0; - // Use provided time if available, otherwise get current time - uint32_t now = time ? time : millis(); - // Compare in milliseconds (3ms threshold) - if (now - last_feed > 3) { - arch_feed_wdt(); - last_feed = now; -#ifdef USE_STATUS_LED - if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); - } -#endif - } +void Application::feed_wdt() { + // Cold entry: callers without a millis() timestamp in hand. Fetches the + // time and defers to the hot path. + this->feed_wdt_with_time(MillisInternal::get()); } + +void HOT Application::feed_wdt_slow_(uint32_t time) { + // Callers (both feed_wdt() and feed_wdt_with_time()) have already + // confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded. + arch_feed_wdt(); + this->last_wdt_feed_ = time; +} + +#ifdef USE_STATUS_LED +void HOT Application::service_status_led_slow_(uint32_t time) { + // Callers (feed_wdt(), feed_wdt_with_time()) have already confirmed the + // STATUS_LED_DISPATCH_INTERVAL_MS rate limit was exceeded. Rate-limited + // separately from arch_feed_wdt() so the LED blink pattern stays readable + // (status_led error blink period is 250 ms) while HAL watchdog pokes can + // still run at the much coarser WDT_FEED_INTERVAL_MS cadence. + this->last_status_led_service_ = time; + if (status_led::global_status_led == nullptr) + return; + auto *sl = status_led::global_status_led; + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; + sl->enable_loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; + } + sl->loop(); +} +#endif + +bool Application::any_component_has_status_flag_(uint8_t flag) const { + // Walk all components (not just looping ones) so non-looping components' + // status bits are respected. Only called from the slow-path clear helpers + // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an + // actual set→clear transition, so walking O(N) here is paid once per + // transition — not once per loop iteration. + for (auto *component : this->components_) { + if ((component->get_component_state() & flag) != 0) + return true; + } + return false; +} + void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { @@ -242,7 +284,7 @@ void Application::run_powerdown_hooks() { } void Application::teardown_components(uint32_t timeout_ms) { - uint32_t start_time = millis(); + uint32_t start_time = MillisInternal::get(); // Use a StaticVector instead of std::vector to avoid heap allocation // since we know the actual size at compile time @@ -299,7 +341,7 @@ void Application::teardown_components(uint32_t timeout_ms) { while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering - this->feed_wdt(now); + this->feed_wdt_with_time(now); // Process components and compact the array, keeping only those still pending size_t still_pending = 0; @@ -317,11 +359,11 @@ void Application::teardown_components(uint32_t timeout_ms) { // Give some time for I/O operations if components are still pending if (pending_count > 0) { - this->yield_with_select_(1); + esphome::internal::wakeable_delay(1); } // Update time for next iteration - now = millis(); + now = MillisInternal::get(); } if (pending_count > 0) { @@ -364,7 +406,7 @@ void Application::disable_component_loop_(Component *component) { // This prevents integer underflow in timing calculations by ensuring // the swapped component starts with a fresh timing reference, avoiding // errors caused by stale or wrapped timing values. - this->loop_component_start_time_ = millis(); + this->loop_component_start_time_ = MillisInternal::get(); } } return; @@ -449,130 +491,6 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} - -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) -bool Application::register_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return false; - - if (fd >= FD_SETSIZE) { - ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); - return false; - } - - this->socket_fds_.push_back(fd); - this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { - this->max_fd_ = fd; - } - - return true; -} - -void Application::unregister_socket_fd(int fd) { - // WARNING: This function is NOT thread-safe and must only be called from the main loop - // It modifies socket_fds_ and related variables without locking - if (fd < 0) - return; - - for (size_t i = 0; i < this->socket_fds_.size(); i++) { - if (this->socket_fds_[i] != fd) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - if (i < this->socket_fds_.size() - 1) - this->socket_fds_[i] = this->socket_fds_.back(); - this->socket_fds_.pop_back(); - this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max - if (fd == this->max_fd_) { - this->max_fd_ = -1; - for (int sock_fd : this->socket_fds_) { - if (sock_fd > this->max_fd_) - this->max_fd_ = sock_fd; - } - } - return; - } -} - -#endif - -// Only the select() fallback path remains in the .cpp — all other paths are inlined in application.h -#ifdef USE_HOST -void Application::yield_with_select_(uint32_t delay_ms) { - // Fallback select() path (host platform and any future platforms without fast select). - if (!this->socket_fds_.empty()) [[likely]] { - // Update fd_set if socket list has changed - if (this->socket_fds_changed_) [[unlikely]] { - FD_ZERO(&this->base_read_fds_); - // fd bounds are validated in register_socket_fd() - for (int fd : this->socket_fds_) { - FD_SET(fd, &this->base_read_fds_); - } - this->socket_fds_changed_ = false; - } - - // Copy base fd_set before each select - this->read_fds_ = this->base_read_fds_; - - // Convert delay_ms to timeval - struct timeval tv; - tv.tv_sec = delay_ms / 1000; - tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; - - // Call select with timeout - int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); - - // Process select() result: - // ret > 0: socket(s) have data ready - normal and expected - // ret == 0: timeout occurred - normal and expected - if (ret >= 0) [[likely]] { - // Yield if zero timeout since select(0) only polls without yielding - if (delay_ms == 0) [[unlikely]] { - yield(); - } - return; - } - // ret < 0: error (EINTR is normal, anything else is unexpected) - const int err = errno; - if (err == EINTR) { - return; - } - // select() error - log and fall through to delay() - ESP_LOGW(TAG, "select() failed with errno %d", err); - } - // No sockets registered or select() failed - use regular delay - delay(delay_ms); -} -#endif // USE_HOST - // App storage — asm label shares the linker symbol with "extern Application App". // char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted. // Constructed via placement new in the generated setup(). @@ -592,66 +510,6 @@ alignas(Application) char app_storage[sizeof(Application)] asm( #undef ESPHOME_STRINGIFY_ #undef ESPHOME_STRINGIFY_IMPL_ -// Host platform wake_loop_threadsafe() and setup — needs wake_socket_fd_ -// ESP32/LibreTiny/ESP8266/RP2040 implementations are in wake.cpp -#ifdef USE_HOST - -void Application::setup_wake_loop_threadsafe_() { - // Create UDP socket for wake notifications - this->wake_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (this->wake_socket_fd_ < 0) { - ESP_LOGW(TAG, "Wake socket create failed: %d", errno); - return; - } - - // Bind to loopback with auto-assigned port - struct sockaddr_in addr = {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // Auto-assign port - - if (::bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { - ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Get the assigned address and connect to it - // Connecting a UDP socket allows using send() instead of sendto() for better performance - struct sockaddr_in wake_addr; - socklen_t len = sizeof(wake_addr); - if (::getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) { - ESP_LOGW(TAG, "Wake socket address failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Connect to self (loopback) - allows using send() instead of sendto() - // After connect(), no need to store wake_addr - the socket remembers it - if (::connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { - ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } - - // Set non-blocking mode - int flags = ::fcntl(this->wake_socket_fd_, F_GETFL, 0); - ::fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK); - - // Register with application's select() loop - if (!this->register_socket_fd(this->wake_socket_fd_)) { - ESP_LOGW(TAG, "Wake socket register failed"); - ::close(this->wake_socket_fd_); - this->wake_socket_fd_ = -1; - return; - } -} - -#endif // USE_HOST - void Application::get_build_time_string(std::span buffer) { ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); buffer[buffer.size() - 1] = '\0'; diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b49..e9b386038 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,100 +24,11 @@ #include "esphome/core/area.h" #endif -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif -#ifdef USE_HOST -#include -#include -#include -#include -#include -#include -#endif #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif #include "esphome/core/wake.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_INFRARED -#include "esphome/components/infrared/infrared.h" -#endif -#ifdef USE_SERIAL_PROXY -#include "esphome/components/serial_proxy/serial_proxy.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif - -namespace esphome::socket { -#ifdef USE_HOST -/// Shared ready() helper for fd-based socket implementations. -bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redundant-declaration) -#endif -} // namespace esphome::socket +#include "esphome/core/entity_includes.h" #ifdef USE_RUNTIME_STATS namespace esphome::runtime_stats { @@ -153,11 +64,9 @@ class Application { void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); this->name_add_mac_suffix_ = true; - // MAC address length: 12 hex chars + null terminator - constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - char mac_addr[mac_address_len]; + char mac_addr[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr); // Overwrite the placeholder suffix in the mutable static buffers with actual MAC // name is always non-empty (validated by validate_hostname in Python config) @@ -190,93 +99,16 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -#ifdef USE_BINARY_SENSOR - void register_binary_sensor(binary_sensor::BinarySensor *binary_sensor) { - this->binary_sensors_.push_back(binary_sensor); - } -#endif - -#ifdef USE_SENSOR - void register_sensor(sensor::Sensor *sensor) { this->sensors_.push_back(sensor); } -#endif - -#ifdef USE_SWITCH - void register_switch(switch_::Switch *a_switch) { this->switches_.push_back(a_switch); } -#endif - -#ifdef USE_BUTTON - void register_button(button::Button *button) { this->buttons_.push_back(button); } -#endif - -#ifdef USE_TEXT_SENSOR - void register_text_sensor(text_sensor::TextSensor *sensor) { this->text_sensors_.push_back(sensor); } -#endif - -#ifdef USE_FAN - void register_fan(fan::Fan *state) { this->fans_.push_back(state); } -#endif - -#ifdef USE_COVER - void register_cover(cover::Cover *cover) { this->covers_.push_back(cover); } -#endif - -#ifdef USE_CLIMATE - void register_climate(climate::Climate *climate) { this->climates_.push_back(climate); } -#endif - -#ifdef USE_LIGHT - void register_light(light::LightState *light) { this->lights_.push_back(light); } -#endif - -#ifdef USE_NUMBER - void register_number(number::Number *number) { this->numbers_.push_back(number); } -#endif - -#ifdef USE_DATETIME_DATE - void register_date(datetime::DateEntity *date) { this->dates_.push_back(date); } -#endif - -#ifdef USE_DATETIME_TIME - void register_time(datetime::TimeEntity *time) { this->times_.push_back(time); } -#endif - -#ifdef USE_DATETIME_DATETIME - void register_datetime(datetime::DateTimeEntity *datetime) { this->datetimes_.push_back(datetime); } -#endif - -#ifdef USE_TEXT - void register_text(text::Text *text) { this->texts_.push_back(text); } -#endif - -#ifdef USE_SELECT - void register_select(select::Select *select) { this->selects_.push_back(select); } -#endif - -#ifdef USE_LOCK - void register_lock(lock::Lock *a_lock) { this->locks_.push_back(a_lock); } -#endif - -#ifdef USE_VALVE - void register_valve(valve::Valve *valve) { this->valves_.push_back(valve); } -#endif - -#ifdef USE_MEDIA_PLAYER - void register_media_player(media_player::MediaPlayer *media_player) { this->media_players_.push_back(media_player); } -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - void register_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { - this->alarm_control_panels_.push_back(a_alarm_control_panel); - } -#endif - -#ifdef USE_WATER_HEATER - void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } -#endif - -#ifdef USE_INFRARED - void register_infrared(infrared::Infrared *infrared) { this->infrareds_.push_back(infrared); } -#endif +// Entity register methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + void register_##singular(type *obj) { this->plural##_.push_back(obj); } +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY void register_serial_proxy(serial_proxy::SerialProxy *proxy) { @@ -285,14 +117,6 @@ class Application { } #endif -#ifdef USE_EVENT - void register_event(event::Event *event) { this->events_.push_back(event); } -#endif - -#ifdef USE_UPDATE - void register_update(update::UpdateEntity *update) { this->updates_.push_back(update); } -#endif - /// Reserve space for components to avoid memory fragmentation /// Set up all the registered components. Call this at the end of your setup() function. @@ -385,7 +209,59 @@ class Application { void schedule_dump_config() { this->dump_config_at_ = 0; } - void feed_wdt(uint32_t time = 0); + /// Minimum interval between real arch_feed_wdt() calls. Sized so the outer + /// feed in Application::loop() is effectively rate-limited across both the + /// normal ~62 Hz cadence and worst-case wake-storm scenarios (e.g. external + /// stacks like OpenThread posting frequent wake notifications). Component + /// loops and scheduler items still feed after every op, so any op exceeding + /// this threshold triggers a real feed naturally. + /// Safety margins vs. platform watchdog timeouts: + /// - ESP32 task WDT default (5 s): ~16x + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below +#ifdef USE_BK72XX + // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny + // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: + // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#else + static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; +#endif + + /// Feed the task watchdog. Cold entry — callers without a millis() + /// timestamp in hand. Out of line to keep call sites tiny. + void feed_wdt(); + +#ifdef USE_STATUS_LED + /// Dispatch interval for the status LED update. Deliberately shorter than + /// WDT_FEED_INTERVAL_MS because the status LED error blink has a 250 ms + /// period (status_led.cpp:ERROR_PERIOD_MS) and a 150 ms on-window; the + /// dispatch cadence must be short enough to render that blink without + /// aliasing. Sampling every 100 ms yields an on/off observation inside + /// every error period with headroom for the 250 ms warning on-window. + static constexpr uint32_t STATUS_LED_DISPATCH_INTERVAL_MS = 100; +#endif + + /// Feed the task watchdog, hot entry. Callers that already have a + /// millis() timestamp pay only a load + sub + branch on the common + /// (no-op) path. The actual arch feed lives in feed_wdt_slow_. + /// When USE_STATUS_LED is compiled in, also gates a separate (shorter) + /// interval for dispatching status_led so the LED blink pattern stays + /// readable even though arch_feed_wdt pokes are now rate-limited at + /// WDT_FEED_INTERVAL_MS. The two rate limits are independent so raising + /// WDT_FEED_INTERVAL_MS does not distort the LED cadence. + void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) { + if (static_cast(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] { + this->feed_wdt_slow_(time); + } +#ifdef USE_STATUS_LED + if (static_cast(time - this->last_status_led_service_) > STATUS_LED_DISPATCH_INTERVAL_MS) [[unlikely]] { + this->service_status_led_slow_(time); + } +#endif + } void reboot(); @@ -401,7 +277,18 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint8_t get_app_state() const { return this->app_state_; } + /// Return the public app state status bits (STATUS_LED_* only). + /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked + /// out so external readers (status_led components, etc.) never see them. + uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } + + /// True once Application::setup() has finished walking all components + /// and finalized the initial status flags. Before this point, the + /// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and + /// status_clear_* intentionally skips its walk-and-clear step so the + /// forced bit doesn't get wiped. Stored as a free bit on app_state_ + /// (bit 6) to avoid costing additional RAM. + bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } // Helper macro for entity getter method declarations #ifdef USE_DEVICES @@ -428,132 +315,34 @@ class Application { #ifdef USE_AREAS const auto &get_areas() { return this->areas_; } #endif -#ifdef USE_BINARY_SENSOR - auto &get_binary_sensors() const { return this->binary_sensors_; } - GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) -#endif -#ifdef USE_SWITCH - auto &get_switches() const { return this->switches_; } - GET_ENTITY_METHOD(switch_::Switch, switch, switches) -#endif -#ifdef USE_BUTTON - auto &get_buttons() const { return this->buttons_; } - GET_ENTITY_METHOD(button::Button, button, buttons) -#endif -#ifdef USE_SENSOR - auto &get_sensors() const { return this->sensors_; } - GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) -#endif -#ifdef USE_TEXT_SENSOR - auto &get_text_sensors() const { return this->text_sensors_; } - GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) -#endif -#ifdef USE_FAN - auto &get_fans() const { return this->fans_; } - GET_ENTITY_METHOD(fan::Fan, fan, fans) -#endif -#ifdef USE_COVER - auto &get_covers() const { return this->covers_; } - GET_ENTITY_METHOD(cover::Cover, cover, covers) -#endif -#ifdef USE_LIGHT - auto &get_lights() const { return this->lights_; } - GET_ENTITY_METHOD(light::LightState, light, lights) -#endif -#ifdef USE_CLIMATE - auto &get_climates() const { return this->climates_; } - GET_ENTITY_METHOD(climate::Climate, climate, climates) -#endif -#ifdef USE_NUMBER - auto &get_numbers() const { return this->numbers_; } - GET_ENTITY_METHOD(number::Number, number, numbers) -#endif -#ifdef USE_DATETIME_DATE - auto &get_dates() const { return this->dates_; } - GET_ENTITY_METHOD(datetime::DateEntity, date, dates) -#endif -#ifdef USE_DATETIME_TIME - auto &get_times() const { return this->times_; } - GET_ENTITY_METHOD(datetime::TimeEntity, time, times) -#endif -#ifdef USE_DATETIME_DATETIME - auto &get_datetimes() const { return this->datetimes_; } - GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) -#endif -#ifdef USE_TEXT - auto &get_texts() const { return this->texts_; } - GET_ENTITY_METHOD(text::Text, text, texts) -#endif -#ifdef USE_SELECT - auto &get_selects() const { return this->selects_; } - GET_ENTITY_METHOD(select::Select, select, selects) -#endif -#ifdef USE_LOCK - auto &get_locks() const { return this->locks_; } - GET_ENTITY_METHOD(lock::Lock, lock, locks) -#endif -#ifdef USE_VALVE - auto &get_valves() const { return this->valves_; } - GET_ENTITY_METHOD(valve::Valve, valve, valves) -#endif -#ifdef USE_MEDIA_PLAYER - auto &get_media_players() const { return this->media_players_; } - GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } - GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) -#endif - -#ifdef USE_WATER_HEATER - auto &get_water_heaters() const { return this->water_heaters_; } - GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) -#endif - -#ifdef USE_INFRARED - auto &get_infrareds() const { return this->infrareds_; } - GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) -#endif +// Entity getter methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + auto &get_##plural() const { return this->plural##_; } \ + GET_ENTITY_METHOD(type, singular, plural) +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_SERIAL_PROXY auto &get_serial_proxies() const { return this->serial_proxies_; } #endif -#ifdef USE_EVENT - auto &get_events() const { return this->events_; } - GET_ENTITY_METHOD(event::Event, event, events) -#endif - -#ifdef USE_UPDATE - auto &get_updates() const { return this->updates_; } - GET_ENTITY_METHOD(update::UpdateEntity, update, updates) -#endif - Scheduler scheduler; - /// Register/unregister a socket to be monitored for read events. - /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. -#ifdef USE_LWIP_FAST_SELECT - /// Fast select path: hooks netconn callback and registers for monitoring. - /// @return true if registration was successful, false if sock is null - bool register_socket(struct lwip_sock *sock); - void unregister_socket(struct lwip_sock *sock); -#elif defined(USE_HOST) - /// Fallback select() path: monitors file descriptors. - /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. - /// @return true if registration was successful, false if fd exceeds limits - bool register_socket_fd(int fd); - void unregister_socket_fd(int fd); -#endif - /// Wake the main event loop from another thread or callback. /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } -#ifdef USE_ESP32 - /// Wake from ISR (ESP32 only). +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } +#elif defined(USE_ESP8266) + /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. + static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). @@ -561,21 +350,17 @@ class Application { protected: friend Component; -#ifdef USE_HOST - friend bool socket::socket_ready_fd(int fd, bool loop_monitored); -#endif #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); -#ifdef USE_HOST - friend void wake_loop_threadsafe(); // Host platform accesses wake_socket_fd_ -#endif -#ifdef USE_HOST - bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } -#endif + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// (which is a friend) to decide whether to clear the corresponding bit on + /// this->app_state_ (the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. @@ -607,27 +392,40 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline void ESPHOME_ALWAYS_INLINE before_loop_tasks_(uint32_t loop_start_time); - inline void ESPHOME_ALWAYS_INLINE after_loop_tasks_() { this->in_loop_ = false; } + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + + // RAII guard for a component loop phase. Constructor processes any pending + // enable_loop requests from ISRs and marks in_loop_ so reentrant + // modifications during component.loop() are safe; destructor clears in_loop_. + class ComponentPhaseGuard { + public: + inline ESPHOME_ALWAYS_INLINE explicit ComponentPhaseGuard(Application &app); + inline ESPHOME_ALWAYS_INLINE ~ComponentPhaseGuard() { this->app_.in_loop_ = false; } + ComponentPhaseGuard(const ComponentPhaseGuard &) = delete; + ComponentPhaseGuard &operator=(const ComponentPhaseGuard &) = delete; + + private: + Application &app_; + }; /// Process dump_config output one component per loop iteration. /// Extracted from loop() to keep cold startup/reconnect logging out of the hot path. /// Caller must ensure dump_config_at_ < components_.size(). void __attribute__((noinline)) process_dump_config_(); - void feed_wdt_arch_(); + /// Slow path for feed_wdt(): actually calls arch_feed_wdt() and updates + /// last_wdt_feed_. Out of line so the inline wrapper stays tiny. Does NOT + /// touch status_led — that's gated separately via service_status_led_slow_ + /// because the two rate limits have very different safe ranges (~ seconds + /// for WDT, < 250 ms for LED blink rendering). + void feed_wdt_slow_(uint32_t time); - /// Perform a delay while also monitoring socket file descriptors for readiness -#ifdef USE_HOST - // select() fallback path is too complex to inline (host platform) - void yield_with_select_(uint32_t delay_ms); -#else - inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms); -#endif - -#ifdef USE_HOST - void setup_wake_loop_threadsafe_(); // Create wake notification socket - inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) +#ifdef USE_STATUS_LED + /// Slow path for the status_led dispatch rate limit. Runs the status_led + /// component's loop() based on its state (LOOP / LOOP_DONE with status + /// bits set), and updates last_status_led_service_. Out of line to keep + /// the feed_wdt_with_time hot path a couple of load+branch sequences. + void service_status_led_slow_(uint32_t time); #endif // === Member variables ordered by size to minimize padding === @@ -653,14 +451,6 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_LWIP_FAST_SELECT - std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read -#elif defined(USE_HOST) - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif -#ifdef USE_HOST - int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks -#endif // StringRef members (8 bytes each: pointer + size) StringRef name_; @@ -669,9 +459,10 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; - -#ifdef USE_HOST - int max_fd_{-1}; // Highest file descriptor number for select() + uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path +#ifdef USE_STATUS_LED + // millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_ + uint32_t last_status_led_service_{0}; #endif // 2-byte members (grouped together for alignment) @@ -686,16 +477,6 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_HOST - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes -#endif - -#ifdef USE_HOST - // Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly) - fd_set read_fds_{}; // Working fd_set: populated by select() - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes -#endif - // StaticVectors (largest members - contain actual array data inline) StaticVector components_{}; @@ -705,123 +486,46 @@ class Application { #ifdef USE_AREAS StaticVector areas_{}; #endif -#ifdef USE_BINARY_SENSOR - StaticVector binary_sensors_{}; -#endif -#ifdef USE_SWITCH - StaticVector switches_{}; -#endif -#ifdef USE_BUTTON - StaticVector buttons_{}; -#endif -#ifdef USE_EVENT - StaticVector events_{}; -#endif -#ifdef USE_SENSOR - StaticVector sensors_{}; -#endif -#ifdef USE_TEXT_SENSOR - StaticVector text_sensors_{}; -#endif -#ifdef USE_FAN - StaticVector fans_{}; -#endif -#ifdef USE_COVER - StaticVector covers_{}; -#endif -#ifdef USE_CLIMATE - StaticVector climates_{}; -#endif -#ifdef USE_LIGHT - StaticVector lights_{}; -#endif -#ifdef USE_NUMBER - StaticVector numbers_{}; -#endif -#ifdef USE_DATETIME_DATE - StaticVector dates_{}; -#endif -#ifdef USE_DATETIME_TIME - StaticVector times_{}; -#endif -#ifdef USE_DATETIME_DATETIME - StaticVector datetimes_{}; -#endif -#ifdef USE_SELECT - StaticVector selects_{}; -#endif -#ifdef USE_TEXT - StaticVector texts_{}; -#endif -#ifdef USE_LOCK - StaticVector locks_{}; -#endif -#ifdef USE_VALVE - StaticVector valves_{}; -#endif -#ifdef USE_MEDIA_PLAYER - StaticVector media_players_{}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - StaticVector - alarm_control_panels_{}; -#endif -#ifdef USE_WATER_HEATER - StaticVector water_heaters_{}; -#endif -#ifdef USE_INFRARED - StaticVector infrareds_{}; -#endif +// Entity StaticVector fields (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) StaticVector plural##_{}; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) + #ifdef USE_SERIAL_PROXY StaticVector serial_proxies_{}; #endif -#ifdef USE_UPDATE - StaticVector updates_{}; -#endif }; /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// Phase A: drain wake notifications and run the scheduler. Invoked on every +// Application::loop() tick regardless of whether a component phase runs, so +// scheduler items fire at their requested cadence even when the caller has +// raised loop_interval_ for power savings (see Application::loop()). +// Returns the timestamp of the last scheduler item that ran (or `now` +// unchanged if none ran), so the caller's WDT feed stays monotonic with the +// per-item feeds inside scheduler.call() without an extra millis(). +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST -// Inline implementations for hot-path functions -// drain_wake_notifications_() is called on every loop iteration - -// Small buffer for draining wake notification bytes (1 byte sent per wake) -// Size allows draining multiple notifications per recvfrom() without wasting stack -static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void Application::drain_wake_notifications_() { - // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready_() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads - // Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK - // We control both ends of this loopback socket (always write 1 byte per wake), - // so no error checking needed - any errors indicate catastrophic system failure - while (::recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed - wake has already occurred - } - } -} -#endif // USE_HOST - -inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_start_time) { -#ifdef USE_HOST - // Drain wake notifications first to clear socket for next wake - this->drain_wake_notifications_(); + // Drain wake notifications first to clear socket for next wake. + wake_drain_notifications(); #endif + return this->scheduler.call(now); +} - // Process scheduled tasks - this->scheduler.call(loop_start_time); - - // Feed the watchdog timer - this->feed_wdt(loop_start_time); - +// Phase B entry: only invoked when a component loop phase is about to run. +// Processes pending enable_loop requests from ISRs and marks in_loop_ so +// reentrant modifications during component.loop() are safe. +inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGuard(Application &app) : app_(app) { // Process any pending enable_loop requests from ISRs // This must be done before marking in_loop_ = true to avoid race conditions - if (this->has_pending_enable_loop_requests_) { + if (this->app_.has_pending_enable_loop_requests_) { // Clear flag BEFORE processing to avoid race condition // If ISR sets it during processing, we'll catch it next loop iteration // This is safe because: @@ -829,98 +533,146 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ // 2. If we can't process a component (wrong state), enable_pending_loops_() // will set this flag back to true // 3. Any new ISR requests during processing will set the flag again - this->has_pending_enable_loop_requests_ = false; - this->enable_pending_loops_(); + this->app_.has_pending_enable_loop_requests_ = false; + this->app_.enable_pending_loops_(); } // Mark that we're in the loop for safe reentrant modifications - this->in_loop_ = true; + this->app_.in_loop_ = true; } inline void ESPHOME_ALWAYS_INLINE Application::loop() { - uint8_t new_app_state = 0; - - // Get the initial loop time at the start - uint32_t last_op_end_time = millis(); - - this->before_loop_tasks_(last_op_end_time); - - for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; - this->current_loop_index_++) { - Component *component = this->looping_components_[this->current_loop_index_]; - - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; - component->loop(); - // Use the finish method to get the current time as the end time - last_op_end_time = guard.finish(); - } - new_app_state |= component->get_component_state(); - this->app_state_ |= new_app_state; - this->feed_wdt(last_op_end_time); - } - - this->after_loop_tasks_(); - this->app_state_ = new_app_state; +#ifdef USE_RUNTIME_STATS + // Capture the start of the active (non-sleeping) portion of this iteration. + // Used to derive main-loop overhead = active time − Σ(component time) − + // before/tail splits recorded below. + uint32_t loop_active_start_us = micros(); + // Snapshot the cumulative component-recorded time so we can subtract the + // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // (scheduler.cpp) — that time is already counted in per-component stats, + // so charging it again to "before" would double-count. + uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; +#endif + // Phase A: always service the scheduler. Decouples scheduler cadence from + // loop_interval_ so raised intervals (for power savings) don't drag scheduled + // items forward. A tick that only runs the scheduler is cheap. + // scheduler_tick_ returns the timestamp of the last scheduler item that ran + // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` + // so the gate check and WDT feed both reflect actual elapsed time after + // scheduler dispatch, without an extra millis() call. + uint32_t now = this->scheduler_tick_(MillisInternal::get()); + // Guarantee one WDT feed per tick even when the scheduler had nothing to + // dispatch and the component phase is gated out — covers configs with no + // looping components and no scheduler work (setup() has its own + // per-component feed_wdt calls, so only do this here, not in scheduler_tick_). + this->feed_wdt_with_time(now); #ifdef USE_RUNTIME_STATS - // Process any pending runtime stats printing after all components have run - // This ensures stats printing doesn't affect component timing measurements + uint32_t loop_before_end_us = micros(); + uint64_t loop_before_scheduled_us = ComponentRuntimeStats::global_recorded_us - loop_recorded_snap; + // Only meaningful when do_component_phase is true; initialized to 0 so the + // tail bucket receives 0 on Phase A-only ticks (no component tail happened, + // the gate-check / stats-prefix overhead belongs to "residual", not "tail"). + uint32_t loop_tail_start_us = 0; +#endif + + // Gate the component phase on loop_interval_, an active high-frequency + // request, or an explicit wake from a background producer. A scheduler-only + // wake (e.g. set_interval firing under a raised loop_interval_) leaves the + // component phase gated; an external producer that called wake_loop_* + // (MQTT RX, USB RX, BLE event, etc.) needs the component phase to actually + // run so its component's loop() can drain the queued work — that is the + // long-standing semantic of wake_loop_threadsafe(), and the wake_request + // flag preserves it. wake_request_take() exchange-clears the flag; wakes + // that arrive during Phase B re-set it and run Phase B again on the next + // iteration. + const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); + const uint32_t elapsed = now - this->last_loop_; + const bool woke = esphome::wake_request_take(); + const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); + + if (do_component_phase) { + ComponentPhaseGuard phase_guard{*this}; + + uint32_t last_op_end_time = now; + for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; + this->current_loop_index_++) { + Component *component = this->looping_components_[this->current_loop_index_]; + + // Update the cached time before each component runs + this->loop_component_start_time_ = last_op_end_time; + + { + this->set_current_component(component); + WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + component->loop(); + // Use the finish method to get the current time as the end time + last_op_end_time = guard.finish(); + } + this->feed_wdt_with_time(last_op_end_time); + } + +#ifdef USE_RUNTIME_STATS + loop_tail_start_us = micros(); +#endif + this->last_loop_ = last_op_end_time; + now = last_op_end_time; + // phase_guard destructor clears in_loop_ at scope exit + } + +#ifdef USE_RUNTIME_STATS + // Record per-tick timing on every loop, not just component-phase ticks. + // record_loop_active is a small accumulator; process_pending_stats is an + // inline gate check that early-outs unless now >= next_log_time_. if (global_runtime_stats != nullptr) { - global_runtime_stats->process_pending_stats(last_op_end_time); + uint32_t loop_now_us = micros(); + // Subtract scheduled-component time from the "before" bucket so it is + // not double-counted (it is already attributed to per-component stats). + uint32_t loop_before_wall_us = loop_before_end_us - loop_active_start_us; + uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us + ? loop_before_wall_us - static_cast(loop_before_scheduled_us) + : 0; + // tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the + // stats bucket keeps its "component-phase trailing overhead" meaning. + uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0; + global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us); + global_runtime_stats->process_pending_stats(now); } #endif - // Use the last component's end time instead of calling millis() again + // Compute sleep: bounded by time-until-next-component-phase and the + // scheduler's next deadline. When a scheduler timer fires it re-enters + // loop(), Phase A services it, and the component phase stays gated by + // loop_interval_. When a background producer calls wake_loop_threadsafe() + // it sets the wake_request flag and wakes select() / the task notification; + // the gate above sees the flag and runs Phase B too so the producer's + // component can drain its queued work without waiting up to loop_interval_. + // + // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of + // reusing the cached `high_frequency` captured above: a component calling + // HighFrequencyLoopRequester::start() from within its loop() would + // otherwise sit under the stale value and sleep for up to loop_interval_ + // before the request took effect. That was fine pre-decoupling (the old + // main loop also called the function fresh at the sleep point) but now + // matters much more — loop_interval_ is a power-saving knob documented + // to accept multi-second values, so the stale path could add seconds of + // latency on an HF request. The call is a trivial atomic read. uint32_t delay_time = 0; - auto elapsed = last_op_end_time - this->last_loop_; - if (elapsed < this->loop_interval_ && !HighFrequencyLoopRequester::is_high_frequency()) { - delay_time = this->loop_interval_ - elapsed; - uint32_t next_schedule = this->scheduler.next_schedule_in(last_op_end_time).value_or(delay_time); - // next_schedule is max 0.5*delay_time - // otherwise interval=0 schedules result in constant looping with almost no sleep - next_schedule = std::max(next_schedule, delay_time / 2); - delay_time = std::min(next_schedule, delay_time); + if (!HighFrequencyLoopRequester::is_high_frequency()) { + const uint32_t elapsed_since_phase = now - this->last_loop_; + const uint32_t until_phase = + (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + delay_time = std::min(until_phase, until_sched); } - this->yield_with_select_(delay_time); - this->last_loop_ = last_op_end_time; + // All platforms route loop yields through the platform wake primitive. + // On host this drains the loopback wake socket via select(); on FreeRTOS + // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. + esphome::internal::wakeable_delay(delay_time); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } } -// Inline yield_with_select_ for all paths except the select() fallback -#ifndef USE_HOST -inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) { -#ifdef USE_LWIP_FAST_SELECT - // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. - // Safe because this runs on the main loop which owns socket lifetime (create, read, close). - if (delay_ms == 0) [[unlikely]] { - yield(); - return; - } - - // Check if any socket already has pending data before sleeping. - // If a socket still has unread data (rcvevent > 0) but the task notification was already - // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. - // This scan preserves select() semantics: return immediately when any fd is ready. - for (struct lwip_sock *sock : this->monitored_sockets_) { - if (esphome_lwip_socket_has_data(sock)) { - yield(); - return; - } - } - - // Sleep with instant wake via FreeRTOS task notification. - // Woken by: callback wrapper (socket data), wake_loop_threadsafe() (background tasks), or timeout. -#endif - esphome::internal::wakeable_delay(delay_ms); -} -#endif // !USE_HOST - } // namespace esphome diff --git a/esphome/core/automation.h b/esphome/core/automation.h index eb270bfee..468ea3b38 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,6 +62,18 @@ template class TemplatableFn { !std::convertible_to, T> || !std::default_initializable) = delete; + // Reject raw (non-callable) values with a helpful diagnostic pointing at the Python-side fix. + // TemplatableFn stores only a function pointer (4 bytes), so constants must be wrapped in a + // stateless lambda by codegen. External components hitting this error should use + // `cg.templatable(value, args, type)` in their Python __init__.py before passing to the setter. + template TemplatableFn(V) requires(!std::invocable) && (!std::convertible_to) { + static_assert(sizeof(V) == 0, "Missing cg.templatable(...) in Python codegen for this TEMPLATABLE_VALUE " + "field. The wrapper was always required; it worked by accident because the old " + "TemplatableValue implicitly converted raw constants. TemplatableFn cannot. See " + "https://developers.esphome.io/blog/2026/04/09/" + "templatablefn-4-byte-templatable-storage-for-trivially-copyable-types/"); + } + bool has_value() const { return this->f_ != nullptr; } T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 11133d397..17f937d10 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -205,7 +205,9 @@ template class DelayAction : public Action, public Compon } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay - auto f = [this, x...]() { this->play_next_(x...); }; + // `mutable` is required so captured copies of non-const reference args (e.g. std::string&) + // are passed as non-const lvalues to play_next_(const Ts&...) where Ts may be `T&` + auto f = [this, x...]() mutable { this->play_next_(x...); }; App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(x...), std::move(f), diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index deda42b0a..e33652482 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) { } void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; + // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished + // AND no other component still has it set. During setup the forced + // STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped + // by a transient component clear — Application::setup() reconciles + // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. + // The set path is unchanged (set_status_flag_ still writes directly). + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error_slow_path_() { this->component_state_ &= ~STATUS_LED_ERROR; + // STATUS_LED_ERROR is never artificially forced — it only ever lands + // in app_state_ via a real set_status_flag_ call. So the walk-and-clear + // path is always safe, including during setup. + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const char *name, uint32_t length) { @@ -493,6 +506,10 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } +#ifdef USE_RUNTIME_STATS +uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void __attribute__((noinline, cold)) WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; diff --git a/esphome/core/component.h b/esphome/core/component.h index e2b7aa85d..6afcfda41 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -9,6 +9,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/millis_internal.h" #include "esphome/core/optional.h" // Forward declarations for friend access from codegen-generated setup() @@ -89,6 +90,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; +// Bit 6 on Application::app_state_ (ONLY) — set at the end of +// Application::setup(). Component::status_clear_*_slow_path_() uses this to +// decide whether to propagate clears to App.app_state_. Never set on a +// Component's component_state_. +inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; @@ -111,6 +117,13 @@ struct ComponentRuntimeStats { uint64_t total_time_us{0}; uint32_t total_max_time_us{0}; + // Cumulative sum of every record_time() duration since boot, across all + // components. Used by Application::loop() to snapshot time spent inside + // WarnIfComponentBlockingGuard (including guards constructed by the + // scheduler at scheduler.cpp) so main-loop overhead accounting can + // subtract scheduled-callback time from the before_loop_tasks_ wall time. + static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void record_time(uint32_t duration_us) { this->period_count++; this->period_time_us += duration_us; @@ -120,6 +133,7 @@ struct ComponentRuntimeStats { this->total_time_us += duration_us; if (duration_us > this->total_max_time_us) this->total_max_time_us = duration_us; + global_recorded_us += duration_us; } void reset_period() { this->period_count = 0; @@ -588,7 +602,7 @@ class Component { */ class PollingComponent : public Component { public: - PollingComponent() : PollingComponent(0) {} + PollingComponent() : PollingComponent(SCHEDULER_DONT_RUN) {} /** Initialize this polling component with the given update interval in ms. * @@ -643,7 +657,7 @@ class WarnIfComponentBlockingGuard { #ifdef USE_RUNTIME_STATS this->component_->runtime_stats_.record_time(micros() - this->started_us_); #endif - uint32_t curr_time = millis(); + uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index ff76b2b81..f4d3c05e1 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -33,53 +33,18 @@ void ComponentIterator::advance() { } break; -#ifdef USE_BINARY_SENSOR - case IteratorState::BINARY_SENSOR: - this->process_platform_item_(App.get_binary_sensors(), &ComponentIterator::on_binary_sensor); - break; -#endif - -#ifdef USE_COVER - case IteratorState::COVER: - this->process_platform_item_(App.get_covers(), &ComponentIterator::on_cover); - break; -#endif - -#ifdef USE_FAN - case IteratorState::FAN: - this->process_platform_item_(App.get_fans(), &ComponentIterator::on_fan); - break; -#endif - -#ifdef USE_LIGHT - case IteratorState::LIGHT: - this->process_platform_item_(App.get_lights(), &ComponentIterator::on_light); - break; -#endif - -#ifdef USE_SENSOR - case IteratorState::SENSOR: - this->process_platform_item_(App.get_sensors(), &ComponentIterator::on_sensor); - break; -#endif - -#ifdef USE_SWITCH - case IteratorState::SWITCH: - this->process_platform_item_(App.get_switches(), &ComponentIterator::on_switch); - break; -#endif - -#ifdef USE_BUTTON - case IteratorState::BUTTON: - this->process_platform_item_(App.get_buttons(), &ComponentIterator::on_button); - break; -#endif - -#ifdef USE_TEXT_SENSOR - case IteratorState::TEXT_SENSOR: - this->process_platform_item_(App.get_text_sensors(), &ComponentIterator::on_text_sensor); - break; -#endif +// Entity iterator cases (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) \ + case IteratorState::upper: \ + this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \ + break; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS case IteratorState::SERVICE: @@ -97,96 +62,6 @@ void ComponentIterator::advance() { } break; #endif -#ifdef USE_CLIMATE - case IteratorState::CLIMATE: - this->process_platform_item_(App.get_climates(), &ComponentIterator::on_climate); - break; -#endif - -#ifdef USE_NUMBER - case IteratorState::NUMBER: - this->process_platform_item_(App.get_numbers(), &ComponentIterator::on_number); - break; -#endif - -#ifdef USE_DATETIME_DATE - case IteratorState::DATETIME_DATE: - this->process_platform_item_(App.get_dates(), &ComponentIterator::on_date); - break; -#endif - -#ifdef USE_DATETIME_TIME - case IteratorState::DATETIME_TIME: - this->process_platform_item_(App.get_times(), &ComponentIterator::on_time); - break; -#endif - -#ifdef USE_DATETIME_DATETIME - case IteratorState::DATETIME_DATETIME: - this->process_platform_item_(App.get_datetimes(), &ComponentIterator::on_datetime); - break; -#endif - -#ifdef USE_TEXT - case IteratorState::TEXT: - this->process_platform_item_(App.get_texts(), &ComponentIterator::on_text); - break; -#endif - -#ifdef USE_SELECT - case IteratorState::SELECT: - this->process_platform_item_(App.get_selects(), &ComponentIterator::on_select); - break; -#endif - -#ifdef USE_LOCK - case IteratorState::LOCK: - this->process_platform_item_(App.get_locks(), &ComponentIterator::on_lock); - break; -#endif - -#ifdef USE_VALVE - case IteratorState::VALVE: - this->process_platform_item_(App.get_valves(), &ComponentIterator::on_valve); - break; -#endif - -#ifdef USE_MEDIA_PLAYER - case IteratorState::MEDIA_PLAYER: - this->process_platform_item_(App.get_media_players(), &ComponentIterator::on_media_player); - break; -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - case IteratorState::ALARM_CONTROL_PANEL: - this->process_platform_item_(App.get_alarm_control_panels(), &ComponentIterator::on_alarm_control_panel); - break; -#endif - -#ifdef USE_WATER_HEATER - case IteratorState::WATER_HEATER: - this->process_platform_item_(App.get_water_heaters(), &ComponentIterator::on_water_heater); - break; -#endif - -#ifdef USE_INFRARED - case IteratorState::INFRARED: - this->process_platform_item_(App.get_infrareds(), &ComponentIterator::on_infrared); - break; -#endif - -#ifdef USE_EVENT - case IteratorState::EVENT: - this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); - break; -#endif - -#ifdef USE_UPDATE - case IteratorState::UPDATE: - this->process_platform_item_(App.get_updates(), &ComponentIterator::on_update); - break; -#endif - case IteratorState::MAX: if (this->on_end()) { this->state_ = IteratorState::NONE; @@ -203,7 +78,4 @@ bool ComponentIterator::on_service(api::UserServiceDescriptor *service) { return #ifdef USE_CAMERA bool ComponentIterator::on_camera(camera::Camera *camera) { return true; } #endif -#ifdef USE_MEDIA_PLAYER -bool ComponentIterator::on_media_player(media_player::MediaPlayer *media_player) { return true; } -#endif } // namespace esphome diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 6c03b74a1..d271fcfed 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -21,6 +21,11 @@ namespace infrared { class Infrared; } // namespace infrared #endif +#ifdef USE_RADIO_FREQUENCY +namespace radio_frequency { +class RadioFrequency; +} // namespace radio_frequency +#endif class ComponentIterator { public: @@ -28,80 +33,21 @@ class ComponentIterator { void advance(); bool completed() const { return this->state_ == IteratorState::NONE; } virtual bool on_begin(); -#ifdef USE_BINARY_SENSOR - virtual bool on_binary_sensor(binary_sensor::BinarySensor *binary_sensor) = 0; -#endif -#ifdef USE_COVER - virtual bool on_cover(cover::Cover *cover) = 0; -#endif -#ifdef USE_FAN - virtual bool on_fan(fan::Fan *fan) = 0; -#endif -#ifdef USE_LIGHT - virtual bool on_light(light::LightState *light) = 0; -#endif -#ifdef USE_SENSOR - virtual bool on_sensor(sensor::Sensor *sensor) = 0; -#endif -#ifdef USE_SWITCH - virtual bool on_switch(switch_::Switch *a_switch) = 0; -#endif -#ifdef USE_BUTTON - virtual bool on_button(button::Button *button) = 0; -#endif -#ifdef USE_TEXT_SENSOR - virtual bool on_text_sensor(text_sensor::TextSensor *text_sensor) = 0; -#endif +// Pure virtual entity callbacks (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) virtual bool on_##singular(type *obj) = 0; +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + ENTITY_TYPE_(type, singular, plural, count, upper) +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) +// Non-entity and non-pure-virtual callbacks (have default implementations) #ifdef USE_API_USER_DEFINED_ACTIONS virtual bool on_service(api::UserServiceDescriptor *service); #endif #ifdef USE_CAMERA virtual bool on_camera(camera::Camera *camera); -#endif -#ifdef USE_CLIMATE - virtual bool on_climate(climate::Climate *climate) = 0; -#endif -#ifdef USE_NUMBER - virtual bool on_number(number::Number *number) = 0; -#endif -#ifdef USE_DATETIME_DATE - virtual bool on_date(datetime::DateEntity *date) = 0; -#endif -#ifdef USE_DATETIME_TIME - virtual bool on_time(datetime::TimeEntity *time) = 0; -#endif -#ifdef USE_DATETIME_DATETIME - virtual bool on_datetime(datetime::DateTimeEntity *datetime) = 0; -#endif -#ifdef USE_TEXT - virtual bool on_text(text::Text *text) = 0; -#endif -#ifdef USE_SELECT - virtual bool on_select(select::Select *select) = 0; -#endif -#ifdef USE_LOCK - virtual bool on_lock(lock::Lock *a_lock) = 0; -#endif -#ifdef USE_VALVE - virtual bool on_valve(valve::Valve *valve) = 0; -#endif -#ifdef USE_MEDIA_PLAYER - virtual bool on_media_player(media_player::MediaPlayer *media_player); -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) = 0; -#endif -#ifdef USE_WATER_HEATER - virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; -#endif -#ifdef USE_INFRARED - virtual bool on_infrared(infrared::Infrared *infrared) = 0; -#endif -#ifdef USE_EVENT - virtual bool on_event(event::Event *event) = 0; -#endif -#ifdef USE_UPDATE - virtual bool on_update(update::UpdateEntity *update) = 0; #endif virtual bool on_end(); @@ -111,80 +57,19 @@ class ComponentIterator { enum class IteratorState : uint8_t { NONE = 0, BEGIN, -#ifdef USE_BINARY_SENSOR - BINARY_SENSOR, -#endif -#ifdef USE_COVER - COVER, -#endif -#ifdef USE_FAN - FAN, -#endif -#ifdef USE_LIGHT - LIGHT, -#endif -#ifdef USE_SENSOR - SENSOR, -#endif -#ifdef USE_SWITCH - SWITCH, -#endif -#ifdef USE_BUTTON - BUTTON, -#endif -#ifdef USE_TEXT_SENSOR - TEXT_SENSOR, -#endif +// Entity iterator states (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) upper, +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) upper, +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +// NOLINTEND(bugprone-macro-parentheses) #ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif #ifdef USE_CAMERA CAMERA, -#endif -#ifdef USE_CLIMATE - CLIMATE, -#endif -#ifdef USE_NUMBER - NUMBER, -#endif -#ifdef USE_DATETIME_DATE - DATETIME_DATE, -#endif -#ifdef USE_DATETIME_TIME - DATETIME_TIME, -#endif -#ifdef USE_DATETIME_DATETIME - DATETIME_DATETIME, -#endif -#ifdef USE_TEXT - TEXT, -#endif -#ifdef USE_SELECT - SELECT, -#endif -#ifdef USE_LOCK - LOCK, -#endif -#ifdef USE_VALVE - VALVE, -#endif -#ifdef USE_MEDIA_PLAYER - MEDIA_PLAYER, -#endif -#ifdef USE_ALARM_CONTROL_PANEL - ALARM_CONTROL_PANEL, -#endif -#ifdef USE_WATER_HEATER - WATER_HEATER, -#endif -#ifdef USE_INFRARED - INFRARED, -#endif -#ifdef USE_EVENT - EVENT, -#endif -#ifdef USE_UPDATE - UPDATE, #endif MAX, }; diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876d..018e05f17 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -242,6 +242,10 @@ PROJECT_MAX_LENGTH = 127 # Max board/model string length (must fit in single-byte varint for proto encoding) BOARD_MAX_LENGTH = 127 +# Keep in sync with ESPHOME_COMMENT_SIZE_MAX in esphome/core/application.h +# (C++ side includes the null terminator). +COMMENT_MAX_LEN = 255 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -275,7 +279,9 @@ CONFIG_SCHEMA = cv.All( cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, - cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), + cv.Optional(CONF_COMMENT): cv.All( + cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) + ), cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( { diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 632b46c89..09975b465 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -1,140 +1,19 @@ #pragma once -#include "esphome/core/defines.h" -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif -#ifdef USE_FAN -#include "esphome/components/fan/fan.h" -#endif -#ifdef USE_LIGHT -#include "esphome/components/light/light_state.h" -#endif -#ifdef USE_COVER -#include "esphome/components/cover/cover.h" -#endif -#ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "esphome/components/text_sensor/text_sensor.h" -#endif -#ifdef USE_SWITCH -#include "esphome/components/switch/switch.h" -#endif -#ifdef USE_BUTTON -#include "esphome/components/button/button.h" -#endif -#ifdef USE_CLIMATE -#include "esphome/components/climate/climate.h" -#endif -#ifdef USE_NUMBER -#include "esphome/components/number/number.h" -#endif -#ifdef USE_DATETIME_DATE -#include "esphome/components/datetime/date_entity.h" -#endif -#ifdef USE_DATETIME_TIME -#include "esphome/components/datetime/time_entity.h" -#endif -#ifdef USE_DATETIME_DATETIME -#include "esphome/components/datetime/datetime_entity.h" -#endif -#ifdef USE_TEXT -#include "esphome/components/text/text.h" -#endif -#ifdef USE_SELECT -#include "esphome/components/select/select.h" -#endif -#ifdef USE_LOCK -#include "esphome/components/lock/lock.h" -#endif -#ifdef USE_VALVE -#include "esphome/components/valve/valve.h" -#endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player.h" -#endif -#ifdef USE_ALARM_CONTROL_PANEL -#include "esphome/components/alarm_control_panel/alarm_control_panel.h" -#endif -#ifdef USE_WATER_HEATER -#include "esphome/components/water_heater/water_heater.h" -#endif -#ifdef USE_EVENT -#include "esphome/components/event/event.h" -#endif -#ifdef USE_UPDATE -#include "esphome/components/update/update_entity.h" -#endif +#include "esphome/core/entity_includes.h" namespace esphome { class Controller { public: -#ifdef USE_BINARY_SENSOR - virtual void on_binary_sensor_update(binary_sensor::BinarySensor *obj){}; -#endif -#ifdef USE_FAN - virtual void on_fan_update(fan::Fan *obj){}; -#endif -#ifdef USE_LIGHT - virtual void on_light_update(light::LightState *obj){}; -#endif -#ifdef USE_SENSOR - virtual void on_sensor_update(sensor::Sensor *obj){}; -#endif -#ifdef USE_SWITCH - virtual void on_switch_update(switch_::Switch *obj){}; -#endif -#ifdef USE_COVER - virtual void on_cover_update(cover::Cover *obj){}; -#endif -#ifdef USE_TEXT_SENSOR - virtual void on_text_sensor_update(text_sensor::TextSensor *obj){}; -#endif -#ifdef USE_CLIMATE - virtual void on_climate_update(climate::Climate *obj){}; -#endif -#ifdef USE_NUMBER - virtual void on_number_update(number::Number *obj){}; -#endif -#ifdef USE_DATETIME_DATE - virtual void on_date_update(datetime::DateEntity *obj){}; -#endif -#ifdef USE_DATETIME_TIME - virtual void on_time_update(datetime::TimeEntity *obj){}; -#endif -#ifdef USE_DATETIME_DATETIME - virtual void on_datetime_update(datetime::DateTimeEntity *obj){}; -#endif -#ifdef USE_TEXT - virtual void on_text_update(text::Text *obj){}; -#endif -#ifdef USE_SELECT - virtual void on_select_update(select::Select *obj){}; -#endif -#ifdef USE_LOCK - virtual void on_lock_update(lock::Lock *obj){}; -#endif -#ifdef USE_VALVE - virtual void on_valve_update(valve::Valve *obj){}; -#endif -#ifdef USE_MEDIA_PLAYER - virtual void on_media_player_update(media_player::MediaPlayer *obj){}; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - virtual void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj){}; -#endif -#ifdef USE_WATER_HEATER - virtual void on_water_heater_update(water_heater::WaterHeater *obj){}; -#endif -#ifdef USE_EVENT - virtual void on_event(event::Event *obj){}; -#endif -#ifdef USE_UPDATE - virtual void on_update(update::UpdateEntity *obj){}; -#endif +// Controller virtual methods (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) }; } // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 92f23f564..907e0f923 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -6,8 +6,6 @@ namespace esphome { StaticVector ControllerRegistry::controllers; -void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 846642da2..c6113116f 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -4,139 +4,13 @@ #ifdef USE_CONTROLLER_REGISTRY +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" -// Forward declarations namespace esphome { class Controller; -#ifdef USE_BINARY_SENSOR -namespace binary_sensor { -class BinarySensor; -} -#endif - -#ifdef USE_FAN -namespace fan { -class Fan; -} -#endif - -#ifdef USE_LIGHT -namespace light { -class LightState; -} -#endif - -#ifdef USE_SENSOR -namespace sensor { -class Sensor; -} -#endif - -#ifdef USE_SWITCH -namespace switch_ { -class Switch; -} -#endif - -#ifdef USE_COVER -namespace cover { -class Cover; -} -#endif - -#ifdef USE_TEXT_SENSOR -namespace text_sensor { -class TextSensor; -} -#endif - -#ifdef USE_CLIMATE -namespace climate { -class Climate; -} -#endif - -#ifdef USE_NUMBER -namespace number { -class Number; -} -#endif - -#ifdef USE_DATETIME_DATE -namespace datetime { -class DateEntity; -} -#endif - -#ifdef USE_DATETIME_TIME -namespace datetime { -class TimeEntity; -} -#endif - -#ifdef USE_DATETIME_DATETIME -namespace datetime { -class DateTimeEntity; -} -#endif - -#ifdef USE_TEXT -namespace text { -class Text; -} -#endif - -#ifdef USE_SELECT -namespace select { -class Select; -} -#endif - -#ifdef USE_LOCK -namespace lock { -class Lock; -} -#endif - -#ifdef USE_VALVE -namespace valve { -class Valve; -} -#endif - -#ifdef USE_MEDIA_PLAYER -namespace media_player { -class MediaPlayer; -} -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -namespace alarm_control_panel { -class AlarmControlPanel; -} -#endif - -#ifdef USE_WATER_HEATER -namespace water_heater { -class WaterHeater; -} -#endif - -#ifdef USE_EVENT -namespace event { -class Event; -} -#endif - -#ifdef USE_UPDATE -namespace update { -class UpdateEntity; -} -#endif - /** Global registry for Controllers to receive entity state updates. * * This singleton registry allows Controllers (APIServer, WebServer) to receive @@ -160,91 +34,17 @@ class ControllerRegistry { * Controllers should call this in their setup() method. * Typically only APIServer and WebServer register. */ - static void register_controller(Controller *controller); + static void register_controller(Controller *controller) { controllers.push_back(controller); } -#ifdef USE_BINARY_SENSOR - static void notify_binary_sensor_update(binary_sensor::BinarySensor *obj); -#endif - -#ifdef USE_FAN - static void notify_fan_update(fan::Fan *obj); -#endif - -#ifdef USE_LIGHT - static void notify_light_update(light::LightState *obj); -#endif - -#ifdef USE_SENSOR - static void notify_sensor_update(sensor::Sensor *obj); -#endif - -#ifdef USE_SWITCH - static void notify_switch_update(switch_::Switch *obj); -#endif - -#ifdef USE_COVER - static void notify_cover_update(cover::Cover *obj); -#endif - -#ifdef USE_TEXT_SENSOR - static void notify_text_sensor_update(text_sensor::TextSensor *obj); -#endif - -#ifdef USE_CLIMATE - static void notify_climate_update(climate::Climate *obj); -#endif - -#ifdef USE_NUMBER - static void notify_number_update(number::Number *obj); -#endif - -#ifdef USE_DATETIME_DATE - static void notify_date_update(datetime::DateEntity *obj); -#endif - -#ifdef USE_DATETIME_TIME - static void notify_time_update(datetime::TimeEntity *obj); -#endif - -#ifdef USE_DATETIME_DATETIME - static void notify_datetime_update(datetime::DateTimeEntity *obj); -#endif - -#ifdef USE_TEXT - static void notify_text_update(text::Text *obj); -#endif - -#ifdef USE_SELECT - static void notify_select_update(select::Select *obj); -#endif - -#ifdef USE_LOCK - static void notify_lock_update(lock::Lock *obj); -#endif - -#ifdef USE_VALVE - static void notify_valve_update(valve::Valve *obj); -#endif - -#ifdef USE_MEDIA_PLAYER - static void notify_media_player_update(media_player::MediaPlayer *obj); -#endif - -#ifdef USE_ALARM_CONTROL_PANEL - static void notify_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); -#endif - -#ifdef USE_WATER_HEATER - static void notify_water_heater_update(water_heater::WaterHeater *obj); -#endif - -#ifdef USE_EVENT - static void notify_event(event::Event *obj); -#endif - -#ifdef USE_UPDATE - static void notify_update(update::UpdateEntity *obj); -#endif +// Notify method declarations (generated from entity_types.h) +// NOLINTBEGIN(bugprone-macro-parentheses) +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + static void notify_##callback(type *obj); +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + // NOLINTEND(bugprone-macro-parentheses) protected: static StaticVector controllers; @@ -265,108 +65,18 @@ namespace esphome { // notify_frontend_(), eliminating an unnecessary function-call frame. // NOLINTBEGIN(bugprone-macro-parentheses) -#define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + inline void ControllerRegistry::notify_##callback(type *obj) { \ for (auto *controller : controllers) { \ - controller->on_##entity_name##_update(obj); \ - } \ - } - -#define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ - inline void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##entity_name(obj); \ + controller->on_##callback(obj); \ } \ } +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) -#ifdef USE_BINARY_SENSOR -CONTROLLER_REGISTRY_NOTIFY(binary_sensor::BinarySensor, binary_sensor) -#endif - -#ifdef USE_FAN -CONTROLLER_REGISTRY_NOTIFY(fan::Fan, fan) -#endif - -#ifdef USE_LIGHT -CONTROLLER_REGISTRY_NOTIFY(light::LightState, light) -#endif - -#ifdef USE_SENSOR -CONTROLLER_REGISTRY_NOTIFY(sensor::Sensor, sensor) -#endif - -#ifdef USE_SWITCH -CONTROLLER_REGISTRY_NOTIFY(switch_::Switch, switch) -#endif - -#ifdef USE_COVER -CONTROLLER_REGISTRY_NOTIFY(cover::Cover, cover) -#endif - -#ifdef USE_TEXT_SENSOR -CONTROLLER_REGISTRY_NOTIFY(text_sensor::TextSensor, text_sensor) -#endif - -#ifdef USE_CLIMATE -CONTROLLER_REGISTRY_NOTIFY(climate::Climate, climate) -#endif - -#ifdef USE_NUMBER -CONTROLLER_REGISTRY_NOTIFY(number::Number, number) -#endif - -#ifdef USE_DATETIME_DATE -CONTROLLER_REGISTRY_NOTIFY(datetime::DateEntity, date) -#endif - -#ifdef USE_DATETIME_TIME -CONTROLLER_REGISTRY_NOTIFY(datetime::TimeEntity, time) -#endif - -#ifdef USE_DATETIME_DATETIME -CONTROLLER_REGISTRY_NOTIFY(datetime::DateTimeEntity, datetime) -#endif - -#ifdef USE_TEXT -CONTROLLER_REGISTRY_NOTIFY(text::Text, text) -#endif - -#ifdef USE_SELECT -CONTROLLER_REGISTRY_NOTIFY(select::Select, select) -#endif - -#ifdef USE_LOCK -CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) -#endif - -#ifdef USE_VALVE -CONTROLLER_REGISTRY_NOTIFY(valve::Valve, valve) -#endif - -#ifdef USE_MEDIA_PLAYER -CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) -#endif - -#ifdef USE_ALARM_CONTROL_PANEL -CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) -#endif - -#ifdef USE_WATER_HEATER -CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) -#endif - -#ifdef USE_EVENT -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) -#endif - -#ifdef USE_UPDATE -CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(update::UpdateEntity, update) -#endif - -#undef CONTROLLER_REGISTRY_NOTIFY -#undef CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX - } // namespace esphome #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d8b4faced..f929b224c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK @@ -64,6 +65,7 @@ #define USE_INFRARED #define USE_IR_RF #define USE_JSON +#define USE_RADIO_FREQUENCY #define USE_LIGHT #define USE_LIGHT_GAMMA_LUT #define USE_LOCK @@ -134,6 +136,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER @@ -177,11 +180,13 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 +#define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG @@ -254,6 +259,11 @@ #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN +#define USE_SENDSPIN_ARTWORK +#define USE_SENDSPIN_CONTROLLER +#define USE_SENDSPIN_METADATA +#define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_LWIP_FAST_SELECT @@ -284,6 +294,7 @@ #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 +#define USB_UART_OUTPUT_CHUNK_COUNT 5 #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) @@ -318,6 +329,7 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_ZIGBEE #endif #endif @@ -445,6 +457,7 @@ #define ESPHOME_ENTITY_LOCK_COUNT 1 #define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 #define ESPHOME_ENTITY_NUMBER_COUNT 1 +#define ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT 1 #define ESPHOME_ENTITY_SELECT_COUNT 1 #define ESPHOME_ENTITY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_SWITCH_COUNT 1 diff --git a/esphome/core/entity_includes.h b/esphome/core/entity_includes.h new file mode 100644 index 000000000..b1310e114 --- /dev/null +++ b/esphome/core/entity_includes.h @@ -0,0 +1,82 @@ +#pragma once + +// Shared entity component includes. +// Conditionally includes headers for all entity types based on USE_* defines. + +#include "esphome/core/defines.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_COVER +#include "esphome/components/cover/cover.h" +#endif +#ifdef USE_FAN +#include "esphome/components/fan/fan.h" +#endif +#ifdef USE_LIGHT +#include "esphome/components/light/light_state.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_DATETIME_DATE +#include "esphome/components/datetime/date_entity.h" +#endif +#ifdef USE_DATETIME_TIME +#include "esphome/components/datetime/time_entity.h" +#endif +#ifdef USE_DATETIME_DATETIME +#include "esphome/components/datetime/datetime_entity.h" +#endif +#ifdef USE_TEXT +#include "esphome/components/text/text.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_LOCK +#include "esphome/components/lock/lock.h" +#endif +#ifdef USE_VALVE +#include "esphome/components/valve/valve.h" +#endif +#ifdef USE_MEDIA_PLAYER +#include "esphome/components/media_player/media_player.h" +#endif +#ifdef USE_ALARM_CONTROL_PANEL +#include "esphome/components/alarm_control_panel/alarm_control_panel.h" +#endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif +#ifdef USE_SERIAL_PROXY +#include "esphome/components/serial_proxy/serial_proxy.h" +#endif +#ifdef USE_EVENT +#include "esphome/components/event/event.h" +#endif +#ifdef USE_UPDATE +#include "esphome/components/update/update_entity.h" +#endif diff --git a/esphome/core/entity_types.h b/esphome/core/entity_types.h new file mode 100644 index 000000000..f830911c0 --- /dev/null +++ b/esphome/core/entity_types.h @@ -0,0 +1,102 @@ +// X-macro include file for entity type declarations. +// This file is included multiple times with different macro definitions. +// +// Both macros must be defined before including this file: +// +// ENTITY_TYPE_(type, singular, plural, count, upper) +// — entities without controller callbacks (button, infrared) +// +// ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) +// — entities with controller callbacks +// +// Excluded from this list (handled manually): +// - devices/areas: not entities +// - serial_proxy: custom register logic, no by-key lookup + +#ifndef ENTITY_TYPE_ +#error "ENTITY_TYPE_(type, singular, plural, count, upper) must be defined before including entity_types.h" +#endif +#ifndef ENTITY_CONTROLLER_TYPE_ +#error \ + "ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) must be defined before including entity_types.h" +#endif + +#ifdef USE_BINARY_SENSOR +ENTITY_CONTROLLER_TYPE_(binary_sensor::BinarySensor, binary_sensor, binary_sensors, ESPHOME_ENTITY_BINARY_SENSOR_COUNT, + BINARY_SENSOR, binary_sensor_update) +#endif +#ifdef USE_COVER +ENTITY_CONTROLLER_TYPE_(cover::Cover, cover, covers, ESPHOME_ENTITY_COVER_COUNT, COVER, cover_update) +#endif +#ifdef USE_FAN +ENTITY_CONTROLLER_TYPE_(fan::Fan, fan, fans, ESPHOME_ENTITY_FAN_COUNT, FAN, fan_update) +#endif +#ifdef USE_LIGHT +ENTITY_CONTROLLER_TYPE_(light::LightState, light, lights, ESPHOME_ENTITY_LIGHT_COUNT, LIGHT, light_update) +#endif +#ifdef USE_SENSOR +ENTITY_CONTROLLER_TYPE_(sensor::Sensor, sensor, sensors, ESPHOME_ENTITY_SENSOR_COUNT, SENSOR, sensor_update) +#endif +#ifdef USE_SWITCH +ENTITY_CONTROLLER_TYPE_(switch_::Switch, switch, switches, ESPHOME_ENTITY_SWITCH_COUNT, SWITCH, switch_update) +#endif +#ifdef USE_BUTTON +ENTITY_TYPE_(button::Button, button, buttons, ESPHOME_ENTITY_BUTTON_COUNT, BUTTON) +#endif +#ifdef USE_TEXT_SENSOR +ENTITY_CONTROLLER_TYPE_(text_sensor::TextSensor, text_sensor, text_sensors, ESPHOME_ENTITY_TEXT_SENSOR_COUNT, + TEXT_SENSOR, text_sensor_update) +#endif +#ifdef USE_CLIMATE +ENTITY_CONTROLLER_TYPE_(climate::Climate, climate, climates, ESPHOME_ENTITY_CLIMATE_COUNT, CLIMATE, climate_update) +#endif +#ifdef USE_NUMBER +ENTITY_CONTROLLER_TYPE_(number::Number, number, numbers, ESPHOME_ENTITY_NUMBER_COUNT, NUMBER, number_update) +#endif +#ifdef USE_DATETIME_DATE +ENTITY_CONTROLLER_TYPE_(datetime::DateEntity, date, dates, ESPHOME_ENTITY_DATE_COUNT, DATETIME_DATE, date_update) +#endif +#ifdef USE_DATETIME_TIME +ENTITY_CONTROLLER_TYPE_(datetime::TimeEntity, time, times, ESPHOME_ENTITY_TIME_COUNT, DATETIME_TIME, time_update) +#endif +#ifdef USE_DATETIME_DATETIME +ENTITY_CONTROLLER_TYPE_(datetime::DateTimeEntity, datetime, datetimes, ESPHOME_ENTITY_DATETIME_COUNT, DATETIME_DATETIME, + datetime_update) +#endif +#ifdef USE_TEXT +ENTITY_CONTROLLER_TYPE_(text::Text, text, texts, ESPHOME_ENTITY_TEXT_COUNT, TEXT, text_update) +#endif +#ifdef USE_SELECT +ENTITY_CONTROLLER_TYPE_(select::Select, select, selects, ESPHOME_ENTITY_SELECT_COUNT, SELECT, select_update) +#endif +#ifdef USE_LOCK +ENTITY_CONTROLLER_TYPE_(lock::Lock, lock, locks, ESPHOME_ENTITY_LOCK_COUNT, LOCK, lock_update) +#endif +#ifdef USE_VALVE +ENTITY_CONTROLLER_TYPE_(valve::Valve, valve, valves, ESPHOME_ENTITY_VALVE_COUNT, VALVE, valve_update) +#endif +#ifdef USE_MEDIA_PLAYER +ENTITY_CONTROLLER_TYPE_(media_player::MediaPlayer, media_player, media_players, ESPHOME_ENTITY_MEDIA_PLAYER_COUNT, + MEDIA_PLAYER, media_player_update) +#endif +#ifdef USE_ALARM_CONTROL_PANEL +ENTITY_CONTROLLER_TYPE_(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels, + ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT, ALARM_CONTROL_PANEL, alarm_control_panel_update) +#endif +#ifdef USE_WATER_HEATER +ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters, ESPHOME_ENTITY_WATER_HEATER_COUNT, + WATER_HEATER, water_heater_update) +#endif +#ifdef USE_INFRARED +ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED) +#endif +#ifdef USE_RADIO_FREQUENCY +ENTITY_TYPE_(radio_frequency::RadioFrequency, radio_frequency, radio_frequencies, ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT, + RADIO_FREQUENCY) +#endif +#ifdef USE_EVENT +ENTITY_CONTROLLER_TYPE_(event::Event, event, events, ESPHOME_ENTITY_EVENT_COUNT, EVENT, event) +#endif +#ifdef USE_UPDATE +ENTITY_CONTROLLER_TYPE_(update::UpdateEntity, update, updates, ESPHOME_ENTITY_UPDATE_COUNT, UPDATE, update) +#endif diff --git a/esphome/core/freertos_queue.h b/esphome/core/freertos_queue.h new file mode 100644 index 000000000..2f3faf818 --- /dev/null +++ b/esphome/core/freertos_queue.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + +#include +#include + +#include +#include + +/* + * FreeRTOS queue wrapper for single-producer single-consumer scenarios on + * platforms without hardware atomic support (e.g. BK72xx ARM968E-S). + * + * Provides the same API as LockFreeQueue (push, pop, get_and_reset_dropped_count, + * empty, full, size) but uses xQueue internally, which synchronizes via + * FreeRTOS critical sections. Uses xQueueCreateStatic so the queue storage + * lives in BSS with zero runtime heap allocation. + * + * @tparam T The type of elements stored in the queue (stored as pointers) + * @tparam SIZE The maximum number of elements + */ + +namespace esphome { + +template class FreeRTOSQueue { + public: + FreeRTOSQueue() : dropped_count_(0) { + this->handle_ = xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); + } + + // No destructor — ESPHome components are never destroyed. Intentionally + // omitted to avoid pulling in vQueueDelete code on resource-constrained targets. + + // Non-copyable, non-movable — queue handle is not transferable + FreeRTOSQueue(const FreeRTOSQueue &) = delete; + FreeRTOSQueue &operator=(const FreeRTOSQueue &) = delete; + FreeRTOSQueue(FreeRTOSQueue &&) = delete; + FreeRTOSQueue &operator=(FreeRTOSQueue &&) = delete; + + bool push(T *element) { + if (element == nullptr) + return false; + + if (xQueueSend(this->handle_, &element, 0) != pdPASS) { + this->increment_dropped_count(); + return false; + } + return true; + } + + T *pop() { + T *element; + if (xQueueReceive(this->handle_, &element, 0) != pdTRUE) { + return nullptr; + } + return element; + } + + uint16_t get_and_reset_dropped_count() { + // Fast path: plain read of aligned uint16_t is a single ARM load instruction. + // Worst case is reading a stale zero and reporting drops one iteration later. + // Avoids critical section overhead on every loop() call since drops are rare. + if (this->dropped_count_ == 0) + return 0; + // Declare outside critical section — BK72xx portENTER_CRITICAL may introduce a scope + uint16_t count; + portENTER_CRITICAL(); + count = this->dropped_count_; + this->dropped_count_ = 0; + portEXIT_CRITICAL(); + return count; + } + + void increment_dropped_count() { + portENTER_CRITICAL(); + this->dropped_count_++; + portEXIT_CRITICAL(); + } + + bool empty() const { return uxQueueMessagesWaiting(this->handle_) == 0; } + + bool full() const { return uxQueueSpacesAvailable(this->handle_) == 0; } + + size_t size() const { return uxQueueMessagesWaiting(this->handle_); } + + protected: + // Static storage for the queue — lives in BSS, no heap allocation + uint8_t storage_[SIZE * sizeof(T *)]; + StaticQueue_t queue_buf_; + QueueHandle_t handle_; + uint16_t dropped_count_; +}; + +} // namespace esphome + +#endif // ESPHOME_THREAD_MULTI_NO_ATOMICS diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b745..e4083622b 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,35 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#elif defined(USE_LIBRETINY) + +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) +#define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif +#define PROGMEM + #else #define IRAM_ATTR @@ -28,8 +57,51 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + +#ifdef USE_BK72XX +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + namespace esphome { +/// Returns true when executing inside an interrupt handler. +/// always_inline so callers placed in IRAM keep the detection in IRAM. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_ESP32) + return xPortInIsrContext() != 0; +#elif defined(USE_ESP8266) + // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is + // non-zero both in a real ISR and when user code masks interrupts. The + // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule + // which is ISR-safe) so this helper is unused on this platform. + return false; +#elif defined(USE_RP2040) + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#elif defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. + return platform_is_in_interrupt_context() != 0; +#elif defined(USE_LIBRETINY) + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#else + // Host and any future platform without an ISR concept. + return false; +#endif +} + void yield(); uint32_t millis(); uint64_t millis_64(); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5940f6ec9..e71da95e6 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -221,31 +221,7 @@ bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffi return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; } -std::string str_truncate(const std::string &str, size_t length) { - return str.length() > length ? str.substr(0, length) : str; -} -std::string str_until(const char *str, char ch) { - const char *pos = strchr(str, ch); - return pos == nullptr ? std::string(str) : std::string(str, pos - str); -} -std::string str_until(const std::string &str, char ch) { return str.substr(0, str.find(ch)); } -// wrapper around std::transform to run safely on functions from the ctype.h header -// see https://en.cppreference.com/w/cpp/string/byte/toupper#Notes -template std::string str_ctype_transform(const std::string &str) { - std::string result; - result.resize(str.length()); - std::transform(str.begin(), str.end(), result.begin(), [](unsigned char ch) { return fn(ch); }); - return result; -} -std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_upper_case(const std::string &str) { return str_ctype_transform(str); } -std::string str_snake_case(const std::string &str) { - std::string result = str; - for (char &c : result) { - c = to_snake_case_char(c); - } - return result; -} +// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { return buffer; @@ -258,41 +234,7 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -std::string str_sanitize(const std::string &str) { - std::string result; - result.resize(str.size()); - str_sanitize_to(&result[0], str.size() + 1, str.c_str()); - return result; -} -std::string str_snprintf(const char *fmt, size_t len, ...) { - std::string str; - va_list args; - - str.resize(len); - va_start(args, len); - size_t out_length = vsnprintf(&str[0], len + 1, fmt, args); - va_end(args); - - if (out_length < len) - str.resize(out_length); - - return str; -} -std::string str_sprintf(const char *fmt, ...) { - std::string str; - va_list args; - - va_start(args, fmt); - size_t length = vsnprintf(nullptr, 0, fmt, args); - va_end(args); - - str.resize(length); - va_start(args, fmt); - vsnprintf(&str[0], length + 1, fmt, args); - va_end(args); - - return str; -} +// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp // Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; @@ -341,23 +283,20 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; - format_mac_addr_upper(mac, buf); - return std::string(buf); -} +// format_mac_address_pretty moved to alloc_helpers.cpp -// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase +// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. +// When separator is set, it is written unconditionally after each byte and the last +// one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator, char base) { - if (length == 0) { - buffer[0] = '\0'; + if (length == 0 || buffer_size == 0) { + if (buffer_size > 0) + buffer[0] = '\0'; return buffer; } - // With separator: total length is 3*length (2*length hex chars, (length-1) separators, 1 null terminator) - // Without separator: total length is 2*length + 1 (2*length hex chars, 1 null terminator) uint8_t stride = separator ? 3 : 2; - size_t max_bytes = separator ? (buffer_size / stride) : ((buffer_size - 1) / stride); + size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2); if (max_bytes == 0) { buffer[0] = '\0'; return buffer; @@ -369,25 +308,35 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t size_t pos = i * stride; buffer[pos] = format_hex_char(data[i] >> 4, base); buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); - if (separator && i < length - 1) { + if (separator) { buffer[pos + 2] = separator; } } + // With separator: overwrite last separator with '\0' + // Without: write '\0' after last hex char buffer[length * stride - (separator ? 1 : 0)] = '\0'; return buffer; } +char *uint32_to_str_unchecked(char *buf, uint32_t val) { + if (val == 0) { + *buf++ = '0'; + return buf; + } + char *start = buf; + while (val > 0) { + *buf++ = '0' + (val % 10); + val /= 10; + } + std::reverse(start, buf); + return buf; +} + char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } -std::string format_hex(const uint8_t *data, size_t length) { - std::string ret; - ret.resize(length * 2); - format_hex_to(&ret[0], length * 2 + 1, data, length); - return ret; -} -std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } +// format_hex (std::string returning overloads) moved to alloc_helpers.cpp char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); @@ -424,43 +373,7 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// Shared implementation for uint8_t and string hex formatting -static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 3 - 1) : (length * 2); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} - -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { - return format_hex_pretty_uint8(data, length, separator, show_length); -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} - -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { - if (data == nullptr || length == 0) - return ""; - std::string ret; - size_t hex_len = separator ? (length * 5 - 1) : (length * 4); - ret.resize(hex_len); - format_hex_pretty_to(&ret[0], hex_len + 1, data, length, separator); - if (show_length && length > 4) - return ret + " (" + std::to_string(length) + ")"; - return ret; -} -std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { - return format_hex_pretty(data.data(), data.size(), separator, show_length); -} -std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { - return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); -} +// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { @@ -483,12 +396,7 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -std::string format_bin(const uint8_t *data, size_t length) { - std::string result; - result.resize(length * 8); - format_bin_to(&result[0], length * 8 + 1, data, length); - return result; -} +// format_bin moved to alloc_helpers.cpp ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) @@ -505,6 +413,23 @@ ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { return PARSE_NONE; } +int8_t ilog10(float value) { + float abs_val = fabsf(value); + int8_t exp = 0; + if (abs_val >= 10.0f) { + while (abs_val >= 10.0f) { + abs_val /= 10.0f; + exp++; + } + } else if (abs_val < 1.0f) { + while (abs_val < 1.0f) { + abs_val *= 10.0f; + exp--; + } + } + return exp; +} + static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_decimals) { if (accuracy_decimals < 0) { float divisor; @@ -520,34 +445,60 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); +// value_accuracy_to_string moved to alloc_helpers.cpp + +// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. +// Caller must guarantee value is finite and |value| * mult fits in uint32_t. +static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { + char *p = buf; + if (std::signbit(value)) { + *p++ = '-'; + value = -value; + } + // Cast to double for the multiply to match snprintf's rounding precision. + // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, + // but snprintf sees 234.500007... via double promotion and rounds differently). + // llrint returns long long so the result fits even on 32-bit targets where + // long is 32-bit; caller has already bounded |value * mult| to UINT32_MAX. + uint32_t scaled = static_cast(llrint(static_cast(value) * mult)); + p = uint32_to_str_unchecked(p, scaled / mult); + if (accuracy_decimals > 0) { + *p++ = '.'; + p = frac_to_str_unchecked(p, scaled % mult, mult / 10); + } + *p = '\0'; + return static_cast(p - buf); } size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error + + // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. + // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. + if (accuracy_decimals <= 3 && std::isfinite(value)) { + const uint32_t mult = small_pow10(accuracy_decimals); + if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { + return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); + } + } + + // Fallback for NaN/Inf/high accuracy/out-of-range int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 + return 0; return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); } size_t value_accuracy_with_uom_to_buf(std::span buf, float value, int8_t accuracy_decimals, StringRef unit_of_measurement) { - if (unit_of_measurement.empty()) { - return value_accuracy_to_buf(buf, value, accuracy_decimals); + size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals); + if (len == 0 || unit_of_measurement.empty()) { + return len; } - normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error - int len = snprintf(buf.data(), buf.size(), "%.*f %s", accuracy_decimals, value, unit_of_measurement.c_str()); - if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 - return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); + char *end = buf_append_sep_str(buf.data() + len, buf.size() - len, ' ', unit_of_measurement.c_str(), + unit_of_measurement.size()); + return static_cast(end - buf.data()); } int8_t step_to_accuracy_decimals(float step) { @@ -589,45 +540,7 @@ static inline uint8_t base64_find_char(char c) { // Check if character is valid base64 or base64url static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } -std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } - -// Encode 3 input bytes to 4 base64 characters, append 'count' to ret. -static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { - char char_array_4[4]; - char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; - char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); - char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); - char_array_4[3] = char_array_3[2] & 0x3f; - - for (int j = 0; j < count; j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; -} - -std::string base64_encode(const uint8_t *buf, size_t buf_len) { - std::string ret; - int i = 0; - char char_array_3[3]; - - while (buf_len--) { - char_array_3[i++] = *(buf++); - if (i == 3) { - base64_encode_triple(char_array_3, 4, ret); - i = 0; - } - } - - if (i) { - for (int j = i; j < 3; j++) - char_array_3[j] = '\0'; - - base64_encode_triple(char_array_3, i + 1, ret); - - while ((i++ < 3)) - ret += '='; - } - - return ret; -} +// base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); @@ -688,14 +601,7 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b 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; -} +// base64_decode (vector-returning overload) moved to alloc_helpers.cpp /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) @@ -834,18 +740,7 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -std::string get_mac_address() { - uint8_t mac[6]; - get_mac_address_raw(mac); - char buf[13]; - format_mac_addr_lower_no_sep(mac, buf); - return std::string(buf); -} - -std::string get_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(get_mac_address_pretty_into_buffer(buf)); -} +// get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp void get_mac_address_into_buffer(std::span buf) { uint8_t mac[6]; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c26bbe17b..4a91c4607 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -21,6 +21,12 @@ #include "esphome/core/optional.h" +// Backward compatibility re-export of heap-allocating helpers. +// These functions have moved to alloc_helpers.h. External components should +// update their includes to use #include "esphome/core/alloc_helpers.h" directly. +// This re-export will be removed in 2026.11.0. +#include "esphome/core/alloc_helpers.h" + #ifdef USE_ESP8266 #include #include @@ -734,6 +740,11 @@ template class SmallBufferWithHeapFallb /// @name Mathematics ///@{ +/// Compute floor(log10(fabs(value))) using iterative comparison. +/// Avoids pulling in __ieee754_logf/log10f (~1KB flash). +/// Only valid for finite, non-zero values. +int8_t ilog10(float value); + /// Compute 10^exp using iterative multiplication/division. /// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT /// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT @@ -979,27 +990,13 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); } -/// Truncate a string to a specific length. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_truncate(const std::string &str, size_t length); +// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 -/// Extract the part of the string until either the first occurrence of the specified character, or the end -/// (requires str to be null-terminated). -std::string str_until(const char *str, char ch); -/// Extract the part of the string until either the first occurrence of the specified character, or the end. -std::string str_until(const std::string &str, char ch); - -/// Convert the string to lower case. -std::string str_lower_case(const std::string &str); -/// Convert the string to upper case. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_upper_case(const std::string &str); +// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -/// Convert the string to snake case (lowercase with underscores). -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -std::string str_snake_case(const std::string &str); +// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1022,9 +1019,7 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. -/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. -std::string str_sanitize(const std::string &str); +// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. @@ -1040,13 +1035,7 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); - -/// sprintf-like function returning std::string. -/// @warning Allocates heap memory. Use snprintf() with a stack buffer instead. -std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); +// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) @@ -1095,7 +1084,33 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif -/// Safely append a string to buffer without format parsing, returning new position (capped at size). +#ifdef USE_ESP8266 +/// Safely append a PROGMEM string to buffer, returning new position (capped at size). +/// ESP8266 internal implementation — prefer the `buf_append_str` macro which wraps +/// literals with `PSTR()` automatically so they stay in flash instead of eating RAM. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str PROGMEM-resident string to append (must not be null) +/// @return New position after appending; returns `size` if `pos >= size`, otherwise +/// returns at most `size - 1` because one byte is reserved for the null terminator +inline size_t buf_append_str_p(char *buf, size_t size, size_t pos, PGM_P str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strnlen_P(str, remaining); + memcpy_P(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} +/// Safely append a string to buffer, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// On ESP8266 the literal is wrapped with PSTR() so it stays in flash. +#define buf_append_str(buf, size, pos, str) buf_append_str_p(buf, size, pos, PSTR(str)) +#else +/// Safely append a string to buffer, returning new position (capped at size). /// More efficient than buf_append_printf for plain string literals. /// @param buf Output buffer /// @param size Total buffer size @@ -1107,15 +1122,16 @@ inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str return size; } size_t remaining = size - pos - 1; // reserve space for null terminator - size_t len = strlen(str); - if (len > remaining) { - len = remaining; + size_t len = 0; + while (len < remaining && str[len] != '\0') { + len++; } memcpy(buf + pos, str, len); pos += len; buf[pos] = '\0'; return pos; } +#endif /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. @@ -1263,13 +1279,13 @@ constexpr uint8_t parse_hex_char(char c) { } /// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase) -inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } /// Convert a nibble (0-15) to lowercase hex char -inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) -inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. @@ -1295,6 +1311,56 @@ inline char *int8_to_str(char *buf, int8_t val) { return buf; } +/// Append a separator char and a string to a buffer, respecting remaining space. +/// Returns pointer past last char written. The buffer is always null-terminated +/// when remaining >= 1 (even on the no-room early-return), so callers always get +/// a valid C string. +inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) { + if (remaining < 2) { + if (remaining >= 1) { + *buf = '\0'; + } + return buf; + } + *buf++ = separator; + remaining--; + size_t copy_len = std::min(str_len, remaining - 1); + memcpy(buf, str, copy_len); + buf += copy_len; + *buf = '\0'; + return buf; +} + +/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } + +/// Minimum buffer size for uint32_to_str: 10 digits + null terminator. +static constexpr size_t UINT32_MAX_STR_SIZE = 11; + +/// Write unsigned 32-bit integer to buffer (internal, no size check). +/// Buffer must have at least 10 bytes free. Returns pointer past last char written. +char *uint32_to_str_unchecked(char *buf, uint32_t val); + +/// Write unsigned 32-bit integer to buffer with compile-time size check. +/// Null-terminates the output. Returns number of chars written (excluding null). +inline size_t uint32_to_str(std::span buf, uint32_t val) { + char *end = uint32_to_str_unchecked(buf.data(), val); + *end = '\0'; + return static_cast(end - buf.data()); +} + +/// Write fractional digits with leading zeros to buffer (internal, no size check). +/// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits). +/// Returns pointer past last char written. +inline char *frac_to_str_unchecked(char *buf, uint32_t frac, uint32_t divisor) { + while (divisor > 0) { + *buf++ = '0' + static_cast(frac / divisor); + frac %= divisor; + divisor /= 10; + } + return buf; +} + /// Format byte array as lowercase hex to buffer (base implementation). char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length); @@ -1426,189 +1492,26 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -/// Format the six-byte array \p mac into a MAC address. -/// @warning Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_mac_address_pretty(const uint8_t mac[6]); -/// Format the byte array \p data of length \p len in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const uint8_t *data, size_t length); -/// Format the vector \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_hex(const std::vector &data); +// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overloads below before 2026.11.0 + /// Format an unsigned integer in lowercased hex, starting with the most significant byte. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_hex(T val) { val = convert_big_endian(val); return format_hex(reinterpret_cast(&val), sizeof(T)); } /// Format the std::array \p data in lowercased hex. /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template std::string format_hex(const std::array &data) { return format_hex(data.data(), data.size()); } -/** Format a byte array in pretty-printed, human-readable hex format. - * - * Converts binary data to a hexadecimal string representation with customizable formatting. - * Each byte is displayed as a two-digit uppercase hex value, separated by the specified separator. - * Optionally includes the total byte count in parentheses at the end. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the byte array to format. - * @param length Number of bytes in the array. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string, e.g., "A1.B2.C3.D4.E5 (5)" or "A1:B2:C3" depending on parameters. - * - * @note Returns empty string if data is nullptr or length is 0. - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint8_t data[] = {0xA1, 0xB2, 0xC3}; - * format_hex_pretty(data, 3); // Returns "A1.B2.C3" (no length shown for <= 4 parts) - * uint8_t data2[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; - * format_hex_pretty(data2, 5); // Returns "A1.B2.C3.D4.E5 (5)" - * format_hex_pretty(data2, 5, ':'); // Returns "A1:B2:C3:D4:E5 (5)" - * format_hex_pretty(data2, 5, '.', false); // Returns "A1.B2.C3.D4.E5" - * @endcode - */ -std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); +// format_hex_pretty (all overloads) moved to alloc_helpers.h +// Remove this comment and the template overload below before 2026.11.0 -/** Format a 16-bit word array in pretty-printed, human-readable hex format. - * - * Similar to the byte array version, but formats 16-bit words as 4-digit hex values. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Pointer to the 16-bit word array to format. - * @param length Number of 16-bit words in the array. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string with 4-digit hex values per word. - * - * @note The length will only be appended if show_length is true AND the length is greater than 4. - * - * Example: - * @code - * uint16_t data[] = {0xA1B2, 0xC3D4}; - * format_hex_pretty(data, 2); // Returns "A1B2.C3D4" (no length shown for <= 4 parts) - * uint16_t data2[] = {0xA1B2, 0xC3D4, 0xE5F6}; - * format_hex_pretty(data2, 3); // Returns "A1B2.C3D4.E5F6 (3)" - * @endcode - */ -std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); - -/** Format a byte vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Formats each byte as a two-digit - * uppercase hex value with customizable separator. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of bytes to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0xDE, 0xAD, 0xBE, 0xEF}; - * format_hex_pretty(data); // Returns "DE.AD.BE.EF" (no length shown for <= 4 parts) - * std::vector data2 = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA}; - * format_hex_pretty(data2); // Returns "DE.AD.BE.EF.CA (5)" - * format_hex_pretty(data2, '-'); // Returns "DE-AD-BE-EF-CA (5)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a 16-bit word vector in pretty-printed, human-readable hex format. - * - * Convenience overload for std::vector. Each 16-bit word is formatted - * as a 4-digit uppercase hex value in big-endian order. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data Vector of 16-bit words to format. - * @param separator Character to use between hex words (default: '.'). - * @param show_length Whether to append the word count in parentheses (default: true). - * @return Formatted hex string representation of the vector contents. - * - * @note The length will only be appended if show_length is true AND the vector size is greater than 4. - * - * Example: - * @code - * std::vector data = {0x1234, 0x5678}; - * format_hex_pretty(data); // Returns "1234.5678" (no length shown for <= 4 parts) - * std::vector data2 = {0x1234, 0x5678, 0x9ABC}; - * format_hex_pretty(data2); // Returns "1234.5678.9ABC (3)" - * @endcode - */ -std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); - -/** Format a string's bytes in pretty-printed, human-readable hex format. - * - * Treats each character in the string as a byte and formats it in hex. - * Useful for debugging binary data stored in std::string containers. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @param data String whose bytes should be formatted as hex. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string representation of the string's byte contents. - * - * @note The length will only be appended if show_length is true AND the string length is greater than 4. - * - * Example: - * @code - * std::string data = "ABC"; // ASCII: 0x41, 0x42, 0x43 - * format_hex_pretty(data); // Returns "41.42.43" (no length shown for <= 4 parts) - * std::string data2 = "ABCDE"; - * format_hex_pretty(data2); // Returns "41.42.43.44.45 (5)" - * @endcode - */ -std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); - -/** Format an unsigned integer in pretty-printed, human-readable hex format. - * - * Converts the integer to big-endian byte order and formats each byte as hex. - * The most significant byte appears first in the output string. - * - * @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. - * Causes heap fragmentation on long-running devices. - * - * @tparam T Unsigned integer type (uint8_t, uint16_t, uint32_t, uint64_t, etc.). - * @param val The unsigned integer value to format. - * @param separator Character to use between hex bytes (default: '.'). - * @param show_length Whether to append the byte count in parentheses (default: true). - * @return Formatted hex string with most significant byte first. - * - * @note The length will only be appended if show_length is true AND sizeof(T) is greater than 4. - * - * Example: - * @code - * uint32_t value = 0x12345678; - * format_hex_pretty(value); // Returns "12.34.56.78" (no length shown for <= 4 parts) - * uint64_t value2 = 0x123456789ABCDEF0; - * format_hex_pretty(value2); // Returns "12.34.56.78.9A.BC.DE.F0 (8)" - * format_hex_pretty(value2, ':'); // Returns "12:34:56:78:9A:BC:DE:F0 (8)" - * format_hex_pretty(0x1234); // Returns "12.34" - * @endcode - */ +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. template::value, int> = 0> std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { val = convert_big_endian(val); @@ -1668,13 +1571,10 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -/// Format the byte array \p data of length \p len in binary. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. -std::string format_bin(const uint8_t *data, size_t length); +// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 + /// Format an unsigned integer in binary, starting with the most significant byte. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_bin(T val) { val = convert_big_endian(val); return format_bin(reinterpret_cast(&val), sizeof(T)); @@ -1690,9 +1590,7 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -ESPDEPRECATED("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0.", "2026.1.0") -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals); +// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1706,10 +1604,8 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -std::string base64_encode(const uint8_t *buf, size_t buf_len); -std::string base64_encode(const std::vector &buf); - -std::vector base64_decode(const std::string &encoded_string); +// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h +// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2145,15 +2041,7 @@ 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) -/// Get the device MAC address as a string, in lowercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_into_buffer() instead. -std::string get_mac_address(); - -/// Get the device MAC address as a string, in colon-separated uppercase hex notation. -/// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. -/// Use get_mac_address_pretty_into_buffer() instead. -std::string get_mac_address_pretty(); +// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index bb3acbafc..36000d4e7 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,17 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +#ifdef USE_OTA_PLATFORM_ESPHOME +static struct netconn *s_ota_listener_conn = NULL; +extern void esphome_wake_ota_component_any_context(void); + +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { + s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; +} +#else +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; } +#endif + // Wrapper callback: calls original event_callback + notifies main loop task. // Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { @@ -171,6 +182,13 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { +#ifdef USE_OTA_PLATFORM_ESPHOME + // Mark OTA pending-enable only for events on its listen socket. MUST happen + // before xTaskNotifyGive so the flags are visible when the main task wakes. + if (conn == s_ota_listener_conn) { + esphome_wake_ota_component_any_context(); + } +#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 20ac19167..3b5e44914 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,12 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); +/// Set the listener netconn that the fast-select callback filters OTA wakes against. +/// After this is called, the OTA wake hook only fires for RCVPLUS events whose `conn` +/// matches this listener. Passing NULL disables OTA wakes (no event matches a NULL +/// listener) — correct behavior before install and after teardown. +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index ed2885d2e..3aa8669e4 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -20,7 +20,8 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. -static inline void esphome_main_task_notify() { +/// always_inline so callers placed in IRAM do not reference a flash-resident copy. +__attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); @@ -28,26 +29,14 @@ static inline void esphome_main_task_notify() { } /// Wake the main loop task from an ISR. ISR-safe. -static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { +__attribute__((always_inline)) static inline void esphome_main_task_notify_from_isr( + BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); } } -#ifdef USE_ESP32 -/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). -static inline void esphome_main_task_notify_any_context() { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_main_task_notify(); - } -} -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h new file mode 100644 index 000000000..bc1d55a1c --- /dev/null +++ b/esphome/core/millis_internal.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#if defined(USE_ESP32) +#include +#include +#include +#elif defined(USE_LIBRETINY) +#include +#include +#endif + +namespace esphome { + +// Friend-gated accessor for a fast millis() variant intended only for +// known task-context callers on the main loop hot path (Application::loop() +// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// dispatch that the public esphome::millis() pays on ESP32 and libretiny. +// +// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the +// non-FromISR FreeRTOS API directly, which is undefined behavior in ISR +// context. +// +// Adding new callers requires adding a friend declaration here — that +// is the review point. Do not relax the access (e.g. by making get() +// public) without considering the ISR-safety contract. +// +// Other platforms currently delegate to the public millis(); the friend +// gate still enforces the intent so platform-specific fast paths can be +// added later without changing call sites. +class MillisInternal { + private: + static ESPHOME_ALWAYS_INLINE uint32_t get() { +#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 + return xTaskGetTickCount(); +#elif defined(USE_LIBRETINY) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // 1 kHz: xTaskGetTickCount() is already ms. + static_assert(configTICK_RATE_HZ == 1000, "MillisInternal fast path requires 1 kHz FreeRTOS tick"); + return xTaskGetTickCount(); +#elif defined(USE_BK72XX) + // 500 Hz: scale by portTICK_PERIOD_MS (== 2). Inlined to avoid the + // out-of-line call to esphome::millis() (IRAM_ATTR is a no-op on BK72xx — + // SDK masks FIQ + IRQ during flash writes, see hal.h). + static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick"); + return xTaskGetTickCount() * portTICK_PERIOD_MS; +#else + return millis(); +#endif + } + friend class Application; + friend class WarnIfComponentBlockingGuard; +}; + +} // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index dff50b03e..d83d67d6e 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -144,6 +144,19 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // An interval of 0 means "fire every tick forever," which is misuse: the + // item would always be due, causing Scheduler::call() to spin and starve + // the main loop (WDT reset in the field). Coerce to 1ms so existing code + // using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz, + // and warn so authors can migrate to HighFrequencyLoopRequester which is + // the intended mechanism for running fast in the main loop. Zero-delay + // timeouts (defer) remain legitimate one-shots and are not affected. + if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] { + ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)", + component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?")); + delay = 1; + } + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; @@ -222,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } target->push_back(item); if (target == &this->to_add_) { - this->to_add_count_increment_(); + this->to_add_count_increment_locked_(); } #ifndef ESPHOME_THREAD_SINGLE else { - this->defer_count_increment_(); + this->defer_count_increment_locked_(); } #endif } @@ -401,8 +414,27 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). - // It performs cleanup and accesses items_[0] without holding a lock, which is only - // safe when called from the main thread. Other threads must not call this method. + // Accesses items_[0] and the fast-path empty checks without holding a lock, which + // is only safe from the main thread. Other threads must not call this method. + // + // Note: cleanup_() is only invoked on the items_[0] path below. The early returns + // skip it because they don't read items_[0], and Scheduler::call() at the top of + // every loop iteration already performs its own cleanup before the next sleep- + // duration computation happens. + +#ifndef ESPHOME_THREAD_SINGLE + // defer() items live in a separate queue that is drained at the top of every + // loop tick via process_defer_queue_(). If any are pending, the next loop + // iteration has work to do right now -- don't let the caller sleep. + if (!this->defer_empty_()) + return 0; +#else + // On single-threaded builds, defer() routes through set_timeout(..., 0) which + // stages in to_add_. process_to_add() runs at the top of every scheduler.call(), + // so anything in to_add_ becomes runnable on the next iteration; don't sleep. + if (!this->to_add_empty_()) + return 0; +#endif // If no items, return empty optional if (!this->cleanup_()) @@ -439,7 +471,7 @@ void Scheduler::full_cleanup_removed_items_() { this->items_.erase(this->items_.begin() + write, this->items_.end()); // Rebuild the heap structure since items are no longer in heap order std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - this->to_remove_clear_(); + this->to_remove_clear_locked_(); } #ifndef ESPHOME_THREAD_SINGLE @@ -488,7 +520,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { this->lock_.lock(); // Reset counter and snapshot queue end under lock - this->defer_count_clear_(); + this->defer_count_clear_locked_(); size_t defer_queue_end = this->defer_queue_.size(); if (this->defer_queue_front_ >= defer_queue_end) { this->lock_.unlock(); @@ -520,7 +552,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -void HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -608,7 +640,7 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } } @@ -617,7 +649,7 @@ void HOT Scheduler::call(uint32_t now) { if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); continue; } #endif @@ -645,7 +677,7 @@ void HOT Scheduler::call(uint32_t now) { if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); continue; } @@ -690,6 +722,9 @@ void HOT Scheduler::call(uint32_t now) { this->debug_verify_no_leak_(); } #endif + // execute_item_() advances `now` as items fire; return it so the caller + // stays monotonic with last_wdt_feed_. + return now; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; @@ -705,7 +740,7 @@ void HOT Scheduler::process_to_add_slow_path_() { std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); - this->to_add_count_clear_(); + this->to_add_count_clear_locked_(); } bool HOT Scheduler::cleanup_slow_path_() { // We must hold the lock for the entire cleanup operation because: @@ -721,7 +756,7 @@ bool HOT Scheduler::cleanup_slow_path_() { SchedulerItem *item = this->items_[0]; if (!this->is_item_removed_locked_(item)) break; - this->to_remove_decrement_(); + this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(this->pop_raw_locked_()); } return !this->items_.empty(); @@ -739,7 +774,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - return guard.finish(); + uint32_t end = guard.finish(); + // Feed the watchdog after each scheduled item (both main heap and defer + // queue paths go through here). A run of back-to-back callbacks cannot + // starve the wdt. The inline fast path is a load + sub + branch — nearly + // free when the 3 ms rate limit hasn't elapsed. + App.feed_wdt_with_time(end); + return end; } // Common implementation for cancel operations - handles locking @@ -803,7 +844,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; - this->to_remove_add_(heap_cancelled); + this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) return true; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec704..46b19855c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -129,7 +129,8 @@ class Scheduler { // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - void call(uint32_t now); + // @return Timestamp of the last item that ran, or `now` unchanged if none ran. + uint32_t call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). @@ -138,7 +139,7 @@ class Scheduler { // (single-threaded). This is safe because the main loop is the only thread // that reads to_add_ without holding lock_; other threads may read it only // while holding the mutex (e.g. cancel_item_locked_). - inline void HOT process_to_add() { + inline void ESPHOME_ALWAYS_INLINE HOT process_to_add() { if (this->to_add_empty_()) return; this->process_to_add_slow_path_(); @@ -284,9 +285,15 @@ class Scheduler { bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); // Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now. - // On platforms with native 64-bit time, ignores now and uses millis_64() directly. - // On other platforms, extends now to 64-bit using rollover tracking. - uint64_t millis_64_from_(uint32_t now) { + // On platforms with native 64-bit time (ESP32, Host, Zephyr, RP2040 — see + // USE_NATIVE_64BIT_TIME in defines.h), ignores now and uses millis_64() directly, so the + // Scheduler always works in 64-bit time regardless of what the caller's 32-bit now came + // from. On ESP32 specifically, millis() comes from xTaskGetTickCount while millis_64() + // comes from esp_timer — two different clocks — but that is safe because scheduling + // compares millis_64 values against millis_64 only, never against millis(). + // On platforms without native 64-bit time (e.g. ESP8266), extends now to 64-bit using + // rollover tracking, so both millis() and scheduling use the same underlying clock. + uint64_t ESPHOME_ALWAYS_INLINE millis_64_from_(uint32_t now) { #ifdef USE_NATIVE_64BIT_TIME (void) now; return millis_64(); @@ -302,7 +309,7 @@ class Scheduler { // loop thread structurally modifies items_ (push/pop/erase). Other threads may // iterate items_ and mark items removed under lock_, but never change the // vector's size or data pointer. - inline bool HOT cleanup_() { + inline bool ESPHOME_ALWAYS_INLINE HOT cleanup_() { if (this->to_remove_empty_()) return !this->items_.empty(); return this->cleanup_slow_path_(); @@ -407,7 +414,7 @@ class Scheduler { // Process defer queue for FIFO execution of deferred items. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing deferred) is just an atomic load check. - inline void HOT process_defer_queue_(uint32_t &now) { + inline void ESPHOME_ALWAYS_INLINE HOT process_defer_queue_(uint32_t &now) { // Fast path: nothing to process, avoid lock entirely. // Worst case is a one-loop-iteration delay before newly deferred items are processed. if (this->defer_empty_()) @@ -517,11 +524,13 @@ class Scheduler { std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Fast-path counter for process_to_add() to skip taking the lock when there is - // nothing to add. Uses std::atomic on platforms that support it, plain uint32_t - // otherwise. On non-atomic platforms, callers must hold the scheduler lock when - // mutating this counter. Not needed on single-threaded platforms where we can - // check to_add_.empty() directly. + // Fast-path counter for process_to_add() to skip taking the lock when there + // is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS + // (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would + // require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on + // NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally + // atomic on ARMv5TE) but expresses the concurrent-access intent in the C++ + // memory model. Writes live behind *_locked_ helpers and must hold lock_. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_add_count_{0}; #else @@ -529,40 +538,41 @@ class Scheduler { #endif #endif /* ESPHOME_THREAD_SINGLE */ - // Fast-path helper for process_to_add() to decide if it can try the lock-free path. - // - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers). - // - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_. - // - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller - // down the locked path; this is NOT a lock-free emptiness check on that platform. + // Fast-path helper for process_to_add() to decide if it can skip the lock. bool to_add_empty_() const { #ifdef ESPHOME_THREAD_SINGLE return this->to_add_.empty(); #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_add_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0; #endif } - // Increment to_add_count_ (no-op on single-threaded platforms) - void to_add_count_increment_() { -#ifdef ESPHOME_THREAD_SINGLE + // Increment to_add_count_ (no-op on single-threaded platforms). + // On NO_ATOMICS the caller must hold lock_; both load and store go through + // __atomic_*_n with __ATOMIC_RELAXED to keep every access to the counter + // explicitly atomic in the C++ memory model (same ARMv5TE codegen as + // plain LDR+STR). + void to_add_count_increment_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.fetch_add(1, std::memory_order_relaxed); #else - this->to_add_count_++; + uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED); #endif } // Reset to_add_count_ (no-op on single-threaded platforms) - void to_add_count_clear_() { -#ifdef ESPHOME_THREAD_SINGLE + void to_add_count_clear_locked_() { +#if defined(ESPHOME_THREAD_SINGLE) // No counter needed — to_add_empty_() checks the vector directly #elif defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_add_count_.store(0, std::memory_order_relaxed); #else - this->to_add_count_ = 0; + __atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED); #endif } @@ -573,7 +583,8 @@ class Scheduler { std::vector defer_queue_; // FIFO queue for defer() calls size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) - // Fast-path counter for process_defer_queue_() to skip lock when nothing to process. + // Fast-path counter for process_defer_queue_() to skip lock when nothing to + // process. See to_add_count_ above for the NO_ATOMICS rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic defer_count_{0}; #else @@ -582,35 +593,35 @@ class Scheduler { bool defer_empty_() const { // defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path - // ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock #ifdef ESPHOME_THREAD_MULTI_ATOMICS return this->defer_count_.load(std::memory_order_relaxed) == 0; #else - return false; + return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0; #endif } - void defer_count_increment_() { + void defer_count_increment_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.fetch_add(1, std::memory_order_relaxed); #else - this->defer_count_++; + uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED); + __atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED); #endif } - void defer_count_clear_() { + void defer_count_clear_locked_() { #ifdef ESPHOME_THREAD_MULTI_ATOMICS this->defer_count_.store(0, std::memory_order_relaxed); #else - this->defer_count_ = 0; + __atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED); #endif } #endif /* ESPHOME_THREAD_SINGLE */ - // Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_(). - // On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path; - // on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock. + // Counter for items marked for removal. Incremented cross-thread in + // cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS + // rationale. #ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic to_remove_{0}; #else @@ -619,44 +630,54 @@ class Scheduler { // Lock-free check if there are items to remove (for fast-path in cleanup_) bool to_remove_empty_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed) == 0; -#elif defined(ESPHOME_THREAD_SINGLE) - return this->to_remove_ == 0; +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0; #else - return false; // Always take the lock path + return this->to_remove_ == 0; #endif } - void to_remove_add_(uint32_t count) { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_add_locked_(uint32_t count) { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_add(count, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v + count, __ATOMIC_RELAXED); #else - this->to_remove_ += count; + this->to_remove_ += count; #endif } - void to_remove_decrement_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_decrement_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.fetch_sub(1, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); + __atomic_store_n(&this->to_remove_, v - 1, __ATOMIC_RELAXED); #else - this->to_remove_--; + this->to_remove_--; #endif } - void to_remove_clear_() { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS + void to_remove_clear_locked_() { +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) this->to_remove_.store(0, std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + __atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED); #else - this->to_remove_ = 0; + this->to_remove_ = 0; #endif } uint32_t to_remove_count_() const { -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#if defined(ESPHOME_THREAD_MULTI_ATOMICS) return this->to_remove_.load(std::memory_order_relaxed); +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) + return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED); #else - return this->to_remove_; + return this->to_remove_; #endif } diff --git a/esphome/core/time.h b/esphome/core/time.h index ed4743203..0b67b7b3f 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -76,8 +76,12 @@ struct ESPTime { /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); - /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) - bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } + /// Check if this ESPTime is valid (year >= 2019 and the requested fields are in range). + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool is_valid(bool check_day_of_week = true, bool check_day_of_year = true) const { + return this->year >= 2019 && this->fields_in_range(check_day_of_week, check_day_of_year); + } /// Check if time fields are in range. /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index db5df25eb..cf651c3e9 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -20,6 +20,12 @@ namespace esphome { static const char *const TAG = "time_64"; #endif +#ifdef ESPHOME_THREAD_SINGLE +// Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. +uint32_t Millis64Impl::last_millis_{0}; +uint16_t Millis64Impl::millis_major_{0}; +#else + uint64_t Millis64Impl::compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; @@ -44,58 +50,32 @@ uint64_t Millis64Impl::compute(uint32_t now) { * to last_millis is provided by its release store and the corresponding acquire loads. */ static std::atomic millis_major{0}; -#elif !defined(ESPHOME_THREAD_SINGLE) /* ESPHOME_THREAD_MULTI_NO_ATOMICS */ +#else /* ESPHOME_THREAD_MULTI_NO_ATOMICS */ static Mutex lock; static uint32_t last_millis{0}; static uint16_t millis_major{0}; -#else /* ESPHOME_THREAD_SINGLE */ - static uint32_t last_millis{0}; - static uint16_t millis_major{0}; #endif // THREAD SAFETY NOTE: - // This function has three implementations, based on the precompiler flags - // - ESPHOME_THREAD_SINGLE - Runs on single-threaded platforms (ESP8266, etc.) + // This function has two out-of-line implementations, based on the preprocessor flags: // - ESPHOME_THREAD_MULTI_NO_ATOMICS - Runs on multi-threaded platforms without atomics (LibreTiny BK72xx) // - ESPHOME_THREAD_MULTI_ATOMICS - Runs on multi-threaded platforms with atomics (LibreTiny RTL87xx/LN882x, etc.) // + // The ESPHOME_THREAD_SINGLE path is inlined in time_64.h. // Make sure all changes are synchronized if you edit this function. // // IMPORTANT: Always pass fresh millis() values to this function. The implementation // handles out-of-order timestamps between threads, but minimizing time differences // helps maintain accuracy. -#ifdef ESPHOME_THREAD_SINGLE - // Single-core platforms have no concurrency, so this is a simple implementation - // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - - uint16_t major = millis_major; - uint32_t last = last_millis; - - // Check for rollover - if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major++; - major++; - last_millis = now; -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); -#endif /* ESPHOME_DEBUG_SCHEDULER */ - } else if (now > last) { - // Only update if time moved forward - last_millis = now; - } - - // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time - return now + (static_cast(major) << 32); - -#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) +#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) // Without atomics, this implementation uses locks more aggressively: // 1. Always locks when near the rollover boundary (within 10 seconds) // 2. Always locks when detecting a large backwards jump // 3. Updates without lock in normal forward progression (accepting minor races) // This is less efficient but necessary without atomic operations. - uint16_t major = millis_major; - uint32_t last = last_millis; + uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); + uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); // Define a safe window around the rollover point (10 seconds) // This covers any reasonable scheduler delays or thread preemption @@ -107,19 +87,26 @@ uint64_t Millis64Impl::compute(uint32_t now) { if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) { // Near rollover or detected a rollover - need lock for safety LockGuard guard{lock}; - // Re-read with lock held - last = last_millis; + // Re-read both values with lock held. last_millis can be updated + // unlocked from the forward-progression branch below, so use an atomic + // load. millis_major can only be updated under this lock, but another + // thread may have completed a rollover between our unlocked loads above + // and the lock acquisition — reload or we'd return a stale high word. + last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED); + major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED); if (now < last && (last - now) > HALF_MAX_UINT32) { - // True rollover detected (happens every ~49.7 days) - millis_major++; + // True rollover detected (happens every ~49.7 days). + // Use the already-loaded `major` local; avoids a second read of the + // global (equivalent under the held lock). major++; + __atomic_store_n(&millis_major, major, __ATOMIC_RELAXED); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ } // Update last_millis while holding lock - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } else if (now > last) { // Normal case: Not near rollover and time moved forward // Update without lock. While this may cause minor races (microseconds of @@ -127,7 +114,7 @@ uint64_t Millis64Impl::compute(uint32_t now) { // 1. The scheduler operates at millisecond resolution, not microsecond // 2. We've already prevented the critical rollover race condition // 3. Any backwards movement is orders of magnitude smaller than scheduler delays - last_millis = now; + __atomic_store_n(&last_millis, now, __ATOMIC_RELAXED); } // If now <= last and we're not near rollover, don't update // This minimizes backwards time movement @@ -202,6 +189,8 @@ uint64_t Millis64Impl::compute(uint32_t now) { #endif } +#endif // !ESPHOME_THREAD_SINGLE + } // namespace esphome #endif // !USE_NATIVE_64BIT_TIME diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 42d4b041e..592e645d4 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -4,6 +4,9 @@ #ifndef USE_NATIVE_64BIT_TIME #include +#include + +#include "esphome/core/helpers.h" namespace esphome { @@ -16,7 +19,36 @@ class Millis64Impl { friend uint64_t millis_64(); friend class Scheduler; +#ifdef ESPHOME_THREAD_SINGLE + // Storage defined in time_64.cpp — declared here so the inline body can access them. + static uint32_t last_millis_; + static uint16_t millis_major_; + + static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { + // Half the 32-bit range - used to detect rollovers vs normal time progression + static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; + + // Single-core platforms have no concurrency, so this is a simple implementation + // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. + uint16_t major = millis_major_; + uint32_t last = last_millis_; + + // Check for rollover + if (now < last && (last - now) > HALF_MAX_UINT32) { + millis_major_++; + major++; + last_millis_ = now; + } else if (now > last) { + // Only update if time moved forward + last_millis_ = now; + } + + // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time + return now + (static_cast(major) << 32); + } +#else static uint64_t compute(uint32_t now); +#endif }; } // namespace esphome diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 996cf8e31..54a795616 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -1,28 +1,14 @@ #include "esphome/core/util.h" -#include "esphome/core/defines.h" #include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - #ifdef USE_MQTT #include "esphome/components/mqtt/mqtt_client.h" #endif namespace esphome { -bool api_is_connected() { -#ifdef USE_API - if (api::global_api_server != nullptr) { - return api::global_api_server->is_connected(); - } -#endif - return false; -} - bool mqtt_is_connected() { #ifdef USE_MQTT if (mqtt::global_mqtt_client != nullptr) { diff --git a/esphome/core/util.h b/esphome/core/util.h index 1ca0173ea..8f90aa341 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -1,10 +1,28 @@ #pragma once #include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif + namespace esphome { -/// Return whether the node has at least one client connected to the native API -bool api_is_connected(); +/// Return whether the node has at least one client connected to the native API. +/// +/// Inline so that hot-path callers (e.g. component loop() ticks that check connectivity every +/// iteration) can skip the call8/return pair. With USE_API disabled this trivially returns false +/// and collapses at compile time. +#ifdef USE_API +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { + return api::global_api_server != nullptr && api::global_api_server->is_connected(); +} +#else +ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; } +#endif /// Return whether the node has an active connection to an MQTT broker bool mqtt_is_connected(); diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index b6b59b599..cac88ae91 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -1,23 +1,43 @@ #include "esphome/core/wake.h" #include "esphome/core/hal.h" +#include "esphome/core/log.h" #ifdef USE_ESP8266 #include #endif #ifdef USE_HOST -#include "esphome/core/application.h" +#include +#include +#include +#include +#include #include +#include +#include #endif namespace esphome { -// === ESP32 — IRAM_ATTR entry points === -#ifdef USE_ESP32 +// === Wake-requested flag storage === +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +// === ESP32 / LibreTiny — IRAM_ATTR entry points === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } -void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } #endif // === ESP8266 / RP2040 === @@ -45,7 +65,7 @@ static int64_t alarm_callback_(alarm_id_t id, void *user_data) { namespace internal { void wakeable_delay(uint32_t ms) { - if (ms == 0) { + if (ms == 0) [[unlikely]] { yield(); return; } @@ -69,14 +89,188 @@ void wakeable_delay(uint32_t ms) { } // namespace internal #endif // USE_RP2040 -// === Host (UDP loopback socket) === +// === Host (UDP loopback socket + select() based fd watcher) === #ifdef USE_HOST -void wake_loop_threadsafe() { - if (App.wake_socket_fd_ >= 0) { - const char dummy = 1; - ::send(App.wake_socket_fd_, &dummy, 1, 0); + +static const char *const TAG = "wake"; + +namespace internal { +// File-scope state — referenced inline by wake_drain_notifications() and +// wake_fd_ready() in wake.h, and by the bodies in this file. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +int g_wake_socket_fd = -1; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +fd_set g_read_fds{}; +} // namespace internal + +namespace { +// File-local state owned entirely by the select() loop. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +std::vector s_socket_fds; +int s_max_fd = -1; +bool s_socket_fds_changed = false; +fd_set s_base_read_fds{}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) +} // namespace + +bool wake_register_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return false; + + if (fd >= FD_SETSIZE) { + ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); + return false; + } + + s_socket_fds.push_back(fd); + s_socket_fds_changed = true; + if (fd > s_max_fd) { + s_max_fd = fd; + } + + return true; +} + +void wake_unregister_fd(int fd) { + // WARNING: not thread-safe — must be called only from the main loop. + if (fd < 0) + return; + + for (size_t i = 0; i < s_socket_fds.size(); i++) { + if (s_socket_fds[i] != fd) + continue; + + // Swap with last element and pop — O(1) removal since order doesn't matter. + if (i < s_socket_fds.size() - 1) + s_socket_fds[i] = s_socket_fds.back(); + s_socket_fds.pop_back(); + s_socket_fds_changed = true; + // Only recalculate max_fd if we removed the current max. + if (fd == s_max_fd) { + s_max_fd = -1; + for (int sock_fd : s_socket_fds) { + if (sock_fd > s_max_fd) + s_max_fd = sock_fd; + } + } + return; } } -#endif + +namespace internal { +void wakeable_delay(uint32_t ms) { + // Fallback select() path for the host platform (and any future platform + // without fast select). select() is the host equivalent of FreeRTOS task + // notify / esp_delay / WFE used on the embedded targets. + if (!s_socket_fds.empty()) [[likely]] { + // Update fd_set if socket list has changed. + if (s_socket_fds_changed) [[unlikely]] { + FD_ZERO(&s_base_read_fds); + // fd bounds are validated in wake_register_fd(). + for (int fd : s_socket_fds) { + FD_SET(fd, &s_base_read_fds); + } + s_socket_fds_changed = false; + } + + // Copy base fd_set before each select. + g_read_fds = s_base_read_fds; + + // Convert ms to timeval. + struct timeval tv; + tv.tv_sec = ms / 1000; + tv.tv_usec = (ms - tv.tv_sec * 1000) * 1000; + + // Call select with timeout. + int ret = ::select(s_max_fd + 1, &g_read_fds, nullptr, nullptr, &tv); + + // Process select() result: + // ret > 0: socket(s) have data ready - normal and expected + // ret == 0: timeout occurred - normal and expected + if (ret >= 0) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding. + if (ms == 0) [[unlikely]] { + yield(); + } + return; + } + // ret < 0: error (EINTR is normal, anything else is unexpected). + const int err = errno; + if (err == EINTR) { + return; + } + // select() error - log and fall through to delay(). + ESP_LOGW(TAG, "select() failed with errno %d", err); + } + // No sockets registered or select() failed - use regular delay. + delay(ms); +} +} // namespace internal + +void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); + if (internal::g_wake_socket_fd >= 0) { + const char dummy = 1; + ::send(internal::g_wake_socket_fd, &dummy, 1, 0); + } +} + +void wake_setup() { + // Create UDP socket for wake notifications. + internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (internal::g_wake_socket_fd < 0) { + ESP_LOGW(TAG, "Wake socket create failed: %d", errno); + return; + } + + // Bind to loopback with auto-assigned port. + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Auto-assign port + + if (::bind(internal::g_wake_socket_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + ESP_LOGW(TAG, "Wake socket bind failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Get the assigned address and connect to it. + // Connecting a UDP socket allows using send() instead of sendto() for better performance. + struct sockaddr_in wake_addr; + socklen_t len = sizeof(wake_addr); + if (::getsockname(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, &len) < 0) { + ESP_LOGW(TAG, "Wake socket address failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Connect to self (loopback) — allows using send() instead of sendto(). + // After connect(), no need to store wake_addr — the socket remembers it. + if (::connect(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) { + ESP_LOGW(TAG, "Wake socket connect failed: %d", errno); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } + + // Set non-blocking mode. + int flags = ::fcntl(internal::g_wake_socket_fd, F_GETFL, 0); + ::fcntl(internal::g_wake_socket_fd, F_SETFL, flags | O_NONBLOCK); + + // Register with the select() loop. + if (!wake_register_fd(internal::g_wake_socket_fd)) { + ESP_LOGW(TAG, "Wake socket register failed"); + ::close(internal::g_wake_socket_fd); + internal::g_wake_socket_fd = -1; + return; + } +} +#endif // USE_HOST } // namespace esphome diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad0..0cfca94a7 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -7,6 +7,10 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + #if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "esphome/core/main_task.h" #endif @@ -17,6 +21,11 @@ #include #endif +#ifdef USE_HOST +#include +#include +#endif + namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -25,26 +34,81 @@ namespace esphome { extern volatile bool g_main_loop_woke; #endif +// === wake_request flag — signals Application::loop() that a producer queued +// work for some component's loop() to drain (MQTT RX, USB RX, BLE event, etc.) +// and the component phase should run this tick instead of being held off by +// the loop_interval_ gate. Set by every wake_loop_* entry point; consumed +// (via exchange-and-clear) at the gate in Application::loop(). === +// +// std::atomic rather than std::atomic because GCC on Xtensa +// generates an indirect function call for atomic ops instead of inlining +// them — same workaround applied in scheduler.h for the SchedulerItem::remove +// flag. On non-atomic platforms a volatile uint8_t suffices: 8-bit aligned +// loads/stores are atomic on every supported MCU, and the platform signal +// that follows wake_request_set() (FreeRTOS task-notify, esp_schedule, socket +// send) provides the cross-thread/cross-core memory barrier. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern std::atomic g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested.store(1, std::memory_order_release); } +__attribute__((always_inline)) inline bool wake_request_take() { + return g_wake_requested.exchange(0, std::memory_order_acquire) != 0; +} +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern volatile uint8_t g_wake_requested; + +__attribute__((always_inline)) inline void wake_request_set() { g_wake_requested = 1; } +__attribute__((always_inline)) inline bool wake_request_take() { + uint8_t v = g_wake_requested; + g_wake_requested = 0; + return v != 0; +} +#endif + // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) -#ifdef USE_ESP32 -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_any_context(); +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); #else -/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not -/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake -/// is not possible. xTaskNotifyGive is used as the best available option. -inline void wake_loop_any_context() { esphome_main_task_notify(); } + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; #endif + } else { + esphome_main_task_notify(); + } +} -inline void wake_loop_threadsafe() { esphome_main_task_notify(); } +/// IRAM_ATTR entry points — defined in wake.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { yield(); return; } @@ -57,6 +121,9 @@ inline void wakeable_delay(uint32_t ms) { /// Inline implementation — IRAM callers inline this directly. inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; esp_schedule(); } @@ -67,9 +134,12 @@ void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { delay(0); return; } @@ -85,6 +155,9 @@ inline void wakeable_delay(uint32_t ms) { #elif defined(USE_RP2040) inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); g_main_loop_woke = true; __sev(); } @@ -102,6 +175,21 @@ void wakeable_delay(uint32_t ms); #ifdef USE_HOST /// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +// wake_fd_ready() and wake_drain_notifications() are defined inline at the +// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in +// scope, which depend on USE_HOST-only includes pulled in above. #else /// Zephyr is currently the only platform without a wake mechanism. /// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). @@ -112,15 +200,51 @@ inline void wake_loop_threadsafe() {} inline void wake_loop_any_context() { wake_loop_threadsafe(); } namespace internal { -inline void wakeable_delay(uint32_t ms) { - if (ms == 0) { +#ifdef USE_HOST +/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. +void wakeable_delay(uint32_t ms); +#else +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { yield(); return; } delay(ms); } +#endif } // namespace internal #endif +#ifdef USE_HOST +namespace internal { +// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() +// and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} +#endif // USE_HOST + } // namespace esphome diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e..c622207da 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -606,33 +606,43 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": if isinstance(rhs, MockObj) and rhs.is_new_expr: # For 'new' allocations, use placement new into static storage # to avoid heap fragmentation on embedded devices. - the_type = id_.type + # + # Storage must be sized and aligned for the actual instantiated class, + # which may be a subclass of id_.type (e.g. `cv.declare_id(BaseClass)` + # combined with `SubClass.new()` — used by ili9xxx, waveshare_epaper, + # etc. to select a model-specific constructor). Using id_.type would + # run the base-class default constructor instead, silently losing any + # subclass initialization. Template args live on the CallExpression + # and are re-emitted below. + call_expr = rhs.base + assert isinstance(call_expr, CallExpression), ( + f"Expected CallExpression for placement new, got {type(call_expr)}" + ) + actual_type = rhs.new_type if rhs.new_type is not None else id_.type + if call_expr.template_args is not None: + actual_type = f"{actual_type}{call_expr.template_args}" + pointer_type = id_.type # Extract component namespace from type for memory analysis attribution - component_ns = _extract_component_ns(str(the_type)) + component_ns = _extract_component_ns(str(actual_type)) storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage CORE.add_global( RawStatement( - f"alignas({the_type}) static unsigned char {storage_name}[sizeof({the_type})];" + f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];" ) ) + # Pointer declaration uses id_.type to preserve the declared base-class + # pointer type for downstream callers (polymorphism through base ptr). CORE.add_global( AssignmentExpression( - f"static {the_type}", + f"static {pointer_type}", "*const ", id_, - MockObj(f"reinterpret_cast<{the_type} *>({storage_name})"), + MockObj(f"reinterpret_cast<{pointer_type} *>({storage_name})"), ) ) - # Extract args from the CallExpression and rebuild as placement new. - # Template args are already encoded in the_type (e.g. GlobalsComponent), - # so we only pass the constructor args, not template_args. - call_expr = rhs.base - assert isinstance(call_expr, CallExpression), ( - f"Expected CallExpression for placement new, got {type(call_expr)}" - ) - placement_new = CallExpression(f"new({id_.id}) {the_type}", *call_expr.args) + placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) @@ -869,12 +879,16 @@ class MockObj(Expression): Mostly consists of magic methods that allow ESPHome's codegen syntax. """ - __slots__ = ("base", "op", "is_new_expr") + __slots__ = ("base", "op", "is_new_expr", "new_type") - def __init__(self, base, op=".", is_new_expr=False) -> None: + def __init__(self, base, op=".", is_new_expr=False, new_type=None) -> None: self.base = base self.op = op self.is_new_expr = is_new_expr + # For `is_new_expr=True` objects, `new_type` holds the class name being + # constructed (e.g. "ili9xxx::ILI9XXXST7789V"). Needed by Pvariable so + # placement new uses the actual subclass rather than id_.type. + self.new_type = new_type def __getattr__(self, attr: str) -> "MockObj": # prevent python dunder methods being replaced by mock objects @@ -889,7 +903,9 @@ class MockObj(Expression): def __call__(self, *args: SafeExpType) -> "MockObj": call = CallExpression(self.base, *args) - return MockObj(call, self.op, is_new_expr=self.is_new_expr) + return MockObj( + call, self.op, is_new_expr=self.is_new_expr, new_type=self.new_type + ) def __str__(self): return str(self.base) @@ -903,7 +919,7 @@ class MockObj(Expression): @property def new(self) -> "MockObj": - return MockObj(f"new {self.base}", "->", is_new_expr=True) + return MockObj(f"new {self.base}", "->", is_new_expr=True, new_type=self.base) def template(self, *args: SafeExpType) -> "MockObj": """Apply template parameters to this object.""" diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 479090016..f2bd3b92a 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,8 @@ def _generate_source_table_code( entries = ", ".join(var_names) lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") - lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}" + lines.append(f' if ({cond}) return LOG_STR("");') lines.append(" return reinterpret_cast(") lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") lines.append("}") diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e..d67245967 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/esphome/external_files.py b/esphome/external_files.py index 18b68fba0..55711e1b7 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -107,7 +107,7 @@ def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: e, ) return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") + raise cv.Invalid(f"Could not download from {url}: {e}") from e path.parent.mkdir(parents=True, exist_ok=True) data = req.content diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1e40fef2d..11531e6d7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -3,6 +3,10 @@ dependencies: version: "7.4.2" esphome/esp-audio-libs: version: 2.0.4 + esphome/esp-micro-speech-features: + version: 1.2.3 + esphome/micro-decoder: + version: 0.1.1 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: @@ -14,7 +18,7 @@ dependencies: espressif/esp32-camera: version: 2.1.6 espressif/mdns: - version: 1.10.0 + version: 1.11.0 espressif/esp_wifi_remote: version: 1.4.0 rules: @@ -33,6 +37,14 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" + espressif/esp-zboss-lib: + version: 1.6.4 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" + espressif/esp-zigbee-lib: + version: 1.6.8 + rules: + - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: version: "1.0.0" rules: @@ -79,5 +91,7 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" esp32async/asynctcp: version: 3.4.91 + sendspin/sendspin-cpp: + version: 0.3.1 lvgl/lvgl: version: 9.5.0 diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index cb080b2a9..dec541985 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -5,157 +5,15 @@ import os from pathlib import Path import re import subprocess -import time -from typing import Any +import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.util import run_external_command, run_external_process +from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) -def patch_structhash(): - # Patch platformio's structhash to not recompile the entire project when files are - # removed/added. This might have unintended consequences, but this improves compile - # times greatly when adding/removing components and a simple clean build solves - # all issues - from platformio.run import cli, helpers - - def patched_clean_build_dir(build_dir, *args): - from platformio import fs - from platformio.project.helpers import get_project_dir - - platformio_ini = Path(get_project_dir()) / "platformio.ini" - - build_dir = Path(build_dir) - - # if project's config is modified - if ( - build_dir.is_dir() - and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime - ): - fs.rmtree(build_dir) - - if not build_dir.is_dir(): - build_dir.mkdir(parents=True) - - helpers.clean_build_dir = patched_clean_build_dir - cli.clean_build_dir = patched_clean_build_dir - - -def patch_file_downloader(): - """Patch PlatformIO's FileDownloader to retry on PackageException errors. - - PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry - for 502/503 errors. We add retries with exponential backoff and close the - session between attempts to force a fresh TCP connection, which may route - to a different CDN edge node. - """ - from platformio.package.download import FileDownloader - from platformio.package.exception import PackageException - - if getattr(FileDownloader.__init__, "_esphome_patched", False): - return - - original_init = FileDownloader.__init__ - - def patched_init(self, *args: Any, **kwargs: Any) -> None: - max_retries = 5 - - for attempt in range(max_retries): - try: - original_init(self, *args, **kwargs) - return - except PackageException as e: - if attempt < max_retries - 1: - # Exponential backoff: 2, 4, 8, 16 seconds - delay = 2 ** (attempt + 1) - _LOGGER.warning( - "Package download failed: %s. " - "Retrying in %d seconds... (attempt %d/%d)", - str(e), - delay, - attempt + 1, - max_retries, - ) - # Close the response and session to free resources - # and force a new TCP connection on retry, which may - # route to a different CDN edge node - # pylint: disable=protected-access,broad-except - try: - if ( - hasattr(self, "_http_response") - and self._http_response is not None - ): - self._http_response.close() - if hasattr(self, "_http_session"): - self._http_session.close() - except Exception: - pass - # pylint: enable=protected-access,broad-except - time.sleep(delay) - else: - # Final attempt - re-raise - raise - - patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access - FileDownloader.__init__ = patched_init - - -IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" -FILTER_PLATFORMIO_LINES = [ - r"Verbose mode can be enabled via `-v, --verbose` option.*", - r"CONFIGURATION: https://docs.platformio.org/.*", - r"DEBUG: Current.*", - r"LDF Modes:.*", - r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", - f"Looking for {IGNORE_LIB_WARNINGS} library in registry", - f"Warning! Library `.*'{IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", - f"You can ignore this message, if `.*{IGNORE_LIB_WARNINGS}.*` is a built-in library.*", - r"Scanning dependencies...", - r"Found \d+ compatible libraries", - r"Memory Usage -> https://bit.ly/pio-memory-usage", - r"Found: https://platformio.org/lib/show/.*", - r"Using cache: .*", - r"Installing dependencies", - r"Library Manager: Already installed, built-in library", - r"Building in .* mode", - r"Advanced Memory Usage is available via .*", - r"Merged .* ELF section", - r"esptool.py v.*", - r"esptool v.*", - r"Checking size .*", - r"Retrieving maximum program size .*", - r"PLATFORM: .*", - r"PACKAGES:.*", - r" - framework-arduinoespressif.* \(.*\)", - r" - tool-esptool.* \(.*\)", - r" - toolchain-.* \(.*\)", - r"Creating BIN file .*", - r"Warning! Could not find file \".*.crt\"", - r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", - r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", - r"Warning: esp-idf-size exited with code 2", - r"esp_idf_size: error: unrecognized arguments: --ng", - r"Package configuration completed successfully", -] - - -class PlatformioLogFilter(logging.Filter): - """Filter to suppress noisy platformio log messages.""" - - _PATTERN = re.compile( - r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) - ) - - def filter(self, record: logging.LogRecord) -> bool: - # Only filter messages from platformio-related loggers - if "platformio" not in record.name.lower(): - return True - return self._PATTERN.match(record.getMessage()) is None - - def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -166,30 +24,9 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") - cmd = ["platformio"] + list(args) + cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) - if not CORE.verbose: - kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES - - if os.environ.get("ESPHOME_USE_SUBPROCESS") is not None: - return run_external_process(*cmd, **kwargs) - - import platformio.__main__ - - patch_structhash() - patch_file_downloader() - - # Add log filter to suppress noisy platformio messages - log_filter = PlatformioLogFilter() if not CORE.verbose else None - if log_filter: - for handler in logging.getLogger().handlers: - handler.addFilter(log_filter) - try: - return run_external_command(platformio.__main__.main, *cmd, **kwargs) - finally: - if log_filter: - for handler in logging.getLogger().handlers: - handler.removeFilter(log_filter) + return run_external_process(*cmd, **kwargs) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py new file mode 100644 index 000000000..599c9408a --- /dev/null +++ b/esphome/platformio_runner.py @@ -0,0 +1,187 @@ +"""Subprocess entry point that applies ESPHome's PlatformIO patches. + +Invoked via ``python -m esphome.platformio_runner`` instead of +``python -m platformio`` so that the patches (incremental rebuild +preservation, download retries) apply inside the subprocess. Running +PlatformIO in a subprocess keeps its ``sys.path`` mutations and other +global state from leaking into the ESPHome process. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import time +from typing import Any + +_LOGGER = logging.getLogger(__name__) + + +def patch_structhash() -> None: + """Avoid full rebuilds when files are added or removed. + + PlatformIO clears the build dir whenever its structure hash changes. + We replace that with an mtime check against ``platformio.ini`` so + incremental builds are preserved unless the project config changed. + """ + from platformio.run import cli, helpers + + def patched_clean_build_dir(build_dir, *_args): + from platformio import fs + from platformio.project.helpers import get_project_dir + + platformio_ini = Path(get_project_dir()) / "platformio.ini" + build_dir = Path(build_dir) + + if ( + build_dir.is_dir() + and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime + ): + fs.rmtree(build_dir) + + if not build_dir.is_dir(): + build_dir.mkdir(parents=True) + + helpers.clean_build_dir = patched_clean_build_dir + cli.clean_build_dir = patched_clean_build_dir + + +def patch_file_downloader() -> None: + """Retry PlatformIO package downloads with exponential backoff. + + PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in + retry for 502/503 errors. We wrap ``__init__`` to retry on + ``PackageException`` and close the session between attempts so a new + TCP connection can route to a different CDN edge node. + """ + from platformio.package.download import FileDownloader + from platformio.package.exception import PackageException + + if getattr(FileDownloader.__init__, "_esphome_patched", False): + return + + original_init = FileDownloader.__init__ + + def patched_init(self, *args: Any, **kwargs: Any) -> None: + max_retries = 5 + + for attempt in range(max_retries): + try: + original_init(self, *args, **kwargs) + return + except PackageException as e: + if attempt < max_retries - 1: + delay = 2 ** (attempt + 1) + _LOGGER.warning( + "Package download failed: %s. " + "Retrying in %d seconds... (attempt %d/%d)", + str(e), + delay, + attempt + 1, + max_retries, + ) + # pylint: disable=protected-access,broad-except + try: + if ( + hasattr(self, "_http_response") + and self._http_response is not None + ): + self._http_response.close() + if hasattr(self, "_http_session"): + self._http_session.close() + except Exception: + pass + # pylint: enable=protected-access,broad-except + time.sleep(delay) + else: + raise + + patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + FileDownloader.__init__ = patched_init + + +_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" +# Regex patterns matched against each line of PlatformIO output. Lines that +# match are dropped by RedirectText before they reach the parent process. +# Patterns are anchored at the start of the line (RedirectText uses +# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to +# ``esphome compile``. +FILTER_PLATFORMIO_LINES = [ + r"Verbose mode can be enabled via `-v, --verbose` option.*", + r"CONFIGURATION: https://docs.platformio.org/.*", + r"DEBUG: Current.*", + r"LDF Modes:.*", + r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", + f"Looking for {_IGNORE_LIB_WARNINGS} library in registry", + f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", + f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*", + r"Scanning dependencies...", + r"Found \d+ compatible libraries", + r"Memory Usage -> https://bit.ly/pio-memory-usage", + r"Found: https://platformio.org/lib/show/.*", + r"Using cache: .*", + r"Installing dependencies", + r"Library Manager: Already installed, built-in library", + r"Building in .* mode", + r"Advanced Memory Usage is available via .*", + r"Merged .* ELF section", + r"esptool.py v.*", + r"esptool v.*", + r"Checking size .*", + r"Retrieving maximum program size .*", + r"PLATFORM: .*", + r"PACKAGES:.*", + r" - framework-arduinoespressif.* \(.*\)", + r" - tool-esptool.* \(.*\)", + r" - toolchain-.* \(.*\)", + r"Creating BIN file .*", + r"Warning! Could not find file \".*.crt\"", + r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", + r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", + r"Warning: esp-idf-size exited with code 2", + r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", +] + + +def main() -> int: + patch_structhash() + patch_file_downloader() + + # Wrap stdout/stderr with RedirectText before PlatformIO runs: + # + # 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and + # PlatformIO's own progress-bar code check ``stream.isatty()`` to + # decide whether to emit TTY-format output (``\r`` cursor moves, ANSI + # colors, fancy progress bars). With the wrapper in place they always + # emit TTY format, even when our real stdout is a pipe to the parent + # process. Downstream consumers (local terminals and the Home + # Assistant dashboard log viewer) render the TTY control sequences + # correctly, so the user sees real progress bars. + # + # 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in + # this subprocess, so noisy PlatformIO output is dropped before it + # ever leaves the runner. This replaces the parent-side filtering + # that was lost when we switched from in-process to subprocess — the + # parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and + # bypasses its ``write()`` path entirely. + # + # Filtering is disabled when the user passed -v / --verbose to + # ``esphome compile``, preserving the previous in-process behavior where + # verbose mode let all PlatformIO output through unfiltered. + from esphome.util import RedirectText + + is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) + filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES + + sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + + import platformio.__main__ + + return platformio.__main__.main() or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/esphome/resolver.py b/esphome/resolver.py index 99482aa20..9fb596ce7 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -2,66 +2,52 @@ from __future__ import annotations -import asyncio -import threading - from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr +from esphome.async_thread import AsyncThreadRunner from esphome.core import EsphomeError RESOLVE_TIMEOUT = 10.0 # seconds -class AsyncResolver(threading.Thread): +class AsyncResolver: """Resolver using aioesphomeapi that runs in a thread for faster results. - This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, - including proper .local domain fallback. Running in a thread allows us to get - the result immediately without waiting for asyncio.run() to complete its - cleanup cycle, which can take significant time. + This resolver uses aioesphomeapi's async_resolve_host to handle DNS + resolution, including proper .local domain fallback. Running in a thread + (via :class:`AsyncThreadRunner`) allows us to get the result immediately + without waiting for ``asyncio.run()`` to complete its cleanup cycle, which + can take significant time. """ def __init__(self, hosts: list[str], port: int) -> None: """Initialize the resolver.""" - super().__init__(daemon=True) self.hosts = hosts self.port = port - self.result: list[hr.AddrInfo] | None = None - self.exception: Exception | None = None - self.event = threading.Event() - async def _resolve(self) -> None: + async def _resolve(self) -> list[hr.AddrInfo]: """Resolve hostnames to IP addresses.""" - try: - self.result = await hr.async_resolve_host( - self.hosts, self.port, timeout=RESOLVE_TIMEOUT - ) - except Exception as e: # pylint: disable=broad-except - # We need to catch all exceptions to ensure the event is set - # Otherwise the thread could hang forever - self.exception = e - finally: - self.event.set() - - def run(self) -> None: - """Run the DNS resolution.""" - asyncio.run(self._resolve()) + return await hr.async_resolve_host( + self.hosts, self.port, timeout=RESOLVE_TIMEOUT + ) def resolve(self) -> list[hr.AddrInfo]: """Start the thread and wait for the result.""" - self.start() + runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve) + runner.start() - if not self.event.wait( + if not runner.event.wait( timeout=RESOLVE_TIMEOUT + 1.0 ): # Give it 1 second more than the resolver timeout raise EsphomeError("Timeout resolving IP address") - if exc := self.exception: + if exc := runner.exception: if isinstance(exc, ResolveTimeoutAPIError): raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc if isinstance(exc, ResolveAPIError): raise EsphomeError(f"Error resolving IP address: {exc}") from exc raise exc - return self.result + assert runner.result is not None # guaranteed when event set and no exception + return runner.result diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 0703c54a7..904963ba4 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -39,8 +39,7 @@ class _Schema(vol.Schema): try: res = extra(res) except vol.Invalid as err: - # pylint: disable=raise-missing-from - raise ensure_multiple_invalid(err) + raise ensure_multiple_invalid(err) from err return res def _compile_mapping(self, schema, invalid_msg=None): diff --git a/esphome/writer.py b/esphome/writer.py index 06a223011..816c57a0b 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -22,7 +22,6 @@ from esphome.helpers import ( read_file, rmtree, walk_files, - write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -171,6 +170,8 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" +BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" +ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -196,22 +197,34 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h + # X-macro files are included multiple times with different macro definitions + # and must not be included bare in esphome.h + esphome_h_exclude = {Path(ENTITY_TYPES_H_TARGET)} include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) source_files_copy = source_files_map.copy() ignore_targets = [ - Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + Path(x) + for x in ( + DEFINES_H_TARGET, + VERSION_H_TARGET, + BUILD_INFO_DATA_H_TARGET, + BUILD_INFO_DATA_CPP_TARGET, + ) ] for t in ignore_targets: source_files_copy.pop(t, None) # Files to exclude from sources_changed tracking (generated files) - generated_files = {Path("esphome/core/build_info_data.h")} + generated_files = { + Path("esphome/core/build_info_data.h"), + Path("esphome/core/build_info_data.cpp"), + } sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): @@ -264,12 +277,15 @@ def copy_src_tree(): build_info_data_h_path = CORE.relative_src_path( "esphome", "core", "build_info_data.h" ) + build_info_data_cpp_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.cpp" + ) build_info_json_path = CORE.relative_build_path("build_info.json") config_hash, build_time, build_time_str, comment = get_build_info() # Defensively force a rebuild if the build_info files don't exist, or if # there was a config change which didn't actually cause a source change - if not build_info_data_h_path.exists(): + if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists(): sources_changed = True else: try: @@ -284,13 +300,19 @@ def copy_src_tree(): # Write build_info header and JSON metadata if sources_changed: - write_file( + # write_file_if_changed avoids bumping mtime on identical content, + # which is what makes the stable header actually isolate metadata churn. + write_file_if_changed( build_info_data_h_path, - generate_build_info_data_h( + generate_build_info_data_h(), + ) + write_file_if_changed( + build_info_data_cpp_path, + generate_build_info_data_cpp( config_hash, build_time, build_time_str, comment ), ) - write_file( + write_file_if_changed( build_info_json_path, json.dumps( { @@ -341,27 +363,60 @@ def get_build_info() -> tuple[int, int, str, str]: return config_hash, build_time, build_time_str, comment -def generate_build_info_data_h( - config_hash: int, build_time: int, build_time_str: str, comment: str -) -> str: - """Generate build_info_data.h header with config hash, build time, and comment.""" - # cpp_string_escape returns '"escaped"', slice off the quotes since template has them - escaped_comment = cpp_string_escape(comment)[1:-1] - # +1 for null terminator - comment_size = len(comment) + 1 - return f"""#pragma once -// Auto-generated build_info data -#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT -#define ESPHOME_BUILD_TIME {build_time} // NOLINT -#define ESPHOME_COMMENT_SIZE {comment_size} // NOLINT +def generate_build_info_data_h() -> str: + """Generate stable declarations for build info provided by generated C++.""" + return """#pragma once +// Auto-generated build_info declarations +#include +#include +#include #ifdef USE_ESP8266 #include -static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; -#else -static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; #endif + +namespace esphome { +extern const uint32_t ESPHOME_CONFIG_HASH; +extern const time_t ESPHOME_BUILD_TIME; +extern const size_t ESPHOME_COMMENT_SIZE; +#ifdef USE_ESP8266 +extern const char ESPHOME_BUILD_TIME_STR[] PROGMEM; +extern const char ESPHOME_COMMENT_STR[] PROGMEM; +#else +extern const char ESPHOME_BUILD_TIME_STR[]; +extern const char ESPHOME_COMMENT_STR[]; +#endif +} // namespace esphome +""" + + +def generate_build_info_data_cpp( + config_hash: int, build_time: int, build_time_str: str, comment: str +) -> str: + """Generate build_info_data.cpp with config hash, build time, and comment.""" + from esphome.core.config import COMMENT_MAX_LEN + + # Defense-in-depth clamp; errors="ignore" drops a partial trailing UTF-8 + # sequence so the literal never decodes to a truncated codepoint. + encoded = comment.encode("utf-8")[:COMMENT_MAX_LEN] + comment = encoded.decode("utf-8", errors="ignore") + # cpp_string_escape wraps in quotes; strip them since the template has them. + escaped_comment = cpp_string_escape(comment)[1:-1] + comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL + return f"""// Auto-generated build_info data +#include "esphome/core/build_info_data.h" + +namespace esphome {{ +const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT +const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT +#ifdef USE_ESP8266 +const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; +#else +const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; +#endif +}} // namespace esphome """ diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 59d851c02..3cfc9c4b1 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -48,6 +48,8 @@ _SECRET_VALUES = {} # Not thread-safe — config processing is single-threaded today. _load_listeners: list[Callable[[Path], None]] = [] +DocumentPath = list[str | int] + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: @@ -111,6 +113,15 @@ def make_data_base( return value +def make_literal(value: Any) -> ESPLiteralValue | Any: + """Wrap a value in an ESPLiteralValue object.""" + try: + return add_class_to_obj(value, ESPLiteralValue) + except TypeError: + # Adding class failed, ignore error + return value + + def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -338,10 +349,9 @@ class ESPHomeLoaderMixin: try: hash(key) except TypeError: - # pylint: disable=raise-missing-from raise yaml.constructor.ConstructorError( f'Invalid key "{key}" (not hashable)', key_node.start_mark - ) + ) from None key = make_data_base(str(key)) key.from_node(key_node) @@ -524,7 +534,7 @@ class ESPHomeLoaderMixin: obj = self.construct_sequence(node) elif isinstance(node, yaml.MappingNode): obj = self.construct_mapping(node) - return add_class_to_obj(obj, ESPLiteralValue) + return make_literal(obj) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: @@ -680,6 +690,123 @@ def is_secret(value): return None +def _path_doc(item: Any) -> str | None: + """Return the source document name if *item* carries location info.""" + if isinstance(item, ESPHomeDataBase) and (r := item.esp_range) is not None: + return r.start_mark.document + return None + + +def _fmt_mark(loc: Any) -> str: + """Render a DocumentLocation as a 1-based 'file line:col' string.""" + return f"{loc.document} {loc.line + 1}:{loc.column + 1}" + + +def _obj_loc(obj: Any) -> str: + """Return formatted source location for *obj*, or '' if it has none.""" + if isinstance(obj, ESPHomeDataBase) and (r := obj.esp_range) is not None: + return _fmt_mark(r.start_mark) + return "" + + +def _fmt_segment(seg: list) -> str: + """Format a path segment, rendering integers as [n] subscripts.""" + parts: list[str] = [] + for item in seg: + if isinstance(item, int): + if parts: + parts[-1] = f"{parts[-1]}[{item}]" + else: + parts.append(f"[{item}]") + else: + parts.append(str(item)) + return "->".join(parts) + + +def _split_into_frames( + path: DocumentPath, +) -> list[tuple[list, str]]: + """Group *path* into per-file frames at include boundaries. + + A "frame" is the slice of the path that belongs to one source document. + Each path item is either: + + * a **located key** — has an ``ESPHomeDataBase`` source mark; this is + what tells us which document owns the surrounding keys. + * an **integer** — a list subscript; always attaches to the open frame + (renders as ``foo[3]`` on the previous name). + * an **unlocated string** — a key with no source mark (e.g. constants + like ``CONF_PACKAGES``); it describes the parent of the *next* file, + so it migrates to the next frame when the document changes. + + Returns a list of ``(items, "file line:col")`` tuples in walk order + (outermost frame first). + """ + frames: list[tuple[list, str]] = [] + open_frame: list = [] + next_frame_keys: list = [] # unlocated strings buffered for the next frame + open_doc: str | None = None + open_loc = "" + + for item in path: + doc = _path_doc(item) + if doc is None: + # Ints subscript the open frame's last name; everything else + # (strings, or leading ints with no open frame) is buffered for + # the next frame. + if isinstance(item, int) and open_doc is not None: + open_frame.append(item) + else: + next_frame_keys.append(item) + continue + if open_doc is not None and doc != open_doc: + # Crossed an include boundary: close the open frame. + frames.append((open_frame, open_loc)) + open_frame = [] + open_frame.extend(next_frame_keys) + next_frame_keys.clear() + open_frame.append(item) + open_doc = doc + open_loc = _fmt_mark(item.esp_range.start_mark) + + if open_doc is not None: + # Trailing buffered keys belong to the innermost (last) frame. + open_frame.extend(next_frame_keys) + frames.append((open_frame, open_loc)) + return frames + + +def format_path(path: DocumentPath, current_obj: Any) -> str: + """Build a human-readable include stack from a config path. + + Each YAML key in *path* that carries an ``ESPHomeDataBase`` ``esp_range`` + reveals which file it came from. When the source document changes between + consecutive such keys, that is an include boundary. The path is split + into per-file frames and formatted innermost-first, e.g.:: + + In: packages->roam in common/package/wifi.yaml 26:10 + Included from packages->net in common/hardware.yaml 44:2 + Included from packages->device in my_project.yaml 11:2 + + The innermost ``In:`` line uses the location from *current_obj* when + available (the value that triggered the error) for extra precision. + """ + frames = _split_into_frames(path) + obj_loc = _obj_loc(current_obj) + + if not frames: + # No source info anywhere in the path: render as a flat path, + # using current_obj's location if it happens to have one. + suffix = f" in {obj_loc}" if obj_loc else "" + return f"In: {_fmt_segment(path)}{suffix}" + + inner_seg, inner_loc = frames[-1] + lines = [f"In: {_fmt_segment(inner_seg)} in {obj_loc or inner_loc}"] + for seg, loc in reversed(frames[:-1]): + lines.append(f" Included from {_fmt_segment(seg)} in {loc}") + return "\n".join(lines) + + class ESPHomeDumper(yaml.SafeDumper): def represent_mapping(self, tag, mapping, flow_style=None): value = [] diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index dd45b58a6..6f5d33c80 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -14,8 +14,13 @@ from zeroconf import ( ) from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf +from esphome.async_thread import AsyncThreadRunner from esphome.storage_json import StorageJSON, ext_storage_path +# Length of the MAC suffix appended when name_add_mac_suffix is enabled. +MAC_SUFFIX_LEN = 6 +_HEX_CHARS = frozenset("0123456789abcdef") + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10.0 @@ -188,15 +193,177 @@ class EsphomeZeroconf(Zeroconf): return None +async def async_resolve_hosts( + zeroconf: Zeroconf, hosts: list[str], timeout: float = DEFAULT_TIMEOUT +) -> dict[str, list[str]]: + """Resolve ``hosts`` to IPs using a shared ``Zeroconf`` instance. + + Tries the cache synchronously first (so hosts already primed by a recent + browse return immediately with no network round-trip), then issues + ``async_request`` for the remaining misses in parallel via + ``asyncio.gather``. Returns a dict mapping each host to its list of + addresses (empty list when unresolved). Only ``.local`` form is + queried, matching the name scheme the resolvers below expect. + """ + resolvers: dict[str, AddressResolver] = {} + pending: list[str] = [] + for host in hosts: + resolver = AddressResolver(f"{host.partition('.')[0]}.local.") + resolvers[host] = resolver + if not resolver.load_from_cache(zeroconf): + pending.append(host) + + if pending and timeout: + results = await asyncio.gather( + *( + resolvers[host].async_request(zeroconf, timeout * 1000) + for host in pending + ), + return_exceptions=True, + ) + for host, result in zip(pending, results): + if isinstance(result, BaseException): + _LOGGER.debug("Failed to resolve %s: %s", host, result) + + return { + host: resolver.parsed_scoped_addresses(IPVersion.All) + for host, resolver in resolvers.items() + } + + class AsyncEsphomeZeroconf(AsyncZeroconf): async def async_resolve_host( self, host: str, timeout: float = DEFAULT_TIMEOUT ) -> list[str] | None: """Resolve a host name to an IP address.""" - info = AddressResolver(f"{host.partition('.')[0]}.local.") - if ( - info.load_from_cache(self.zeroconf) - or (timeout and await info.async_request(self.zeroconf, timeout * 1000)) - ) and (addresses := info.parsed_scoped_addresses(IPVersion.All)): - return addresses - return None + addresses = (await async_resolve_hosts(self.zeroconf, [host], timeout))[host] + return addresses or None + + +def _is_mac_suffix_match(device_name: str, prefix: str) -> bool: + """Return True if ``device_name`` is ``prefix`` followed by a 6-char hex MAC.""" + if not device_name.startswith(prefix): + return False + suffix = device_name[len(prefix) :] + return len(suffix) == MAC_SUFFIX_LEN and all(c in _HEX_CHARS for c in suffix) + + +async def async_discover_mdns_devices( + base_name: str, timeout: float = 5.0 +) -> dict[str, list[str]]: + """Discover ESPHome devices via mDNS that match the base name + MAC suffix. + + When ``name_add_mac_suffix`` is enabled, devices advertise as + ``-<6-hex-mac>.local``. This function uses a single + ``AsyncEsphomeZeroconf`` lifecycle to both browse for matching services and + resolve their IP addresses, so callers get resolved addresses without + opening a second Zeroconf client. + + Args: + base_name: The base device name (without MAC suffix). + timeout: How long to wait for mDNS responses (default 5 seconds). + + Returns: + Mapping of ``.local`` hostnames to their resolved IP addresses + (may be empty for a device if resolution failed within the timeout). + """ + prefix = f"{base_name}-" + # Preserves insertion order for stable output and deduplicates + discovered: dict[str, list[str]] = {} + + def on_service_state_change( + zeroconf: Zeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + if state_change not in (ServiceStateChange.Added, ServiceStateChange.Updated): + return + device_name = name.partition(".")[0] + if not _is_mac_suffix_match(device_name, prefix): + _LOGGER.debug( + "Ignoring %s (%s): does not match '%s<6-hex>'", + device_name, + state_change.name, + prefix, + ) + return + host = f"{device_name}.local" + if host in discovered: + return + discovered[host] = [] + _LOGGER.debug("Discovered %s (%s)", host, state_change.name) + + _LOGGER.debug( + "Starting mDNS discovery for '%s.local' (timeout=%.1fs)", + prefix, + timeout, + ) + try: + aiozc = AsyncEsphomeZeroconf() + except Exception as err: # pylint: disable=broad-except + # Zeroconf init can raise OSError, NonUniqueNameException, etc. + # Any failure here just means we can't discover — log and move on. + _LOGGER.warning("mDNS discovery failed to initialize: %s", err) + return {} + + try: + browser = AsyncServiceBrowser( + aiozc.zeroconf, + ESPHOME_SERVICE_TYPE, + handlers=[on_service_state_change], + ) + try: + await asyncio.sleep(timeout) + finally: + await browser.async_cancel() + _LOGGER.debug( + "Browse finished: %d device(s) matched '%s'", + len(discovered), + prefix, + ) + + # Resolve each discovered hostname on the SAME Zeroconf instance so + # we don't spin up a second client. ``async_resolve_hosts`` tries the + # cache synchronously (the browse usually primes it) before issuing + # any ``async_request`` in parallel for misses. + resolved = await async_resolve_hosts(aiozc.zeroconf, list(discovered)) + for host, addresses in resolved.items(): + if addresses: + discovered[host] = addresses + _LOGGER.debug("Resolved %s -> %s", host, addresses) + else: + _LOGGER.debug("No addresses returned for %s", host) + finally: + await aiozc.async_close() + + return dict(sorted(discovered.items())) + + +def _await_discovery( + runner: AsyncThreadRunner[dict[str, list[str]]], timeout: float +) -> dict[str, list[str]]: + """Wait for ``runner`` to finish and return its discovery result. + + Split out of :func:`discover_mdns_devices` so the timeout branch is + testable without patching ``asyncio`` or ``threading`` internals — a test + passes a stub whose ``event.wait`` returns ``False``. + """ + # Give the discovery an extra second over the browse timeout for the + # resolution + cleanup pass. + if not runner.event.wait(timeout=timeout + 2.0): + _LOGGER.warning("mDNS discovery timed out after %.1fs", timeout) + return {} + if runner.exception is not None: + _LOGGER.warning("mDNS discovery failed: %s", runner.exception) + return {} + return runner.result or {} + + +def discover_mdns_devices(base_name: str, timeout: float = 5.0) -> dict[str, list[str]]: + """Synchronous wrapper around :func:`async_discover_mdns_devices`.""" + runner = AsyncThreadRunner( + lambda: async_discover_mdns_devices(base_name, timeout=timeout) + ) + runner.start() + return _await_discovery(runner, timeout) diff --git a/platformio.ini b/platformio.ini index e0f7c7d44..3023a1573 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,8 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.1.0 ; dsmr - polargoose/Crypto-no-arduino@0.4.0 ; dsmr + esphome/dsmr_parser@1.4.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library @@ -83,7 +82,7 @@ lib_deps = fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -118,7 +117,7 @@ lib_deps = ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = @@ -133,10 +132,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.7/esp32-core-3.3.7.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -154,8 +153,7 @@ lib_deps = DNSServer ; captive_portal (Arduino built-in) makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio - droscy/esp_wireguard@0.4.4 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word + droscy/esp_wireguard@0.4.5 ; wireguard build_flags = ${common:arduino.build_flags} @@ -169,16 +167,15 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = ${common:idf.lib_deps} - droscy/esp_wireguard@0.4.4 ; wireguard - kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word - tonia/HeatpumpIR@1.0.40 ; heatpumpir + droscy/esp_wireguard@0.4.5 ; wireguard + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare @@ -221,7 +218,7 @@ lib_compat_mode = soft lib_deps = bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - droscy/esp_wireguard@0.4.4 ; wireguard + droscy/esp_wireguard@0.4.5 ; wireguard lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} diff --git a/pyproject.toml b/pyproject.toml index 2e3a24776..dc6785001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.47"] +requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] @@ -20,7 +20,6 @@ classifiers = [ "Topic :: Home Automation", ] -# Python 3.14 is not supported on Windows, see https://github.com/zephyrproject-rtos/windows-curses/issues/76 requires-python = ">=3.11.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] diff --git a/requirements.txt b/requirements.txt index 1466eccc9..abc8ac5db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.7 +cryptography==47.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 @@ -6,13 +6,13 @@ colorama==0.4.6 icmplib==3.0.4 tornado==6.5.5 tzlocal==5.3.1 # from time -tzdata>=2021.1 # from time +tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -click==8.3.2 -esphome-dashboard==20260408.1 -aioesphomeapi==44.13.3 +click==8.3.3 +esphome-dashboard==20260425.0 +aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import @@ -27,7 +27,7 @@ smpclient==6.0.0 requests==2.33.1 # esp-idf >= 5.0 requires this -pyparsing >= 3.0 +pyparsing >= 3.3.2 # For autocompletion -argcomplete>=2.0.0 +argcomplete>=3.6.3 diff --git a/requirements_test.txt b/requirements_test.txt index 18d0461e8..b35025fa0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.10 # also change in .pre-commit-config.yaml when updating +ruff==0.15.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 39bfc865d..c10479a72 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -65,11 +65,31 @@ _enum_max_values: dict[str, int] = {} _message_desc_map: dict[str, Any] = {} +def _make_ifdef_line(condition: str) -> str: + """Return the correct preprocessor open-guard line for a condition string. + + Simple identifiers use ``#ifdef IDENTIFIER``. + Compound expressions (containing ``||`` or ``&&``) use + ``#if defined(A) || defined(B)`` so that the preprocessor + evaluates them correctly. + """ + if any(op in condition for op in ("||", "&&", "!")): + # Replace each bare identifier token with defined(token) + expr = re.sub(r"\b([A-Za-z_]\w*)\b", r"defined(\1)", condition) + return f"#if {expr}" + return f"#ifdef {condition}" + + def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if line == "" or line.startswith("#ifdef") or line.startswith("#endif"): + if ( + line == "" + or line.startswith("#ifdef") + or line.startswith("#if ") + or line.startswith("#endif") + ): p = "" else: p = padding @@ -82,7 +102,7 @@ def indent(text: str, padding: str = " ") -> str: def wrap_with_ifdef(content: str | list[str], ifdef: str | None) -> list[str]: - """Wrap content with #ifdef directives if ifdef is provided. + """Wrap content with #ifdef / #if directives if ifdef is provided. Args: content: Single string or list of strings to wrap @@ -96,7 +116,7 @@ def wrap_with_ifdef(content: str | list[str], ifdef: str | None) -> list[str]: return [content] return content - result = [f"#ifdef {ifdef}"] + result = [_make_ifdef_line(ifdef)] if isinstance(content, str): result.append(content) else: @@ -1028,7 +1048,8 @@ class BytesType(TypeInfo): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -1109,7 +1130,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);" class PointerToStringBufferType(PointerToBufferTypeBase): @@ -2679,6 +2701,16 @@ def build_message_type( and get_opt(desc, inline_opt, False) ) + # Check if this message wants speed-optimized encode/calculate_size. + # When set, __attribute__((optimize("O2"))) is added to the definitions + # so GCC inlines the small ProtoEncode helpers even under -Os. + is_speed_optimized = get_opt(desc, pb.speed_optimized, False) + speed_attr = ( + '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' + if is_speed_optimized + else "" + ) + # Only generate encode method if this message needs encoding and has fields if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls @@ -2688,7 +2720,7 @@ def build_message_type( ) for line in encode ] - o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)) + "\n" o += " return pos;\n" @@ -2702,7 +2734,7 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" o += " return size;\n" @@ -3009,7 +3041,7 @@ def build_service_message_type( if source in (SOURCE_BOTH, SOURCE_CLIENT): # Only add ifdef when we're actually generating content if ifdef is not None: - hout += f"#ifdef {ifdef}\n" + hout += _make_ifdef_line(ifdef) + "\n" # Generate receive handler and switch case func = f"on_{snake}" has_fields = any(not field.options.deprecated for field in mt.field) @@ -3290,8 +3322,8 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint content += "#endif\n" dump_cpp += "#endif\n" if enum_ifdef is not None: - content += f"#ifdef {enum_ifdef}\n" - dump_cpp += f"#ifdef {enum_ifdef}\n" + content += _make_ifdef_line(enum_ifdef) + "\n" + dump_cpp += _make_ifdef_line(enum_ifdef) + "\n" current_ifdef = enum_ifdef content += s @@ -3366,9 +3398,9 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint if dump_cpp: dump_cpp += "#endif\n" if msg_ifdef is not None: - content += f"#ifdef {msg_ifdef}\n" - cpp += f"#ifdef {msg_ifdef}\n" - dump_cpp += f"#ifdef {msg_ifdef}\n" + content += _make_ifdef_line(msg_ifdef) + "\n" + cpp += _make_ifdef_line(msg_ifdef) + "\n" + dump_cpp += _make_ifdef_line(msg_ifdef) + "\n" current_ifdef = msg_ifdef content += s @@ -3517,7 +3549,7 @@ static const char *const TAG = "api.service"; for id_ in sorted(ids): _, ifdef, case_label = RECEIVE_CASES[id_] if ifdef: - result += f"#ifdef {ifdef}\n" + result += _make_ifdef_line(ifdef) + "\n" result += f" case {case_label}: {comment}\n" if ifdef: result += "#endif\n" @@ -3560,7 +3592,7 @@ static const char *const TAG = "api.service"; out += " switch (msg_type) {\n" for i, (case, ifdef, case_label) in cases: if ifdef is not None: - out += f"#ifdef {ifdef}\n" + out += _make_ifdef_line(ifdef) + "\n" c = f" case {case_label}: {{\n" c += indent(case, " ") + "\n" diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ec3eab3a..02ec08bc3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -672,7 +672,7 @@ def lint_using_esp_idf_deprecated(fname, line, col, content): ) -@lint_content_check(include=["*.h"]) +@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( @@ -722,18 +722,22 @@ def lint_trailing_whitespace(fname, match): # Heap-allocating helpers that cause fragmentation on long-running embedded devices. # These return std::string and should be replaced with stack-based alternatives. HEAP_ALLOCATING_HELPERS = { + "base64_encode": "base64_encode_to() with a pre-allocated buffer", "format_bin": "format_bin_to() with a stack buffer", "format_hex": "format_hex_to() with a stack buffer", "format_hex_pretty": "format_hex_pretty_to() with a stack buffer", "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", + "str_lower_case": "manual tolower() with a stack buffer", "str_sanitize": "str_sanitize_to() with a stack buffer", "str_truncate": "removal (function is unused)", + "str_until": "manual strchr()/find() with a StringRef or stack buffer", "str_upper_case": "removal (function is unused)", "str_snake_case": "removal (function is unused)", "str_sprintf": "snprintf() with a stack buffer", "str_snprintf": "snprintf() with a stack buffer", + "value_accuracy_to_string": "value_accuracy_to_buf() with a stack buffer", } @@ -743,24 +747,33 @@ HEAP_ALLOCATING_HELPERS = { # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. # CPP_RE_EOL captures rest of line so NOLINT comments are detected r"[^\w](" + r"base64_encode(?!_)|" r"format_bin(?!_)|" r"format_hex(?!_)|" r"format_hex_pretty(?!_)|" r"format_mac_address_pretty|" r"get_mac_address_pretty(?!_)|" r"get_mac_address(?!_)|" + r"str_lower_case|" r"str_sanitize(?!_)|" r"str_truncate|" + r"str_until|" r"str_upper_case|" r"str_snake_case|" r"str_sprintf|" - r"str_snprintf" + r"str_snprintf|" + r"value_accuracy_to_string" r")\s*\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves + "esphome/core/alloc_helpers.h", + "esphome/core/alloc_helpers.cpp", + # Backward compatibility re-exports (remove before 2026.11.0) "esphome/core/helpers.h", "esphome/core/helpers.cpp", + # Vendored third-party library + "esphome/components/http_request/httplib.h", ], ) def lint_no_heap_allocating_helpers(fname, match): @@ -812,6 +825,7 @@ def lint_no_sprintf(fname, match): "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string "esphome/core/helpers.cpp", + "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", # Test fixtures - not production embedded code diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 92faa0581..5080a9fec 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -26,12 +26,11 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" PLATFORMIO_OPTIONS = { - "build_unflags": [ - "-Os", # remove default size-opt - ], "build_flags": [ - "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) + "-Os", # match firmware optimization level (detects inlining regressions) "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9..f03644754 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -402,8 +402,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: Benchmarks run when any of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - 2. A directly changed component has benchmark files (no dependency expansion) - 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + 2. The host platform changed (esphome/components/host/*) — benchmarks + are built and run on the host platform, so its implementations of + ``millis()``/``micros()``/etc. affect every benchmark + 3. A directly changed component has benchmark files (no dependency expansion) + 4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. @@ -420,6 +423,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Host platform supplies the runtime that benchmarks execute on + if any(f.startswith("esphome/components/host/") for f in files): + return True + # Check if benchmark infrastructure changed if any( f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES diff --git a/script/helpers.py b/script/helpers.py index c9c550d88..7a6d7ecef 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ast from collections.abc import Callable +from dataclasses import dataclass, field from functools import cache import hashlib import json @@ -139,6 +141,109 @@ def get_component_test_files( return list(tests_dir.glob("test.*.yaml")) +@dataclass(frozen=True) +class ComponentMetadata: + """Statically-parsed AUTO_LOAD and CONFLICTS_WITH declarations.""" + + auto_load: frozenset[str] = field(default_factory=frozenset) + conflicts_with: frozenset[str] = field(default_factory=frozenset) + + +@cache +def parse_component_metadata(name: str) -> ComponentMetadata: + """Return the AUTO_LOAD / CONFLICTS_WITH declarations for a component. + + Parses the component's ``esphome/components//__init__.py`` statically. + Callable forms (``def AUTO_LOAD():``) require runtime imports and are + reported as empty -- safe for conflict detection since they cannot be + evaluated without executing the module. + """ + init_file = Path(root_path) / ESPHOME_COMPONENTS_PATH / name / "__init__.py" + if not init_file.exists(): + return ComponentMetadata() + try: + tree = ast.parse(init_file.read_text(encoding="utf-8")) + except (OSError, SyntaxError, UnicodeError): + return ComponentMetadata() + fields: dict[str, frozenset[str]] = { + "AUTO_LOAD": frozenset(), + "CONFLICTS_WITH": frozenset(), + } + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.List): + continue + for target in node.targets: + if not isinstance(target, ast.Name) or target.id not in fields: + continue + fields[target.id] = frozenset( + e.value + for e in node.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ) + return ComponentMetadata( + auto_load=fields["AUTO_LOAD"], + conflicts_with=fields["CONFLICTS_WITH"], + ) + + +@dataclass +class _ConflictWalk: + loaded: set[str] + rejects: set[str] + + +def split_conflicting_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Split groups so components declaring mutual CONFLICTS_WITH end up in separate builds. + + A conflict propagates through AUTO_LOAD: if X declares CONFLICTS_WITH=[Y] + and Z auto-loads Y, then X and Z conflict (e.g. bme680_bsec vs. + bme68x_bsec2_i2c which auto-loads bme68x_bsec2). Only components that + appear in the batch (and their AUTO_LOAD closures) are parsed. The + conflict relation is treated as symmetric even when only one side + declares it (e.g. ethernet rejects wifi but wifi does not declare the + reverse). + """ + batch = {c for comps in grouped_components.values() for c in comps} + + walks: dict[str, _ConflictWalk] = {} + for comp in batch: + walk = _ConflictWalk(loaded={comp}, rejects=set()) + stack = [comp] + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + walks[comp] = walk + + def conflicts(a: str, b: str) -> bool: + wa, wb = walks[a], walks[b] + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( + wa.loaded + ) + + result: dict[tuple[str, str], list[str]] = {} + for (platform, signature), components in grouped_components.items(): + buckets: list[list[str]] = [] + for comp in components: + for bucket in buckets: + if not any(conflicts(comp, other) for other in bucket): + bucket.append(comp) + break + else: + buckets.append([comp]) + if len(buckets) == 1: + result[(platform, signature)] = buckets[0] + continue + for index, bucket in enumerate(buckets): + key = signature if index == 0 else f"{signature}__conflict{index}" + result[(platform, key)] = bucket + return result + + def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color suffix = colorama.Style.RESET_ALL if reset else "" @@ -174,7 +279,12 @@ def build_all_include(header_files: list[str] | None = None) -> None: if line ] - headers = [f'#include "{h}"' for h in header_files] + from esphome.writer import ENTITY_TYPES_H_TARGET + + # X-macro files are included multiple times with different macro definitions + # and must not be included bare in the all-include header + exclude = {ENTITY_TYPES_H_TARGET} + headers = [f'#include "{h}"' for h in header_files if h not in exclude] headers.sort() headers.append("") content = "\n".join(headers) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 65d09efb9..d95cdcbe8 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -28,7 +28,7 @@ from script.analyze_component_buses import ( create_grouping_signature, merge_compatible_bus_groups, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups # Weighting for batch creation # Isolated components can't be grouped/merged, so they count as 10x @@ -145,6 +145,11 @@ def create_intelligent_batches( # improving the efficiency of test_build_components.py grouping signature_groups = merge_compatible_bus_groups(signature_groups) + # Split groups containing mutually-incompatible components (CONFLICTS_WITH). + # Without this, batch weighting assumes the group is one build when it will + # actually be split into two at build time -- throwing off CI distribution. + signature_groups = split_conflicting_groups(signature_groups) + # Create batches by keeping signature groups together # Components with the same signature stay in the same batches batches = [] diff --git a/script/stress_test_connect.py b/script/stress_test_connect.py new file mode 100644 index 000000000..f91a7e8f9 --- /dev/null +++ b/script/stress_test_connect.py @@ -0,0 +1,84 @@ +"""Rapid connect/disconnect stress test for ESPHome native API.""" + +import asyncio +import sys +import time + +from aioesphomeapi import APIClient + +HOST = "192.168.1.100" +PORT = 6053 +PASSWORD = "" +NOISE_PSK = None +ITERATIONS = 500 +CONCURRENCY = 4 # simultaneous connection attempts + + +async def connect_disconnect(client_id: int, iteration: int) -> tuple[int, bool, str]: + """Connect and immediately disconnect.""" + cli = APIClient(HOST, PORT, PASSWORD, noise_psk=NOISE_PSK) + try: + await asyncio.wait_for(cli.connect(login=True), timeout=10) + await cli.disconnect() + return iteration, True, "" + except Exception as e: + return ( + iteration, + False, + f"client{client_id} iter{iteration}: {type(e).__name__}: {e}", + ) + finally: + await cli.disconnect(force=True) + + +async def main() -> None: + iterations = int(sys.argv[1]) if len(sys.argv) > 1 else ITERATIONS + concurrency = int(sys.argv[2]) if len(sys.argv) > 2 else CONCURRENCY + + print(f"Stress testing {HOST}:{PORT}") + print(f"Iterations: {iterations}, Concurrency: {concurrency}") + print() + + success = 0 + fail = 0 + errors: list[str] = [] + start = time.monotonic() + + sem = asyncio.Semaphore(concurrency) + + async def run(client_id: int, iteration: int) -> tuple[int, bool, str]: + async with sem: + return await connect_disconnect(client_id, iteration) + + tasks = [asyncio.create_task(run(i % concurrency, i)) for i in range(iterations)] + + for coro in asyncio.as_completed(tasks): + iteration, ok, err = await coro + if ok: + success += 1 + else: + fail += 1 + errors.append(err) + total = success + fail + if total % 10 == 0 or not ok: + elapsed = time.monotonic() - start + rate = total / elapsed if elapsed > 0 else 0 + print(f"[{total}/{iterations}] ok={success} fail={fail} ({rate:.1f}/s)") + if err: + print(f" ERROR: {err}") + + elapsed = time.monotonic() - start + print() + print(f"Done in {elapsed:.1f}s") + print(f"Success: {success}, Failed: {fail}, Rate: {iterations / elapsed:.1f}/s") + + if errors: + print("\nLast 10 errors:") + for e in errors[-10:]: + print(f" {e}") + + sys.exit(1 if fail > 0 else 0) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/script/test_build_components.py b/script/test_build_components.py index e369b0364..82d05f78b 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -39,7 +39,7 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, uses_local_file_references, ) -from script.helpers import get_component_test_files +from script.helpers import get_component_test_files, split_conflicting_groups from script.merge_component_configs import merge_component_configs @@ -675,6 +675,13 @@ def run_grouped_component_tests( # as long as they don't have conflicting configurations for the same bus type grouped_components = merge_compatible_bus_groups(grouped_components) + # Split groups that contain components declaring CONFLICTS_WITH each other. + # The bus-level merge above only considers shared bus configs; components + # with the same bus signature (e.g. both I2C) can still be mutually + # incompatible (e.g. bme680_bsec vs. bme68x_bsec2_i2c which auto-loads + # bme68x_bsec2). Those must end up in separate builds. + grouped_components = split_conflicting_groups(grouped_components) + # Print detailed grouping plan print("\nGrouping Plan:") print("-" * 80) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 72ca3f6e9..299649029 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -20,3 +20,8 @@ CONFIG_BT_ENABLED=y # esp32_camera CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y CONFIG_ESP32_SPIRAM_SUPPORT=y + +# zigbee +CONFIG_ZB_ENABLED=y +CONFIG_ZB_ZED=y +CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/benchmarks/components/api/bench_log_response.cpp b/tests/benchmarks/components/api/bench_log_response.cpp new file mode 100644 index 000000000..4ef57987b --- /dev/null +++ b/tests/benchmarks/components/api/bench_log_response.cpp @@ -0,0 +1,118 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Typical log line: "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy" +static constexpr const char *kTypicalLogLine = + "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy"; + +// Short log line: "[12:34:56][I][app:029]: Running..." +static constexpr const char *kShortLogLine = "[12:34:56][I][app:029]: Running..."; + +// --- Encode --- + +static void Encode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_LogResponse_Typical); + +static void Encode_LogResponse_Short(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_INFO; + msg.set_message(reinterpret_cast(kShortLogLine), strlen(kShortLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_LogResponse_Short); + +// --- Calculate Size --- + +static void CalculateSize_LogResponse_Typical(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_LogResponse_Typical); + +// --- Calc + Encode (steady state) --- + +static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical); + +// --- Calc + Encode (fresh allocation each time) --- + +static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical_Fresh); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index d9a9d158a..1ce9101ff 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include "esphome/core/helpers.h" @@ -307,4 +309,58 @@ static void Base64Decode_32Bytes(benchmark::State &state) { } BENCHMARK(Base64Decode_32Bytes); +// --- uint32_to_str() vs snprintf --- + +static void Uint32ToStr_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 12345); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Small); + +static void Snprintf_Uint32_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(12345)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Small); + +static void Uint32ToStr_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 4294967295u); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Large); + +static void Snprintf_Uint32_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(4294967295u)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Large); + } // namespace esphome::benchmarks diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828c..ac492e275 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/ili9xxx/__init__.py b/tests/component_tests/ili9xxx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml new file mode 100644 index 000000000..bc6148b8d --- /dev/null +++ b/tests/component_tests/ili9xxx/config/ili9xxx_test.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: ili9xxx + id: tft_display + model: ST7789V + cs_pin: GPIO5 + dc_pin: GPIO17 + reset_pin: GPIO16 + invert_colors: false diff --git a/tests/component_tests/ili9xxx/test_ili9xxx.py b/tests/component_tests/ili9xxx/test_ili9xxx.py new file mode 100644 index 000000000..3919eb382 --- /dev/null +++ b/tests/component_tests/ili9xxx/test_ili9xxx.py @@ -0,0 +1,31 @@ +"""Tests for the ili9xxx component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_ili9xxx_placement_new_uses_model_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for ili9xxx picking the right constructor under placement new. + + ili9xxx declares the ID as the base ``ILI9XXXDisplay`` but constructs a + model-specific subclass (e.g. ``ILI9XXXST7789V``) via ``MODELS[...].new()``. + Pvariable must emit placement new for the subclass — otherwise the base + default constructor runs and the panel is left with a null init sequence + and 0x0 dimensions, producing a silent blank screen. + """ + main_cpp = generate_main(component_config_path("ili9xxx_test.yaml")) + + # Storage is sized for the subclass so the full object fits. + assert "sizeof(ili9xxx::ILI9XXXST7789V)" in main_cpp + assert "alignas(ili9xxx::ILI9XXXST7789V)" in main_cpp + # Pointer is declared as the base type for polymorphism. + assert "static ili9xxx::ILI9XXXDisplay *const tft_display" in main_cpp + # Placement new runs the subclass constructor — this is the actual regression fix. + assert "new(tft_display) ili9xxx::ILI9XXXST7789V()" in main_cpp + # Base-class default constructor must NOT be used. + assert "new(tft_display) ili9xxx::ILI9XXXDisplay()" not in main_cpp diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 119bbf7fe..955e94552 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -7,6 +7,11 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4 + +# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their +# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test. +import esphome.components.xl9535 # noqa: F401 from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py new file mode 100644 index 000000000..8c45b4775 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -0,0 +1,185 @@ +"""Tests for the _final_validate buffer size calculation in mipi_spi.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def _custom_config( + width: int, + height: int, + color_depth: str | int | None = None, + **extra: Any, +) -> ConfigType: + """Build a minimal valid custom-model config with the given dimensions.""" + config: ConfigType = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": width, "height": height}, + "init_sequence": [[0xA0, 0x01]], + } + if color_depth is not None: + config["color_depth"] = color_depth + config.update(extra) + return config + + +# The auto buffer-size selection inside _final_validate targets ~20 kB of +# pixel buffer. For a buffer of ``depth_bytes * width * height``, it picks the +# smallest integer ``x`` in range(2, 8) such that +# ``min(20000, buffer // 4) / buffer >= 1 / x`` (falling back to ``x = 8``). +# The test cases below cover the full range of possible outcomes (1/4 .. 1/8). +@pytest.mark.parametrize( + ("width", "height", "color_depth", "expected"), + [ + # 16-bit color depth -- buffer = 2 * width * height + # 128*160*2 = 40960 B -> fraction = 10240/40960 = 0.25 -> x = 4 + pytest.param(128, 160, "16bit", 1.0 / 4, id="16bit_tiny"), + # 200*224*2 = 89600 B -> fraction = 20000/89600 ≈ 0.2232 -> x = 5 + pytest.param(200, 224, "16bit", 1.0 / 5, id="16bit_small"), + # 240*224*2 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(240, 224, "16bit", 1.0 / 6, id="16bit_medium"), + # 200*320*2 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(200, 320, "16bit", 1.0 / 7, id="16bit_large"), + # 240*320*2 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(240, 320, "16bit", 1.0 / 8, id="16bit_xlarge"), + # 320*480*2 = 307200 B -> fraction ≈ 0.0651 -> default x = 8 + pytest.param(320, 480, "16bit", 1.0 / 8, id="16bit_huge"), + # 8-bit color depth -- buffer = width * height + # 320*240 = 76800 B -> fraction = 19200/76800 = 0.25 -> x = 4 + pytest.param(320, 240, "8bit", 1.0 / 4, id="8bit_tiny"), + # 400*224 = 89600 B -> fraction ≈ 0.2232 -> x = 5 + pytest.param(400, 224, "8bit", 1.0 / 5, id="8bit_small"), + # 480*224 = 107520 B -> fraction ≈ 0.1860 -> x = 6 + pytest.param(480, 224, "8bit", 1.0 / 6, id="8bit_medium"), + # 400*320 = 128000 B -> fraction = 0.15625 -> x = 7 + pytest.param(400, 320, "8bit", 1.0 / 7, id="8bit_large"), + # 480*320 = 153600 B -> fraction ≈ 0.1302 -> default x = 8 + pytest.param(480, 320, "8bit", 1.0 / 8, id="8bit_xlarge"), + ], +) +def test_buffer_size_auto_selected( + width: int, + height: int, + color_depth: str, + expected: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """Without PSRAM or an explicit buffer_size, a fraction is chosen from the display size. + + Without any drawing method and without LVGL, final validation also auto-enables + ``show_test_card``, which in turn makes the component require a buffer and therefore + triggers the buffer-size selection path. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated(_custom_config(width, height, color_depth)) + + # Sanity check: final validation should have enabled the test card for us, + # which is what causes the buffer-size calculation to actually run. + assert config.get(CONF_SHOW_TEST_CARD) is True + assert config[CONF_BUFFER_SIZE] == pytest.approx(expected) + + +@pytest.mark.parametrize( + "buffer_size", + [0.125, 0.25, 0.5, 1.0], + ids=["one_eighth", "one_quarter", "half", "full"], +) +def test_explicit_buffer_size_is_preserved( + buffer_size: float, + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicitly configured buffer_size is never overridden by final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = _validated( + _custom_config(240, 320, "16bit", buffer_size=buffer_size), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(buffer_size) + + +def test_buffer_size_not_set_when_psram_enabled( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """When PSRAM is enabled the auto buffer-size selection is skipped.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + # Presence of the psram domain in the full config is what _final_validate checks. + set_component_config("psram", True) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + + +def test_buffer_size_not_set_when_buffer_not_required( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """With LVGL present and no drawing methods, no buffer fraction is chosen. + + LVGL suppresses the automatic show_test_card injection, which means + ``requires_buffer`` is False and the early-return branch fires. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + config = _validated(_custom_config(240, 320, "16bit")) + + assert CONF_BUFFER_SIZE not in config + # And no test card should have been auto-enabled either. + assert not config.get(CONF_SHOW_TEST_CARD) + + +def test_buffer_size_selected_when_lvgl_with_test_card( + set_core_config: SetCoreConfigCallable, + set_component_config, +) -> None: + """LVGL present + an explicit drawing method still triggers buffer sizing. + + When LVGL is enabled, ``show_test_card`` is not injected automatically, + but users can still request it explicitly -- in that case ``requires_buffer`` + is True and the buffer-size heuristic still runs. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("lvgl", []) + + # 128x160 @ 16bit -> expected 1/4 (see test_buffer_size_auto_selected). + config = _validated( + _custom_config(128, 160, "16bit", show_test_card=True), + ) + + assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py new file mode 100644 index 000000000..cdac430ff --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -0,0 +1,105 @@ +"""Tests for the esphome OTA platform final_validate logic.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esphome.ota import ota_esphome_final_validate +from esphome.const import ( + CONF_ESPHOME, + CONF_ID, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_VERSION, +) +from esphome.core import ID +import esphome.final_validate as fv + + +def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]: + config: dict[str, Any] = { + CONF_PLATFORM: CONF_ESPHOME, + CONF_ID: ID(f"ota_esphome_{port}", is_manual=False), + CONF_VERSION: 2, + CONF_PORT: port, + } + config.update(kwargs) + return config + + +def test_single_esphome_ota_instance_accepted() -> None: + """A single ESPHome OTA config passes final_validate untouched.""" + full_conf = {CONF_OTA: [_make_ota_config(port=3232)]} + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + finally: + fv.full_config.reset(token) + + +def test_same_port_configs_merge(caplog: pytest.LogCaptureFixture) -> None: + """Two ESPHome OTA configs on the same port merge into one instance.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}), + _make_ota_config(port=3232), + ] + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + assert any("Found and merged" in record.message for record in caplog.records), ( + "Expected merge warning not found in log" + ) + finally: + fv.full_config.reset(token) + + +def test_multiple_ports_rejected() -> None: + """Two ESPHome OTA configs on different ports raise cv.Invalid.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + _make_ota_config(port=3233), + ] + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match=r"Only a single port is supported for 'ota' 'platform: esphome'", + ): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_non_esphome_ota_unaffected() -> None: + """Non-esphome OTA platforms are not subject to the single-instance rule.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + {CONF_PLATFORM: "http_request", CONF_ID: ID("ota_hr", is_manual=False)}, + ] + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 3 + finally: + fv.full_config.reset(token) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 0893c7dcb..13a6da9f2 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1,12 +1,21 @@ """Tests for the packages component.""" +import logging from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages -from esphome.components.substitutions import do_substitution_pass +from esphome.components.packages import ( + CONFIG_SCHEMA, + _substitute_package_definition, + _walk_packages, + do_packages_pass, + is_package_definition, + merge_packages, +) +from esphome.components.substitutions import ContextVars, do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove @@ -37,7 +46,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import add_context +from esphome.yaml_util import DocumentPath, IncludeFile, add_context, load_yaml # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -79,6 +88,44 @@ def packages_pass(config): return config +_INCLUDE_FILE = "INCLUDE_FILE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # IncludeFile objects are package definitions + (_INCLUDE_FILE, True), + # Git URL shorthand strings are package definitions + ("github://esphome/firmware/base.yaml@main", True), + # Remote package dicts (with url key) are package definitions + ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), + # Plain config dicts are NOT package definitions (they are config fragments) + ({"wifi": {"ssid": "test"}}, False), + # None is not a package definition + (None, False), + # Lists are not package definitions + ([{"wifi": {"ssid": "test"}}], False), + # Empty dicts are not package definitions + ({}, False), + ], + ids=[ + "include_file", + "git_shorthand", + "remote_package", + "config_fragment", + "none", + "list", + "empty_dict", + ], +) +def test_is_package_definition(value: object, expected: bool) -> None: + """Test that is_package_definition correctly identifies package definitions.""" + if value is _INCLUDE_FILE: + value = MagicMock(spec=IncludeFile) + assert is_package_definition(value) is expected + + def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -1061,6 +1108,51 @@ def test_packages_invalid_type_raises() -> None: do_packages_pass(config) +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a list, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = [package_content] + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a dict, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = {"network": package_content} + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_invalid_type_raises( + mock_resolve_include, +) -> None: + """When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised.""" + include_file = MagicMock(spec=IncludeFile) + mock_resolve_include.return_value = "not_a_dict_or_list" + + config = {CONF_PACKAGES: include_file} + with pytest.raises( + cv.Invalid, match="Packages must be a key to value mapping or list" + ) as exc_info: + do_packages_pass(config) + + assert exc_info.value.path == [CONF_PACKAGES] + + @pytest.mark.parametrize( "invalid_package", [ @@ -1107,6 +1199,142 @@ def test_invalid_package_contents_masked_by_deprecation( do_packages_pass(config) +def test_named_dict_with_include_files_no_false_deprecation_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Package errors in named dicts must not trigger the deprecated fallback.""" + good_include = MagicMock(spec=IncludeFile) + bad_include = MagicMock(spec=IncludeFile) + + config = { + CONF_PACKAGES: { + "good_pkg": good_include, + "bad_pkg": bad_include, + }, + } + + call_count = 0 + + def failing_callback( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First package processes fine + return {CONF_WIFI: {CONF_SSID: "test"}} + # Second package has an error (e.g. jinja syntax error) + raise cv.Invalid("simulated jinja error in bad_pkg") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="simulated jinja error"), + ): + _walk_packages(config, failing_callback) + + # Must NOT emit the deprecated single-package warning + assert "deprecated" not in caplog.text.lower() + + +def test_validate_deprecated_false_raises_directly( + caplog: pytest.LogCaptureFixture, +) -> None: + """With validate_deprecated=False, errors raise directly without fallback. + + This is the codepath used for remote packages where _process_remote_package + returns already-resolved dicts that is_package_definition cannot detect. + """ + config = { + CONF_PACKAGES: { + "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, + "pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}}, + }, + } + + call_count = 0 + + def failing_callback( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + return package_config + raise cv.Invalid("nested error") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="nested error"), + ): + _walk_packages(config, failing_callback, validate_deprecated=False) + + assert "deprecated" not in caplog.text.lower() + + +def test_error_on_first_declared_package_still_detected() -> None: + """When the first declared package errors, it's the last processed in reverse. + + All other entries are already resolved to dicts, but the failing entry + retains its original IncludeFile value since assignment was skipped. + """ + config = { + CONF_PACKAGES: { + "first_pkg": MagicMock(spec=IncludeFile), + "second_pkg": MagicMock(spec=IncludeFile), + "third_pkg": MagicMock(spec=IncludeFile), + }, + } + + call_count = 0 + + def fail_on_last( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: + nonlocal call_count + call_count += 1 + # Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3) + if call_count < 3: + return {CONF_WIFI: {CONF_SSID: "test"}} + raise cv.Invalid("error in first_pkg") + + with pytest.raises(cv.Invalid, match="error in first_pkg"): + _walk_packages(config, fail_on_last) + + +def test_deprecated_single_package_fallback_still_works( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated single-package form still falls back at the top level. + + When a dict's values are plain config fragments (not package definitions) + and the callback fails, the deprecated fallback wraps the dict in a list + and retries with a deprecation warning. + """ + config = { + CONF_PACKAGES: { + CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, + }, + } + + attempt = 0 + + def fail_then_succeed( + package_config: dict, context: object, path: DocumentPath | None = None + ) -> dict: + nonlocal attempt + attempt += 1 + if attempt == 1: + # First attempt: treating as named dict fails + raise cv.Invalid("not a valid package") + # Second attempt: after fallback wraps as list, succeeds + return package_config + + with caplog.at_level(logging.WARNING): + _walk_packages(config, fail_then_succeed) + + assert "deprecated" in caplog.text.lower() + + def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { @@ -1181,3 +1409,215 @@ def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: "CORE.raw_config should contain esphome section after package merge" ) assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME + + +# --------------------------------------------------------------------------- +# _substitute_package_definition +# --------------------------------------------------------------------------- + + +def test_substitute_package_definition_local_dict_returned_unchanged() -> None: + """A plain local config dict is not substituted and is returned as-is.""" + pkg = {CONF_WIFI: {CONF_SSID: "test"}} + result = _substitute_package_definition(pkg, ContextVars()) + assert result is pkg + + +def test_substitute_package_definition_string_resolved_with_context() -> None: + """A string package definition has its variables substituted.""" + ctx = ContextVars({"variant": "esp32"}) + result = _substitute_package_definition("device-${variant}.yaml", ctx) + assert result == "device-esp32.yaml" + + +def test_substitute_package_definition_undefined_in_string() -> None: + """An undefined variable in a package URL string raises cv.Invalid.""" + with pytest.raises(cv.Invalid, match="Undefined variable in package definition"): + _substitute_package_definition( + "github://org/repo/${undefined_var}/pkg.yaml", ContextVars() + ) + + +def test_substitute_package_definition_undefined_in_remote_dict_field() -> None: + """An undefined variable inside a remote-dict field names the offending field.""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + {CONF_URL: "github://${typo}/repo"}, ContextVars() + ) + err = str(exc_info.value) + assert "'typo' is undefined" in err + assert CONF_URL in err + + +def test_substitute_package_definition_undefined_in_remote_dict_non_first_field() -> ( + None +): + """The field path joins correctly for non-first dict fields (e.g. ``ref``).""" + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition( + { + CONF_URL: "github://org/repo", + CONF_REF: "branch-${branch_typo}", + }, + ContextVars(), + ) + err = str(exc_info.value) + assert "'branch_typo' is undefined" in err + assert CONF_REF in err + + +def test_substitute_package_definition_includes_source_location(tmp_path: Path) -> None: + """A package loaded from YAML surfaces file/line/col in the cv.Invalid message. + + Line/column are rendered 1-based (matching config.line_info() and editor + line numbering) and point at the offending scalar, not the enclosing dict. + """ + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "packages:\n broken: github://org/repo/${undefined_var}/pkg.yaml\n" + ) + config = load_yaml(yaml_file) + package_config = config[CONF_PACKAGES]["broken"] + + with pytest.raises(cv.Invalid) as exc_info: + _substitute_package_definition(package_config, ContextVars()) + + err = str(exc_info.value) + assert "main.yaml" in err + # The offending value lives on line 2 (1-based). Column depends on the YAML + # loader, so we only pin line and check that a 1-based column is present. + match = re.search(r"main\.yaml (\d+):(\d+)", err) + assert match, err + line, col = int(match.group(1)), int(match.group(2)) + assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" + assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" + + +def test_substitute_package_definition_vars_preserved_literally() -> None: + """``vars:`` blocks in remote-package files are not substituted prematurely. + + Variable references inside ``vars:`` may resolve to substitutions + contributed by sibling packages that have not yet been loaded, so they + must be passed through untouched and resolved later by the package YAML. + """ + pkg = { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_FILES: [ + { + CONF_PATH: "common/somefile.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + # Note: PIN is intentionally NOT in the context — it is meant to + # be resolved later, when the package YAML is processed. + result = _substitute_package_definition(pkg, ContextVars()) + + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_other_fields_still_substituted() -> None: + """Marking ``vars:`` literal does not stop substitution of url/ref/path.""" + ctx = ContextVars({"branch": "release", "org": "esphome"}) + pkg = { + CONF_URL: "https://github.com/${org}/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + { + CONF_PATH: "common/sensor.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_URL] == "https://github.com/esphome/firmware" + assert result[CONF_REF] == "release" + # vars passed through unchanged + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_without_vars_unaffected() -> None: + """Files entries without a ``vars:`` block continue to work.""" + ctx = ContextVars({"branch": "main"}) + pkg = { + CONF_URL: "https://github.com/esphome/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + {CONF_PATH: "file1.yaml"}, + "file2.yaml", + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_REF] == "main" + assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"} + assert result[CONF_FILES][1] == "file2.yaml" + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_package_vars_resolved_against_sibling_package_substitutions( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """A ``vars:`` reference in one remote package can resolve to a + substitution defined in a sibling remote package. + + A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a + lower-priority package's ``files: -> vars:`` references that substitution. + Because packages are processed highest-priority first and ``vars:`` is now + preserved literally during package-definition processing, the substitution + is resolved correctly when the package YAML itself is loaded. + """ + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + + # Two YAML files mocked from the "remote" repo: + # - platform.yaml exports a substitution ``SENSOR_PIN`` + # - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to + # ``${SENSOR_PIN}`` and resolved against the merged substitutions). + mock_load_yaml.side_effect = [ + # Order matches reverse-priority traversal (highest priority first). + OrderedDict( + { + CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"}, + } + ), + OrderedDict( + { + CONF_SENSOR: [ + { + CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, + CONF_NAME: TEST_SENSOR_NAME_1, + "pin": "${pin}", + } + ], + } + ), + ] + + config = { + CONF_PACKAGES: { + "special_sensor": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: [ + { + CONF_PATH: "sensor.yaml", + CONF_VARS: {"pin": "${SENSOR_PIN}"}, + }, + ], + CONF_REFRESH: "1d", + }, + "platform": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: ["platform.yaml"], + CONF_REFRESH: "1d", + }, + } + } + + actual = packages_pass(config) + + assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" diff --git a/tests/component_tests/template/__init__.py b/tests/component_tests/template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/component_tests/template/config/template_text_restore.yaml b/tests/component_tests/template/config/template_text_restore.yaml new file mode 100644 index 000000000..4574470ea --- /dev/null +++ b/tests/component_tests/template/config/template_text_restore.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +host: + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/component_tests/template/test_template_text.py b/tests/component_tests/template/test_template_text.py new file mode 100644 index 000000000..2ce9a88d6 --- /dev/null +++ b/tests/component_tests/template/test_template_text.py @@ -0,0 +1,44 @@ +"""Tests for the template text component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_template_text_saver_uses_placement_new_with_templated_subclass( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Regression test for template text restore saver using placement new. + + When ``restore_value: true``, the saver is its own Pvariable with + placement new: storage is sized for ``TextSaver``, the + declared pointer stays at ``TemplateTextSaverBase *`` for polymorphism, + and the templated subclass constructor runs. A regression would either + reintroduce the heap ``new TextSaver<...>()`` expression or size the + storage for the base class and silently skip the subclass ctor. + """ + main_cpp = generate_main(component_config_path("template_text_restore.yaml")) + + # Storage is sized and aligned for the templated subclass. + assert "sizeof(template_::TextSaver<10>)" in main_cpp + assert "alignas(template_::TextSaver<10>)" in main_cpp + # Pointer declared as base type for polymorphism. + assert ( + "static template_::TemplateTextSaverBase *const test_text_restore_value_saver" + in main_cpp + ) + # Placement new runs the templated subclass constructor. + assert "new(test_text_restore_value_saver) template_::TextSaver<10>()" in main_cpp + # Base-class default ctor must NOT be used. + assert ( + "new(test_text_restore_value_saver) template_::TemplateTextSaverBase()" + not in main_cpp + ) + # No heap `new TextSaver<...>()` left over — the pre-fix pattern. + assert "new template_::TextSaver<" not in main_cpp + # Saver is wired into the text component. + assert ( + "test_text_restore->set_value_saver(test_text_restore_value_saver)" in main_cpp + ) diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index 8f9306683..c16e2e834 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -3,3 +3,4 @@ output: id: ac_dimmer_1 gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} + zero_cross_interrupt_type: ANY diff --git a/tests/components/audio_http/common.yaml b/tests/components/audio_http/common.yaml new file mode 100644 index 000000000..b7457165a --- /dev/null +++ b/tests/components/audio_http/common.yaml @@ -0,0 +1,7 @@ +psram: + +media_source: + - platform: audio_http + id: audio_http_source + buffer_size: 100000 + task_stack_in_psram: true diff --git a/tests/components/audio_http/test.esp32-idf.yaml b/tests/components/audio_http/test.esp32-idf.yaml new file mode 100644 index 000000000..dade44d14 --- /dev/null +++ b/tests/components/audio_http/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 8bddeb740..e779f7f07 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -50,6 +50,13 @@ button: - platform: template name: Canbus Actions on_press: + - canbus.send: + can_id: 0x601 + data: [0, 1, 2] + - canbus.send: + can_id: 0x1FFFFFFF + use_extended_id: true + data: [0, 1, 2] - canbus.send: "abc" - canbus.send: [0, 1, 2] - canbus.send: !lambda return {0, 1, 2}; diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp new file mode 100644 index 000000000..468185787 --- /dev/null +++ b/tests/components/core/helpers_test.cpp @@ -0,0 +1,58 @@ +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome { + +TEST(HelpersTest, Ilog10PowersOfTen) { + EXPECT_EQ(ilog10(1.0f), 0); + EXPECT_EQ(ilog10(10.0f), 1); + EXPECT_EQ(ilog10(100.0f), 2); + EXPECT_EQ(ilog10(1000.0f), 3); + EXPECT_EQ(ilog10(10000.0f), 4); + EXPECT_EQ(ilog10(100000.0f), 5); + EXPECT_EQ(ilog10(0.1f), -1); + EXPECT_EQ(ilog10(0.001f), -3); +} + +TEST(HelpersTest, Ilog10General) { + EXPECT_EQ(ilog10(5.0f), 0); + EXPECT_EQ(ilog10(9.99f), 0); + EXPECT_EQ(ilog10(50.0f), 1); + EXPECT_EQ(ilog10(99.0f), 1); + EXPECT_EQ(ilog10(999.0f), 2); + EXPECT_EQ(ilog10(0.5f), -1); + EXPECT_EQ(ilog10(0.0072f), -3); + EXPECT_EQ(ilog10(120000.0f), 5); + EXPECT_EQ(ilog10(123456.789f), 5); +} + +TEST(HelpersTest, Ilog10Negative) { + EXPECT_EQ(ilog10(-1.0f), 0); + EXPECT_EQ(ilog10(-10.0f), 1); + EXPECT_EQ(ilog10(-0.1f), -1); + EXPECT_EQ(ilog10(-123.456f), 2); +} + +// Verify that ilog10 + pow10_int produces the same rounding result as log10/pow. +// ilog10 may differ from floor(log10f()) for values not exactly representable in float +// (e.g. 0.01f is 0.00999...), but the full round-trip must match. +TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { + float values[] = {0.0072f, 0.05f, 0.1f, 0.5f, 1.0f, 3.14f, 9.99f, 10.0f, 42.0f, 100.0f, + 1234.5f, 9999.0f, 10000.0f, 99999.0f, 120000.0f, 999999.0f, -1.0f, -0.1f, -123.456f, -10000.0f}; + for (uint8_t digits = 1; digits <= 6; digits++) { + for (float v : values) { + // New implementation using ilog10 + pow10_int + float factor_new = pow10_int(digits - 1 - ilog10(v)); + float result_new = roundf(v * factor_new) / factor_new; + + // Reference using log10/pow + double factor_ref = pow(10.0, digits - std::ceil(std::log10(std::fabs(v)))); + float result_ref = static_cast(round(v * factor_ref) / factor_ref); + + EXPECT_FLOAT_EQ(result_new, result_ref) << "mismatch for value=" << v << " digits=" << (int) digits; + } + } +} + +} // namespace esphome diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp new file mode 100644 index 000000000..5fb77ef75 --- /dev/null +++ b/tests/components/core/test_helpers.cpp @@ -0,0 +1,216 @@ +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +// --- format_hex_to() --- + +TEST(FormatHexTo, Basic) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[7]; // 3 * 2 + 1 + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcdef"); +} + +TEST(FormatHexTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0f"); +} + +TEST(FormatHexTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_to(buffer, static_cast(0), data, 1); + // Should not crash, buffer unchanged + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexTo, BufferTooSmall) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[5]; // only room for 2 bytes + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcd"); +} + +TEST(FormatHexTo, MacAddress) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[13]; + format_hex_to(buffer, mac, 6); + EXPECT_STREQ(buffer, "aabbccddeeff"); +} + +// --- format_hex_pretty_to() --- + +TEST(FormatHexPrettyTo, BasicColon) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[9]; // 3 * 3 + format_hex_pretty_to(buffer, data, 3); + EXPECT_STREQ(buffer, "AB:CD:EF"); +} + +TEST(FormatHexPrettyTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_pretty_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0F"); +} + +TEST(FormatHexPrettyTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_pretty_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexPrettyTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_pretty_to(buffer, static_cast(0), data, 1); + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexPrettyTo, CustomSeparator) { + const uint8_t data[] = {0xAA, 0xBB, 0xCC}; + char buffer[9]; + format_hex_pretty_to(buffer, data, 3, '-'); + EXPECT_STREQ(buffer, "AA-BB-CC"); +} + +// --- format_mac_addr_upper() --- + +TEST(FormatMacAddrUpper, Basic) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "AA:BB:CC:DD:EE:FF"); +} + +TEST(FormatMacAddrUpper, AllZeros) { + const uint8_t mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "00:00:00:00:00:00"); +} + +// --- format_hex_char() --- + +TEST(FormatHexChar, LowercaseDigits) { + EXPECT_EQ(format_hex_char(0), '0'); + EXPECT_EQ(format_hex_char(9), '9'); + EXPECT_EQ(format_hex_char(10), 'a'); + EXPECT_EQ(format_hex_char(15), 'f'); +} + +TEST(FormatHexChar, UppercaseDigits) { + EXPECT_EQ(format_hex_pretty_char(0), '0'); + EXPECT_EQ(format_hex_pretty_char(9), '9'); + EXPECT_EQ(format_hex_pretty_char(10), 'A'); + EXPECT_EQ(format_hex_pretty_char(15), 'F'); +} + +// --- small_pow10() --- + +TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } +TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } +TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } +TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } + +// --- frac_to_str_unchecked() --- + +TEST(FracToStr, OneDigit) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 5, 1); + *end = '\0'; + EXPECT_STREQ(buf, "5"); + EXPECT_EQ(end - buf, 1); +} + +TEST(FracToStr, TwoDigits) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 46, 10); + *end = '\0'; + EXPECT_STREQ(buf, "46"); +} + +TEST(FracToStr, ThreeDigits) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 456, 100); + *end = '\0'; + EXPECT_STREQ(buf, "456"); + EXPECT_EQ(end - buf, 3); +} + +TEST(FracToStr, LeadingZeros) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 1, 100); + *end = '\0'; + EXPECT_STREQ(buf, "001"); + + end = frac_to_str_unchecked(buf, 5, 10); + *end = '\0'; + EXPECT_STREQ(buf, "05"); +} + +TEST(FracToStr, AllZeros) { + char buf[8]; + char *end = frac_to_str_unchecked(buf, 0, 100); + *end = '\0'; + EXPECT_STREQ(buf, "000"); + + end = frac_to_str_unchecked(buf, 0, 1); + *end = '\0'; + EXPECT_STREQ(buf, "0"); +} + +TEST(FracToStr, ZeroDivisor) { + char buf[8]; + buf[0] = 'X'; + char *end = frac_to_str_unchecked(buf, 0, 0); + EXPECT_EQ(end, buf); // writes nothing +} + +// --- buf_append_sep_str() --- + +TEST(BufAppendSepStr, Basic) { + char buf[32] = "23.46"; + char *start = buf + 5; + char *end = buf_append_sep_str(start, sizeof(buf) - 5, ' ', "°C", 3); + EXPECT_STREQ(buf, "23.46 °C"); + EXPECT_EQ(end - buf, 9); // "°C" is 3 bytes (UTF-8) +} + +TEST(BufAppendSepStr, EmptyString) { + char buf[32] = "100"; + char *start = buf + 3; + char *end = buf_append_sep_str(start, sizeof(buf) - 3, ' ', "", 0); + EXPECT_STREQ(buf, "100 "); + EXPECT_EQ(end - start, 1); // just the separator +} + +TEST(BufAppendSepStr, NoRoom) { + char buf[8] = "1234567"; + char *start = buf + 7; + char *end = buf_append_sep_str(start, 1, ' ', "unit", 4); + EXPECT_EQ(end, start); // nothing written +} + +TEST(BufAppendSepStr, Truncation) { + char buf[8] = "val"; + char *start = buf + 3; + // remaining = 5, separator takes 1, so 3 chars of string fit + null + char *end = buf_append_sep_str(start, 5, ' ', "longunit", 8); + *end = '\0'; + EXPECT_STREQ(buf, "val lon"); + EXPECT_EQ(end - buf, 7); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_uint32_to_str.cpp b/tests/components/core/test_uint32_to_str.cpp new file mode 100644 index 000000000..fc754429e --- /dev/null +++ b/tests/components/core/test_uint32_to_str.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +// --- uint32_to_str_unchecked() (internal, raw pointer) --- + +TEST(Uint32ToStr, InternalZero) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 0); + *end = '\0'; + EXPECT_STREQ(buf, "0"); + EXPECT_EQ(end - buf, 1); +} + +TEST(Uint32ToStr, InternalSingleDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 7); + *end = '\0'; + EXPECT_STREQ(buf, "7"); +} + +TEST(Uint32ToStr, InternalMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 12345); + *end = '\0'; + EXPECT_STREQ(buf, "12345"); + EXPECT_EQ(end - buf, 5); +} + +TEST(Uint32ToStr, InternalMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 4294967295u); + *end = '\0'; + EXPECT_STREQ(buf, "4294967295"); + EXPECT_EQ(end - buf, 10); +} + +TEST(Uint32ToStr, InternalPowersOfTen) { + char buf[UINT32_MAX_STR_SIZE]; + char *end; + + end = uint32_to_str_unchecked(buf, 10); + *end = '\0'; + EXPECT_STREQ(buf, "10"); + + end = uint32_to_str_unchecked(buf, 100); + *end = '\0'; + EXPECT_STREQ(buf, "100"); + + end = uint32_to_str_unchecked(buf, 1000000); + *end = '\0'; + EXPECT_STREQ(buf, "1000000"); +} + +// --- uint32_to_str() (public, span API) --- + +TEST(Uint32ToStr, SpanZero) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 0), 1u); + EXPECT_STREQ(buf, "0"); +} + +TEST(Uint32ToStr, SpanMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 12345), 5u); + EXPECT_STREQ(buf, "12345"); +} + +TEST(Uint32ToStr, SpanMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u); + EXPECT_STREQ(buf, "4294967295"); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp new file mode 100644 index 000000000..381a742a9 --- /dev/null +++ b/tests/components/core/test_value_accuracy.cpp @@ -0,0 +1,237 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +// Helper to call value_accuracy_to_buf and return as string +static std::string va_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + size_t len = value_accuracy_to_buf(sp, value, accuracy_decimals); + return std::string(buf, len); +} + +// Helper: reference implementation using snprintf for comparison +static std::string va_reference(float value, int8_t accuracy_decimals) { + // Replicate normalize_accuracy_decimals logic + if (accuracy_decimals < 0) { + float divisor; + if (accuracy_decimals == -1) { + divisor = 10.0f; + } else if (accuracy_decimals == -2) { + divisor = 100.0f; + } else { + divisor = pow10_int(-accuracy_decimals); + } + value = roundf(value / divisor) * divisor; + accuracy_decimals = 0; + } + char buf[VALUE_ACCURACY_MAX_LEN]; + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + return std::string(buf); +} + +// --- Basic formatting --- + +TEST(ValueAccuracyToBuf, ZeroDecimals) { + EXPECT_EQ(va_to_string(23.456f, 0), "23"); + EXPECT_EQ(va_to_string(0.0f, 0), "0"); + EXPECT_EQ(va_to_string(100.0f, 0), "100"); + EXPECT_EQ(va_to_string(1.0f, 0), "1"); +} + +TEST(ValueAccuracyToBuf, OneDecimal) { + EXPECT_EQ(va_to_string(23.456f, 1), "23.5"); + EXPECT_EQ(va_to_string(0.0f, 1), "0.0"); + EXPECT_EQ(va_to_string(1.05f, 1), va_reference(1.05f, 1)); +} + +TEST(ValueAccuracyToBuf, TwoDecimals) { + EXPECT_EQ(va_to_string(23.456f, 2), "23.46"); + EXPECT_EQ(va_to_string(0.0f, 2), "0.00"); + EXPECT_EQ(va_to_string(1.005f, 2), va_reference(1.005f, 2)); +} + +TEST(ValueAccuracyToBuf, ThreeDecimals) { + EXPECT_EQ(va_to_string(23.456f, 3), "23.456"); + EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); +} + +// --- Negative values --- + +TEST(ValueAccuracyToBuf, NegativeValues) { + EXPECT_EQ(va_to_string(-23.456f, 2), "-23.46"); + EXPECT_EQ(va_to_string(-0.5f, 1), "-0.5"); + EXPECT_EQ(va_to_string(-100.0f, 0), "-100"); +} + +// --- Negative accuracy_decimals (rounding to tens/hundreds) --- + +TEST(ValueAccuracyToBuf, NegativeAccuracy) { + EXPECT_EQ(va_to_string(1234.0f, -1), va_reference(1234.0f, -1)); + EXPECT_EQ(va_to_string(1234.0f, -2), va_reference(1234.0f, -2)); + EXPECT_EQ(va_to_string(56.0f, -1), va_reference(56.0f, -1)); +} + +// --- Special float values --- + +TEST(ValueAccuracyToBuf, NaN) { + std::string result = va_to_string(NAN, 2); + EXPECT_EQ(result, va_reference(NAN, 2)); +} + +TEST(ValueAccuracyToBuf, Infinity) { + std::string result = va_to_string(INFINITY, 2); + EXPECT_EQ(result, va_reference(INFINITY, 2)); +} + +TEST(ValueAccuracyToBuf, NegativeInfinity) { + std::string result = va_to_string(-INFINITY, 2); + EXPECT_EQ(result, va_reference(-INFINITY, 2)); +} + +// --- Edge cases --- + +TEST(ValueAccuracyToBuf, VerySmallValues) { + EXPECT_EQ(va_to_string(0.001f, 3), "0.001"); + EXPECT_EQ(va_to_string(0.001f, 2), "0.00"); + EXPECT_EQ(va_to_string(0.009f, 2), "0.01"); +} + +TEST(ValueAccuracyToBuf, LargeValues) { + EXPECT_EQ(va_to_string(999999.0f, 0), va_reference(999999.0f, 0)); + EXPECT_EQ(va_to_string(1013.25f, 2), "1013.25"); +} + +TEST(ValueAccuracyToBuf, Rounding) { + // 0.5 rounds up + EXPECT_EQ(va_to_string(23.5f, 0), "24"); + EXPECT_EQ(va_to_string(23.45f, 1), "23.5"); // float: 23.45 -> 23.4 or 23.5 + EXPECT_EQ(va_to_string(23.45f, 1), va_reference(23.45f, 1)); +} + +// --- Match snprintf for a range of typical sensor values --- + +TEST(ValueAccuracyToBuf, MatchesSnprintf) { + float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; + int8_t test_accuracies[] = {0, 1, 2, 3}; + + for (float value : test_values) { + for (int8_t acc : test_accuracies) { + EXPECT_EQ(va_to_string(value, acc), va_reference(value, acc)) + << "Mismatch for value=" << value << " accuracy=" << static_cast(acc); + } + } +} + +// --- Return value (length) --- + +TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + + size_t len = value_accuracy_to_buf(sp, 23.456f, 2); + EXPECT_EQ(len, 5u); // "23.46" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, 0.0f, 0); + EXPECT_EQ(len, 1u); // "0" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, -100.0f, 1); + EXPECT_EQ(len, 6u); // "-100.0" + EXPECT_EQ(strlen(buf), len); +} + +TEST(ValueAccuracyToBuf, NegativeZero) { + // Hand-rolled formatter must preserve snprintf's sign-of-zero behavior. + EXPECT_EQ(va_to_string(-0.0f, 2), va_reference(-0.0f, 2)); + EXPECT_EQ(va_to_string(-0.0f, 0), va_reference(-0.0f, 0)); + // Tiny negative that rounds to zero at this precision must still render as "-0.00". + EXPECT_EQ(va_to_string(-0.001f, 2), va_reference(-0.001f, 2)); +} + +TEST(ValueAccuracyToBuf, OverflowFallsBackToSnprintf) { + // |value| * 10^acc must exceed UINT32_MAX to exercise the snprintf fallback path. + EXPECT_EQ(va_to_string(1.0e7f, 3), va_reference(1.0e7f, 3)); + EXPECT_EQ(va_to_string(-1.0e7f, 3), va_reference(-1.0e7f, 3)); + EXPECT_EQ(va_to_string(5.0e9f, 0), va_reference(5.0e9f, 0)); +} + +// --- value_accuracy_with_uom_to_buf --- + +static std::string va_uom_to_string(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(uom); + size_t len = value_accuracy_with_uom_to_buf(sp, value, accuracy_decimals, ref); + return std::string(buf, len); +} + +static std::string va_uom_reference(float value, int8_t accuracy_decimals, const char *uom) { + char buf[VALUE_ACCURACY_MAX_LEN]; + if (!uom || *uom == '\0') { + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + } else { + snprintf(buf, sizeof(buf), "%.*f %s", accuracy_decimals, value, uom); + } + return std::string(buf); +} + +TEST(ValueAccuracyWithUomToBuf, BasicWithUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, "°C"), va_uom_reference(23.456f, 2, "°C")); + EXPECT_EQ(va_uom_to_string(1013.25f, 2, "hPa"), va_uom_reference(1013.25f, 2, "hPa")); + EXPECT_EQ(va_uom_to_string(-40.0f, 1, "°F"), va_uom_reference(-40.0f, 1, "°F")); + EXPECT_EQ(va_uom_to_string(100.0f, 0, "%"), va_uom_reference(100.0f, 0, "%")); +} + +TEST(ValueAccuracyWithUomToBuf, EmptyUnit) { + EXPECT_EQ(va_uom_to_string(23.456f, 2, ""), "23.46"); + EXPECT_EQ(va_uom_to_string(0.0f, 1, ""), "0.0"); +} + +TEST(ValueAccuracyWithUomToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref("°C"); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_EQ(strlen(buf), len); + EXPECT_EQ(len, strlen("23.46 °C")); +} + +TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { + // Build a unit long enough that value + " " + unit exceeds VALUE_ACCURACY_MAX_LEN. + // "23.46" (5) + " " (1) + unit -> must cap at buf.size()-1 and stay null-terminated. + std::string long_unit(VALUE_ACCURACY_MAX_LEN, 'U'); + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + StringRef ref(long_unit.c_str()); + size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref); + EXPECT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(strlen(buf), len); + // Should begin with the formatted value and a separator. + EXPECT_EQ(std::string(buf, 6), "23.46 "); +} + +TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { + const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; + float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; + int8_t accs[] = {0, 1, 2, 3}; + for (const char *u : units) { + for (float v : values) { + for (int8_t a : accs) { + EXPECT_EQ(va_uom_to_string(v, a, u), va_uom_reference(v, a, u)) + << "value=" << v << " acc=" << static_cast(a) << " uom=" << u; + } + } + } +} + +} // namespace esphome::core::testing diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e..7a1a70996 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml new file mode 100644 index 000000000..6362142be --- /dev/null +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -0,0 +1,12 @@ +deep_sleep: + run_duration: 10s + sleep_duration: 50s + +<<: !include common.yaml + +zigbee: + +sensor: + - platform: template + name: "Temperature" + id: temperature_sensor diff --git a/tests/components/dsmr/test.esp32-ard.yaml b/tests/components/dsmr/test.esp32-ard.yaml index f218b297a..41ea1e8d8 100644 --- a/tests/components/dsmr/test.esp32-ard.yaml +++ b/tests/components/dsmr/test.esp32-ard.yaml @@ -5,3 +5,10 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-ard.yaml <<: !include common.yaml + +sensor: + - platform: dsmr + energy_delivered_lux: + name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" + energy_delivered_tariff1: + name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" diff --git a/tests/components/dsmr/test.esp32-idf.yaml b/tests/components/dsmr/test.esp32-idf.yaml index 522f60db4..9eb7d3e17 100644 --- a/tests/components/dsmr/test.esp32-idf.yaml +++ b/tests/components/dsmr/test.esp32-idf.yaml @@ -5,3 +5,17 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml <<: !include common.yaml + +sensor: + - platform: dsmr + energy_delivered_lux: + name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" + energy_delivered_tariff1: + name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" + +text_sensor: + - platform: dsmr + identification: + name: "DSMR Identification" + p1_version: + name: "DSMR Version. OBIS: 1-3:0.2.8" diff --git a/tests/components/dsmr/test.esp8266-ard.yaml b/tests/components/dsmr/test.esp8266-ard.yaml index 08bcf16fc..d318076ed 100644 --- a/tests/components/dsmr/test.esp8266-ard.yaml +++ b/tests/components/dsmr/test.esp8266-ard.yaml @@ -5,3 +5,10 @@ packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml <<: !include common.yaml + +text_sensor: + - platform: dsmr + identification: + name: "DSMR Identification" + p1_version: + name: "DSMR Version. OBIS: 1-3:0.2.8" diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bf6053c78..8a420f299 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -145,3 +145,19 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey042t81-4.2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem new file mode 100644 index 000000000..bd0920560 --- /dev/null +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -0,0 +1,7 @@ +*** DO NOT USE THIS KEY...EVER *** +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 +AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo +eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== +-----END EC PRIVATE KEY----- +*** DO NOT USE THIS KEY...EVER *** diff --git a/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml new file mode 100644 index 000000000..b32e157da --- /dev/null +++ b/tests/components/esp32/test-signed_ota_v1.esp32-idf.yaml @@ -0,0 +1,10 @@ +esp32: + variant: esp32 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key_v1_ecdsa.pem + signing_scheme: ecdsa_v1 + +<<: !include common.yaml diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index b999f23e1..6b77a4e17 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -20,6 +20,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + watchdog_timeout: 7s wifi: ssid: MySSID diff --git a/tests/components/esp32_ble/common_use_psram.yaml b/tests/components/esp32_ble/common_use_psram.yaml new file mode 100644 index 000000000..cce6cf547 --- /dev/null +++ b/tests/components/esp32_ble/common_use_psram.yaml @@ -0,0 +1,4 @@ +esp32_ble: + use_psram: true + +psram: diff --git a/tests/components/esp32_ble/test.esp32-ard.yaml b/tests/components/esp32_ble/test.esp32-ard.yaml index dade44d14..fa7b9befc 100644 --- a/tests/components/esp32_ble/test.esp32-ard.yaml +++ b/tests/components/esp32_ble/test.esp32-ard.yaml @@ -1 +1,2 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml diff --git a/tests/components/esp32_ble/test.esp32-idf.yaml b/tests/components/esp32_ble/test.esp32-idf.yaml index f8defaf28..0b2a920c6 100644 --- a/tests/components/esp32_ble/test.esp32-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-idf.yaml @@ -1,4 +1,5 @@ <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only diff --git a/tests/components/esp32_ble/test.esp32-p4-idf.yaml b/tests/components/esp32_ble/test.esp32-p4-idf.yaml index 4eeb7c2f1..170220bf4 100644 --- a/tests/components/esp32_ble/test.esp32-p4-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-p4-idf.yaml @@ -2,6 +2,7 @@ packages: ble: !include ../../test_build_components/common/ble/esp32-p4-idf.yaml <<: !include common.yaml +<<: !include common_use_psram.yaml esp32_ble: io_capability: keyboard_only diff --git a/tests/components/ethernet/test.esp32-c3-idf.yaml b/tests/components/ethernet/test.esp32-c3-idf.yaml new file mode 100644 index 000000000..b7b95875c --- /dev/null +++ b/tests/components/ethernet/test.esp32-c3-idf.yaml @@ -0,0 +1,19 @@ +ethernet: + type: W5500 + clk_pin: 6 + mosi_pin: 7 + miso_pin: 2 + cs_pin: 10 + interrupt_pin: 3 + reset_pin: 4 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-lan8720.esp32-idf.yaml b/tests/components/ethernet/test.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720.esp32-idf.yaml rename to tests/components/ethernet/test.esp32-idf.yaml diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 35dca0624..6d5721d3b 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,6 +4,14 @@ esphome: - globals.set: id: glob_int value: "10" + # Set a float global with an integer literal - must emit the correct + # return type so TemplatableFn stores a direct function pointer. + - globals.set: + id: glob_float + value: "102" + - globals.set: + id: glob_float + value: !lambda "return 42;" globals: - id: glob_int diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 13ca5ceba..ef67671c9 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -45,6 +45,11 @@ esphome: args: - response->status_code - body.c_str() + - delay: 1s + - logger.log: + format: "After delay, body still: %s" + args: + - body.c_str() http_request: useragent: esphome/tagreader diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index f84156c9d..6328648fe 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,7 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + rotation: 180 top_layer: - id: lvgl_1 diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp new file mode 100644 index 000000000..e1b4fb2aa --- /dev/null +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -0,0 +1,22 @@ +#include + +#include "esphome/components/modbus/modbus_helpers.h" + +namespace esphome::modbus::helpers { + +TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { + const std::vector data{0x12, 0x34, 0x56}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); +} + +TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { + const std::vector data{0x12, 0x34}; + EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); +} + +} // namespace esphome::modbus::helpers diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/test-empty_password.esp8266-ard.yaml new file mode 100644 index 000000000..e48f67e47 --- /dev/null +++ b/tests/components/ota/test-empty_password.esp8266-ard.yaml @@ -0,0 +1,14 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + id: my_ota + port: 3287 + password: "" + +esphome: + on_boot: + then: + - lambda: id(my_ota).set_auth_password("runtime_password"); diff --git a/tests/components/radio_frequency/common-rx.yaml b/tests/components/radio_frequency/common-rx.yaml new file mode 100644 index 000000000..bcfa1f10c --- /dev/null +++ b/tests/components/radio_frequency/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: rf_receiver + pin: ${rx_pin} + +# Test radio_frequency platform with receiver +radio_frequency: + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: rf_receiver + + # RF receiver (no frequency specified) + - platform: ir_rf_proxy + id: rf_rx + name: "RF Receiver" + remote_receiver_id: rf_receiver diff --git a/tests/components/radio_frequency/common-tx.yaml b/tests/components/radio_frequency/common-tx.yaml new file mode 100644 index 000000000..778dd68d1 --- /dev/null +++ b/tests/components/radio_frequency/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: rf_transmitter + pin: ${tx_pin} + carrier_duty_percent: 100% + +# Test radio_frequency platform with transmitter +radio_frequency: + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: rf_transmitter + + # RF transmitter (no frequency specified) + - platform: ir_rf_proxy + id: rf_tx + name: "RF Transmitter" + remote_transmitter_id: rf_transmitter diff --git a/tests/components/radio_frequency/common.yaml b/tests/components/radio_frequency/common.yaml new file mode 100644 index 000000000..53a0cd379 --- /dev/null +++ b/tests/components/radio_frequency/common.yaml @@ -0,0 +1,7 @@ +network: + +wifi: + ssid: MySSID + password: password1 + +api: diff --git a/tests/components/radio_frequency/test.bk72xx-ard.yaml b/tests/components/radio_frequency/test.bk72xx-ard.yaml new file mode 100644 index 000000000..a0e145f47 --- /dev/null +++ b/tests/components/radio_frequency/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp32-idf.yaml b/tests/components/radio_frequency/test.esp32-idf.yaml new file mode 100644 index 000000000..a0e145f47 --- /dev/null +++ b/tests/components/radio_frequency/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp8266-ard.yaml b/tests/components/radio_frequency/test.esp8266-ard.yaml new file mode 100644 index 000000000..a0e145f47 --- /dev/null +++ b/tests/components/radio_frequency/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.rp2040-ard.yaml b/tests/components/radio_frequency/test.rp2040-ard.yaml new file mode 100644 index 000000000..a0e145f47 --- /dev/null +++ b/tests/components/radio_frequency/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 86b52ca3d..a4d8f951f 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -3,6 +3,22 @@ esphome: then: - rtttl.play: 'siren:d=8,o=5,b=100:d,e,d,e,d,e,d,e' - rtttl.stop + # Test all note features: all notes, denominators (1,2,4,8,16,32), sharp (#), octaves (4-7), dotted (.), note gap (c5,c5), pause (p) + - rtttl.play: 'special:d=4,o=5,b=120:1c4,2d#5,4e6.,8f#7,16g4,32a5,8a#5,4b6,8h5,c5,c5,8p,2c4' + # Different orders of control parameters + - rtttl.play: 'test_odb:o=5,d=8,b=100:c' + - rtttl.play: 'test_bod:b=100,o=5,d=8:c' + - rtttl.play: 'test_bdo:b=100,d=8,o=5:c' + - rtttl.play: 'test_obd:o=5,b=100,d=8:c' + - rtttl.play: 'test_dbo:d=8,b=100,o=5:c' + # Missing parameters (use defaults) + - rtttl.play: 'test_no_d:o=5,b=100:c' + - rtttl.play: 'test_no_o:d=8,b=100:c' + - rtttl.play: 'test_no_b:d=8,o=5:c' + - rtttl.play: 'test_only_d:d=8:c' + - rtttl.play: 'test_only_o:o=5:c' + - rtttl.play: 'test_only_b:b=100:c' + - rtttl.play: 'test_empty::c' output: - platform: ${output_platform} @@ -13,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml new file mode 100644 index 000000000..16f19ad7d --- /dev/null +++ b/tests/components/sendspin/common-action.yaml @@ -0,0 +1,8 @@ +# `sendspin.switch` action enables the controller role, so we use a standalone test +packages: + base: !include common.yaml + +wifi: + on_connect: + then: + - sendspin.switch: diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml new file mode 100644 index 000000000..d3792cf47 --- /dev/null +++ b/tests/components/sendspin/common-media_player.yaml @@ -0,0 +1,5 @@ +<<: !include common.yaml + +media_player: + - platform: sendspin + id: media_player_id diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml new file mode 100644 index 000000000..4a7cd79c6 --- /dev/null +++ b/tests/components/sendspin/common-media_source.yaml @@ -0,0 +1,9 @@ +<<: !include common.yaml + +media_source: + - platform: sendspin + id: media_source_id + buffer_size: 500000 + initial_static_delay: 5ms + static_delay_adjustable: true + fixed_delay: 480us diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml new file mode 100644 index 000000000..6d9745cff --- /dev/null +++ b/tests/components/sendspin/common-sensor.yaml @@ -0,0 +1,15 @@ +<<: !include common.yaml + +sensor: + - platform: sendspin + name: "Sendspin Track Progress" + type: track_progress + - platform: sendspin + name: "Sendspin Track Duration" + type: track_duration + - platform: sendspin + name: "Sendspin Year" + type: year + - platform: sendspin + name: "Sendspin Track" + type: track diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml new file mode 100644 index 000000000..fc6a56a21 --- /dev/null +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -0,0 +1,15 @@ +<<: !include common.yaml + +text_sensor: + - platform: sendspin + name: "Title" + type: title + - platform: sendspin + name: "Artist" + type: artist + - platform: sendspin + name: "Album" + type: album + - platform: sendspin + name: "Album Artist" + type: album_artist diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml new file mode 100644 index 000000000..9d7da7675 --- /dev/null +++ b/tests/components/sendspin/common.yaml @@ -0,0 +1,9 @@ +wifi: + ap: + +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml new file mode 100644 index 000000000..70a7ee1ba --- /dev/null +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-action.yaml diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml new file mode 100644 index 000000000..cbbdb07c7 --- /dev/null +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_player.yaml diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml new file mode 100644 index 000000000..47aeb2257 --- /dev/null +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_source.yaml diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml new file mode 100644 index 000000000..f9127d47b --- /dev/null +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-sensor.yaml diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml new file mode 100644 index 000000000..8998b8896 --- /dev/null +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-text_sensor.yaml diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml new file mode 100644 index 000000000..dade44d14 --- /dev/null +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index c958c0d91..a849e04b3 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -11,9 +11,9 @@ media_player: id: speaker_media_player_id announcement_pipeline: speaker: speaker_id + format: NONE buffer_size: 1000000 volume_increment: 0.02 volume_max: 0.95 volume_min: 0.0 task_stack_in_psram: true - codec_support_enabled: all diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ed398b0ab..ecc65de66 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -171,6 +171,7 @@ sensor: quantile: .9 - round: 1 - round_to_multiple_of: 0.25 + - round_to_significant_digits: 3 - skip_initial: 3 - sliding_window_moving_average: window_size: 15 diff --git a/tests/components/time/is_valid.cpp b/tests/components/time/is_valid.cpp new file mode 100644 index 000000000..9148c0e8d --- /dev/null +++ b/tests/components/time/is_valid.cpp @@ -0,0 +1,72 @@ +// Regression tests for ESPTime::is_valid() optional checks. +// +// The RTC components (ds1307, bm8563, pcf85063, pcf8563, rx8130) read date/time +// fields from hardware but do not populate day_of_year. They call +// recalc_timestamp_utc(false) -- which skips day_of_year -- and then is_valid(). +// These tests ensure the is_valid() overload can skip day_of_year validation so +// RTCs don't log "Invalid RTC time, not syncing to system clock." for valid times. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +// Build an ESPTime that mirrors what the RTC components construct: all fields +// populated from hardware except day_of_year (left zero-initialized). +static ESPTime make_rtc_like_time() { + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_week = 4; // thursday + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + // day_of_year intentionally left at 0 -- RTCs don't compute it. + return t; +} + +TEST(ESPTimeIsValid, DefaultRejectsZeroDayOfYear) { + // Default is_valid() checks day_of_year; zero-init is out of range. + ESPTime t = make_rtc_like_time(); + EXPECT_FALSE(t.is_valid()); +} + +TEST(ESPTimeIsValid, SkipDayOfYearAcceptsRTCLikeTime) { + // RTC code path: skip day_of_year validation. + ESPTime t = make_rtc_like_time(); + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsOutOfRangeFields) { + ESPTime t = make_rtc_like_time(); + t.hour = 25; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipDayOfYearStillRejectsYearBefore2019) { + ESPTime t = make_rtc_like_time(); + t.year = 2000; + EXPECT_FALSE(t.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)); +} + +TEST(ESPTimeIsValid, SkipBothDayChecksAcceptsGPSLikeTime) { + // GPS path (gps_time.cpp) populates neither day_of_week nor day_of_year. + ESPTime t{}; + t.second = 30; + t.minute = 15; + t.hour = 12; + t.day_of_month = 15; + t.month = 4; + t.year = 2026; + EXPECT_TRUE(t.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false)); + EXPECT_FALSE(t.is_valid()); // default still rejects +} + +TEST(ESPTimeIsValid, FullyPopulatedAcceptsWithDefaults) { + ESPTime t = make_rtc_like_time(); + t.day_of_year = 105; + EXPECT_TRUE(t.is_valid()); +} + +} // namespace esphome::testing diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index 35a605484..5a05a58c2 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -38,3 +38,4 @@ event: update: water_heater: infrared: +radio_frequency: diff --git a/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml new file mode 100644 index 000000000..2b440102d --- /dev/null +++ b/tests/components/zephyr_ble_server/test.nrf52-xiao-ble.yaml @@ -0,0 +1,10 @@ +zephyr_ble_server: + on_numeric_comparison_request: + then: + - logger.log: + format: "Compare this passkey with the one on your BLE device: %06d" + args: [passkey] + - ble_server.numeric_comparison_reply: + accept: True + - ble_server.numeric_comparison_reply: + accept: !lambda "return true;" diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 2af35ff14..c689d07f6 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,4 +1,3 @@ ---- binary_sensor: - platform: template name: "Garage Door Open 1" @@ -22,12 +21,6 @@ sensor: lambda: return 12.0; internal: True -zigbee: - wipe_on_boot: true - on_join: - then: - - logger.log: "Joined network" - output: - platform: template id: output_factory @@ -35,9 +28,6 @@ output: write_action: - zigbee.factory_reset -time: - - platform: zigbee - switch: - platform: template name: "Template Switch" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml new file mode 100644 index 000000000..4494b4081 --- /dev/null +++ b/tests/components/zigbee/common_esp32.yaml @@ -0,0 +1,14 @@ +binary_sensor: + - platform: template + name: "Garage Door Open 10" + report: "enable" + - platform: template + name: "Garage Door Open 11" + report: "coordinator" + - platform: template + name: "Garage Door Open 12" + report: "force" + +zigbee: + model: zigbee_test + router: true diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml new file mode 100644 index 000000000..bc39b371f --- /dev/null +++ b/tests/components/zigbee/common_nrf52.yaml @@ -0,0 +1,12 @@ +packages: + - !include common.yaml + +zigbee: + model: zigbee_test + wipe_on_boot: true + on_join: + then: + - logger.log: "Joined network" + +time: + - platform: zigbee diff --git a/tests/components/zigbee/test.esp32-c6-idf.yaml b/tests/components/zigbee/test.esp32-c6-idf.yaml new file mode 100644 index 000000000..8e4796a07 --- /dev/null +++ b/tests/components/zigbee/test.esp32-c6-idf.yaml @@ -0,0 +1 @@ +<<: !include common_esp32.yaml diff --git a/tests/components/zigbee/test.nrf52-adafruit.yaml b/tests/components/zigbee/test.nrf52-adafruit.yaml index dade44d14..bf3cb9cdd 100644 --- a/tests/components/zigbee/test.nrf52-adafruit.yaml +++ b/tests/components/zigbee/test.nrf52-adafruit.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index dade44d14..bf3cb9cdd 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index 254f370ca..acfbc9e99 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -1,6 +1,7 @@ -<<: !include common.yaml +<<: !include common_nrf52.yaml zigbee: wipe_on_boot: once power_source: battery ieee802154_vendor_oui: 0x231 + sleepy: true diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff38451..1a62cfda9 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() diff --git a/tests/integration/fixtures/addressable_light_transition.yaml b/tests/integration/fixtures/addressable_light_transition.yaml new file mode 100644 index 000000000..7b847dd80 --- /dev/null +++ b/tests/integration/fixtures/addressable_light_transition.yaml @@ -0,0 +1,29 @@ +esphome: + name: addr-light-transition +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +light: + - platform: mock_addressable_light + output_id: strip_output + id: strip + name: "Test Strip" + num_leds: 4 + gamma_correct: 2.8 + default_transition_length: 0s + +sensor: + - platform: template + name: "led0_red_raw" + id: led0_red_raw + update_interval: 10ms + accuracy_decimals: 0 + lambda: |- + return (float) id(strip_output).get_raw_red(0); diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py new file mode 100644 index 000000000..e8cfff8e1 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/tests"] diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/light.py b/tests/integration/fixtures/external_components/mock_addressable_light/light.py new file mode 100644 index 000000000..293d2854f --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/light.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID +from esphome.types import ConfigType + +mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light") +MockAddressableLight = mock_addressable_light_ns.class_( + "MockAddressableLight", light.AddressableLight +) + +CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight), + cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS]) + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h new file mode 100644 index 000000000..c6b0d1060 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/core/component.h" + +namespace esphome::mock_addressable_light { + +// In-memory addressable light for host-mode integration tests. Exposes the raw +// per-LED byte buffer (post-gamma-correction, as the hardware would see it) +// so tests can observe transition behavior without real hardware. +class MockAddressableLight : public light::AddressableLight { + public: + explicit MockAddressableLight(uint16_t num_leds) + : num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {} + + void setup() override {} + void write_state(light::LightState *state) override {} + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override { + for (uint16_t i = 0; i < this->num_leds_; i++) + this->effect_data_[i] = 0; + } + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + + // Accessors for tests: return the raw stored byte (post gamma correction), + // which is what actual LED hardware would receive. + uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; } + uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; } + uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; } + uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; } + + protected: + light::ESPColorView get_view_internal(int32_t index) const override { + size_t pos = index * 4; + return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2, + this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_}; + } + + uint16_t num_leds_; + std::unique_ptr buf_; + std::unique_ptr effect_data_; +}; + +} // namespace esphome::mock_addressable_light diff --git a/tests/integration/fixtures/external_components/wake_test_component/__init__.py b/tests/integration/fixtures/external_components/wake_test_component/__init__.py new file mode 100644 index 000000000..ce2416788 --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +wake_test_component_ns = cg.esphome_ns.namespace("wake_test_component") +WakeTestComponent = wake_test_component_ns.class_("WakeTestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(WakeTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp new file mode 100644 index 000000000..b58f1c9ad --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -0,0 +1,19 @@ +#include "wake_test_component.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::wake_test_component { + +static const char *const TAG = "wake_test_component"; + +void WakeTestComponent::start_async_wake() { + ESP_LOGI(TAG, "Spawning async wake thread (50ms delay)"); + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + App.wake_loop_threadsafe(); + }).detach(); +} + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h new file mode 100644 index 000000000..c8e4e0a89 --- /dev/null +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include + +namespace esphome::wake_test_component { + +class WakeTestComponent : public Component { + public: + void setup() override {} + void loop() override { this->loop_count_.fetch_add(1, std::memory_order_relaxed); } + + int get_loop_count() const { return this->loop_count_.load(std::memory_order_relaxed); } + + // Spawn a detached thread that sleeps briefly then calls + // App.wake_loop_threadsafe(). Used by the integration test to verify a + // cross-thread wake forces a component-phase iteration even when + // loop_interval_ has been raised high enough to gate it off otherwise. + void start_async_wake(); + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + std::atomic loop_count_{0}; +}; + +} // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/loop_interval_decoupling.yaml b/tests/integration/fixtures/loop_interval_decoupling.yaml new file mode 100644 index 000000000..5aedd9aba --- /dev/null +++ b/tests/integration/fixtures/loop_interval_decoupling.yaml @@ -0,0 +1,60 @@ +esphome: + name: loop-interval-decouple + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 500ms. With the decoupling fix the + // component phase should run ~twice per second while the 50ms + // scheduler interval below still fires at its requested cadence. + App.set_loop_interval(500); + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + id(sched_at_start) = id(sched_count); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d", + id(loop_at_start), id(sched_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + int sched_delta = id(sched_count) - id(sched_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d", + loop_delta, sched_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: sched_count + type: int + initial_value: "0" + - id: loop_at_start + type: int + initial_value: "0" + - id: sched_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval — with the decoupling fix this should fire at + # its requested 50ms cadence regardless of loop_interval_. + - interval: 50ms + then: + - lambda: |- + id(sched_count) += 1; diff --git a/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml new file mode 100644 index 000000000..fec83865b --- /dev/null +++ b/tests/integration/fixtures/loop_interval_default_not_pulled_forward.yaml @@ -0,0 +1,51 @@ +esphome: + name: loop-default-not-pulled + on_boot: + priority: -100 + then: + # Leave loop_interval_ at its default (16 ms → ~62 Hz). Do NOT call + # set_loop_interval here. The fast scheduler interval below used to + # pull the component phase forward to ~128 Hz via the old + # std::max(next_schedule, delay_time / 2) floor. + # Start measurement after 1s so boot transients settle. + - delay: 1000ms + - lambda: |- + id(loop_at_start) = id(loop_counter)->get_loop_count(); + ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d", id(loop_at_start)); + # Observe for 2s. + - delay: 2000ms + - lambda: |- + int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start); + ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d", loop_delta); + +host: +api: +logger: + level: INFO + logs: + loop_test_component: WARN # Silence per-loop log spam + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +globals: + - id: loop_at_start + type: int + initial_value: "0" + +loop_test_component: + components: + - id: loop_counter + name: loop_counter + +interval: + # Fast scheduler interval (well under loop_interval_/2 = 8ms). In the + # pre-decoupling code this would have pulled the component phase forward + # to ~128 Hz. After the decoupling fix the component phase stays at + # ~62 Hz regardless. + - interval: 5ms + then: + - lambda: |- + // No-op; the presence of a due scheduler item is what matters. diff --git a/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml new file mode 100644 index 000000000..13be55d61 --- /dev/null +++ b/tests/integration/fixtures/scheduler_interval_zero_coerced.yaml @@ -0,0 +1,27 @@ +esphome: + name: sched-interval-zero + +host: +api: +logger: + level: DEBUG + +globals: + - id: fire_count + type: int + initial_value: "0" + +interval: + # Deliberately configure 0ms — this path goes through the C++ + # Scheduler::set_timer_common_ coercion (not the Python cv.update_interval + # path, since interval: doesn't call cv.update_interval — it's an intervals + # component schema, not a PollingComponent's update_interval). + # Expected: scheduler coerces to 1ms at registration, emits ESP_LOGE, + # fires at ~1kHz instead of spinning. + - interval: 0ms + then: + - lambda: |- + id(fire_count) += 1; + if (id(fire_count) == 50) { + ESP_LOGI("test", "ZERO_INTERVAL_50_FIRES_REACHED"); + } diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml new file mode 100644 index 000000000..cb118dcc8 --- /dev/null +++ b/tests/integration/fixtures/status_flags.yaml @@ -0,0 +1,141 @@ +esphome: + name: status-flags-test + +host: +api: + actions: + # Warning flag services for sensor_a + - action: set_warning_a + then: + - lambda: "id(sensor_a)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_a + then: + - lambda: "id(sensor_a)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Warning flag services for sensor_b + - action: set_warning_b + then: + - lambda: "id(sensor_b)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_b + then: + - lambda: "id(sensor_b)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_a + - action: set_error_a + then: + - lambda: "id(sensor_a)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_a + then: + - lambda: "id(sensor_a)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_b + - action: set_error_b + then: + - lambda: "id(sensor_b)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_b + then: + - lambda: "id(sensor_b)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Snapshot of the status_led_light's output state for observation. + - action: snapshot_led + then: + - component.update: status_led_writes + - component.update: status_led_last_state + +logger: + +# Tracks each write to the fake status_led output. +globals: + - id: status_led_write_count + type: uint32_t + restore_value: no + initial_value: "0" + - id: status_led_last_write + type: bool + restore_value: no + initial_value: "false" + +# Fake binary output — status_led_light writes to this instead of a pin. +# Every write bumps a counter and records the last value, both of which +# are exposed below so the test can verify status_led_light's loop is +# actually reading App.get_app_state() and responding. +output: + - platform: template + id: fake_status_led + type: binary + write_action: + - globals.set: + id: status_led_write_count + value: !lambda "return id(status_led_write_count) + 1;" + - globals.set: + id: status_led_last_write + value: !lambda "return state;" + +# Actual status_led_light component under test. +light: + - platform: status_led + name: Status LED + id: status_led_light_id + output: fake_status_led + +sensor: + # Two components that the test will toggle warning/error flags on. + - platform: template + name: Sensor A + id: sensor_a + update_interval: 24h + lambda: return 1.0; + - platform: template + name: Sensor B + id: sensor_b + update_interval: 24h + lambda: return 2.0; + + # Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits + # as 0.0 / 1.0. force_update ensures every manual component.update + # publishes even if the value is unchanged. + - platform: template + name: App Warning Bit + id: app_warning_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0; + - platform: template + name: App Error Bit + id: app_error_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0; + + # Observables for the fake status_led output. + - platform: template + name: Status LED Writes + id: status_led_writes + update_interval: 24h + force_update: true + lambda: return id(status_led_write_count); + - platform: template + name: Status LED Last State + id: status_led_last_state + update_interval: 24h + force_update: true + lambda: |- + return id(status_led_last_write) ? 1.0 : 0.0; diff --git a/tests/integration/fixtures/wake_loop_forces_phase_b.yaml b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml new file mode 100644 index 000000000..d97ab8514 --- /dev/null +++ b/tests/integration/fixtures/wake_loop_forces_phase_b.yaml @@ -0,0 +1,52 @@ +esphome: + name: wake-loop-phase-b + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without the wake-request flag, + // a wake_loop_threadsafe() call would only run Phase A (scheduler) + // and leave the component phase gated for ~2s. + App.set_loop_interval(2000); + # Let boot transients settle. + - delay: 1000ms + - lambda: |- + // Snapshot the loop counter, then ask the component to spawn a + // background thread that calls App.wake_loop_threadsafe() after + // ~50ms. With the fix, that wake forces Phase B on the next tick + // and the counter increments well within the 500ms observation + // window below. + id(count_at_start) = id(wake_counter)->get_loop_count(); + id(start_time) = millis(); + id(wake_counter)->start_async_wake(); + ESP_LOGI("test", "WAKE_STARTED count=%d", id(count_at_start)); + # Observation window must be much shorter than loop_interval_ (2000ms) + # so a "false pass" isn't possible by simply waiting out the gate. + - delay: 500ms + - lambda: |- + int count_now = id(wake_counter)->get_loop_count(); + int delta = count_now - id(count_at_start); + uint32_t elapsed = millis() - id(start_time); + ESP_LOGI("test", "WAKE_RESULT delta=%d elapsed=%u", delta, elapsed); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +globals: + - id: count_at_start + type: int + initial_value: "0" + - id: start_time + type: uint32_t + initial_value: "0" + +wake_test_component: + id: wake_counter diff --git a/tests/integration/test_addressable_light_transition.py b/tests/integration/test_addressable_light_transition.py new file mode 100644 index 000000000..37fecde59 --- /dev/null +++ b/tests/integration/test_addressable_light_transition.py @@ -0,0 +1,119 @@ +"""Integration test for addressable light transitions with gamma correction. + +Regression test for a bug where a long turn-on transition on an addressable +light with gamma correction (e.g. gamma_correct: 2.8) produced no visible +output for ~90% of the transition duration, then jumped to the target in the +final ~10%. Root cause: the transition algorithm read each LED's current value +back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value +below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until +progress was high enough for a single step to produce a large-enough pre-gamma +value to clear the gamma threshold. + +The fix interpolates against a cached start color when all LEDs started at the +same value (the common case for plain turn_on/turn_off), avoiding the round-trip. + +This test uses a host-only mock addressable light that exposes the raw stored +byte of each LED, so we can observe the transition directly. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import LightInfo, SensorInfo, SensorState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_addressable_light_transition( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With gamma 2.8, the stored raw byte must rise visibly well before the end.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + light = require_entity(entities, "test_strip", LightInfo) + sensor = require_entity(entities, "led0_red_raw", SensorInfo) + + # Track the raw-byte sensor. It polls every 10ms in the fixture, and + # ESPHome sensors publish on every change, so we collect a time series. + # Samples are stored as absolute (loop_time, value); we rebase to the + # command-issue time after the run so pre-command samples are strictly + # negative and reliably excluded. + loop = asyncio.get_running_loop() + samples: list[tuple[float, float]] = [] + + def on_state(state: object) -> None: + if not isinstance(state, SensorState) or state.key != sensor.key: + return + samples.append((loop.time(), state.state)) + + # InitialStateHelper swallows the first state ESPHome sends per entity + # on subscribe, so on_state only sees real post-subscribe updates. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Start transition: off -> full white over 1 second. This is the + # scenario from the bug report, compressed in time. + transition_s = 1.0 + command_time = loop.time() + client.light_command( + key=light.key, + state=True, + rgb=(1.0, 1.0, 1.0), + brightness=1.0, + transition_length=transition_s, + ) + + # Let the full transition run, plus margin for the final sample. + await asyncio.sleep(transition_s + 0.2) + + # Rebase to command-issue time. Pre-command samples have t < 0 and are + # excluded; everything else is in seconds since the command was issued. + post_command = [ + (t - command_time, v) for (t, v) in samples if t >= command_time + ] + assert post_command, "no sensor samples received after command was issued" + + # Assertion 1: the transition is not stalled. With the bug, the raw + # byte stays at 0 until ~90% of the transition duration. With the fix, + # it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76 + # clears the gamma threshold at progress ~0.30). Require the first + # nonzero sample to land well before 50% of the transition duration, + # measured from the command-issue time. The 50% bound (rather than + # 70%) leaves headroom for assertion 2's mid-window check. + first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None) + assert first_nonzero is not None, ( + "raw byte never rose above 0 during the transition — the fade stalled" + ) + assert first_nonzero[0] < transition_s * 0.5, ( + f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s " + f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling" + ) + + # Assertion 2: by mid-late transition, the raw byte should have reached + # a substantial fraction of its final value. Bound the window to + # [50%, 90%] of the transition so the post-transition settled value + # (which always reaches 255) can't satisfy this assertion — that would + # let "stays at 0 then jumps at 99%" regressions slip through. + mid_window = [ + v + for (t, v) in post_command + if transition_s * 0.5 <= t <= transition_s * 0.9 + ] + assert mid_window, "no samples captured in mid-transition window" + assert max(mid_window) >= 100, ( + f"raw byte peaked at only {max(mid_window)} between 50%–90% of " + "transition (expected >= 100 for white target at gamma 2.8)" + ) + + # Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255. + final_samples = [v for (_, v) in post_command[-5:]] + assert max(final_samples) >= 250, ( + f"final raw byte was {max(final_samples)}, expected >= 250" + ) diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py new file mode 100644 index 000000000..6c34aed45 --- /dev/null +++ b/tests/integration/test_loop_interval_decoupling.py @@ -0,0 +1,75 @@ +"""Test that loop_interval_ no longer clamps scheduler cadence. + +Regression test for the decoupling of Application::loop() component-phase +cadence from scheduler wake timing. + +Setup: +- App.set_loop_interval(500) — raised for power-savings style cadence +- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_ +- Component loop (LoopTestComponent) — should run at 500ms cadence + +Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)` +floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times +per 2s (vs the ~40 expected). After the fix the scheduler fires close to its +requested cadence while the component phase stays gated at loop_interval_. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_decoupling( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Raised loop_interval_ must not clamp scheduler item cadence.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-interval-decouple" + + try: + loop_delta, sched_delta = await asyncio.wait_for( + measurement_done, timeout=10.0 + ) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ = 500ms. + # Component phase should fire ~4 times in 2s. The upper bound must be + # less than 8: the pre-decoupling behavior clamped to ~250ms cadence + # giving ~8 loops/2s, so allowing 8 would let the old behavior pass. + # Lower bound 3 (not 2) keeps the test honest: a >30% slowdown from + # the ~4 nominal is not normal CI jitter and should fail. + assert 3 <= loop_delta <= 6, ( + f"Component loop should fire ~4 times in 2s at loop_interval=500ms, " + f"got {loop_delta}" + ) + + # Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling + # fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped + # behavior with comfortable jitter headroom for slow CI hosts. + assert sched_delta >= 20, ( + f"50ms scheduler interval should fire ~40 times in 2s but only " + f"fired {sched_delta}. This indicates loop_interval_ is still " + f"clamping scheduler cadence." + ) diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py new file mode 100644 index 000000000..17a707043 --- /dev/null +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -0,0 +1,67 @@ +"""Test that a fast scheduler item does not pull the component phase forward. + +Regression test for the original ~128 Hz → ~62 Hz bug fixed by decoupling +Application::loop() component-phase cadence from scheduler wake timing. + +Setup: +- loop_interval_ left at its default (16 ms → ~62 Hz component phase). +- Scheduler interval at 5 ms (well under the old loop_interval_/2 = 8 ms floor). + +Before the decoupling fix the ``std::max(next_schedule, delay_time / 2)`` floor +clamped the sleep to ~8 ms whenever any scheduler item was due sooner than +loop_interval_/2. That pulled the component phase forward to ~128 Hz — twice +what the documented ~62 Hz default promised. After the fix the component +phase stays at ~62 Hz regardless of scheduler activity. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_interval_default_not_pulled_forward( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Fast scheduler item must not pull component phase past default ~62 Hz.""" + loop = asyncio.get_running_loop() + measurement_done: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line) + if match and not measurement_done.done(): + measurement_done.set_result(int(match.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-default-not-pulled" + + try: + loop_delta = await asyncio.wait_for(measurement_done, timeout=10.0) + except TimeoutError: + pytest.fail("MEASUREMENT_DONE marker never appeared") + + # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → + # ~125 component-phase iterations expected. + # Pre-fix behavior: the 5 ms scheduler interval tripped the old + # delay_time/2 = 8 ms floor, pulling the phase to ~128 Hz → ~256. + # Upper bound 180 is comfortably below the ~256 pre-fix rate but + # above the ~125 nominal with CI jitter. + # Lower bound 80 covers very slow CI hosts without permitting a + # complete regression. + assert 80 <= loop_delta <= 180, ( + f"Component loop at default loop_interval_ should fire ~125 times " + f"in 2s (≈62 Hz × 2s); got {loop_delta}. Values >200 indicate the " + f"scheduler is again pulling the component phase forward." + ) diff --git a/tests/integration/test_runtime_stats.py b/tests/integration/test_runtime_stats.py index 9e93035d8..bd7f36341 100644 --- a/tests/integration/test_runtime_stats.py +++ b/tests/integration/test_runtime_stats.py @@ -26,6 +26,7 @@ async def test_runtime_stats( # Track component stats component_stats_found = set() + main_loop_lines: list[dict[str, str]] = [] # Patterns to match - need to handle ANSI color codes and timestamps # The log format is: [HH:MM:SS][color codes][I][tag]: message @@ -34,6 +35,14 @@ async def test_runtime_stats( component_pattern = re.compile( r"^\[[^\]]+\].*?\s+([\w.]+):\s+count=(\d+),\s+avg=([\d.]+)ms" ) + # Main loop overhead line emitted by runtime_stats + main_loop_pattern = re.compile( + r"main_loop:\s+iters=(?P\d+),\s+" + r"active_avg=(?P[\d.]+)ms,\s+" + r"active_max=(?P[\d.]+)ms,\s+" + r"active_total=(?P[\d.]+)ms,\s+" + r"overhead_total=(?P[\d.]+)ms" + ) def check_output(line: str) -> None: """Check log output for runtime stats messages.""" @@ -54,6 +63,11 @@ async def test_runtime_stats( component_name = match.group(1) component_stats_found.add(component_name) + # Check for main_loop overhead line + ml_match = main_loop_pattern.search(line) + if ml_match: + main_loop_lines.append(ml_match.groupdict()) + async with ( run_compiled(yaml_config, line_callback=check_output), api_client_connected() as client, @@ -86,3 +100,22 @@ async def test_runtime_stats( assert "template.switch" in component_stats_found, ( f"Expected template.switch stats, found: {component_stats_found}" ) + + # Verify the main_loop overhead line is emitted (at least once for + # the period section and once for the total section, per log cycle). + assert len(main_loop_lines) >= 2, ( + f"Expected at least 2 main_loop lines, got {len(main_loop_lines)}" + ) + for fields in main_loop_lines: + assert int(fields["iters"]) > 0, f"iters should be > 0: {fields}" + assert float(fields["active_total"]) > 0.0, ( + f"active_total should be > 0: {fields}" + ) + assert float(fields["active_avg"]) >= 0.0, ( + f"active_avg should be >= 0: {fields}" + ) + # overhead_total is derived and may be 0 if components dominate, + # but the field must still be present and parseable as a float. + assert float(fields["overhead_total"]) >= 0.0, ( + f"overhead_total should be >= 0: {fields}" + ) diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py new file mode 100644 index 000000000..f71c0f728 --- /dev/null +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -0,0 +1,67 @@ +"""Test that Scheduler::set_timer_common_ coerces interval=0 to 1ms. + +Regression test for the scheduler busy-loop when interval=0 was passed +literally. Without the coercion, Scheduler::call() would spin forever +because the item's next_execution == now_64 after re-scheduling, failing +the loop's `> now_64` break condition. The device would fail to yield +back to the main loop and trigger a WDT reset. + +With the coercion, interval=0 becomes interval=1 and the scheduler +fires at ~1kHz (bounded by the loop), the main loop continues to run, +and the device stays responsive to API calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_interval_zero_coerced( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """interval=0ms must be coerced to 1ms and not starve the main loop.""" + loop = asyncio.get_running_loop() + reached_50: asyncio.Future[None] = loop.create_future() + coerce_warning: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): + reached_50.set_result(None) + if "would spin main loop" in line and not coerce_warning.done(): + coerce_warning.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # The API-client connection itself is evidence that the main loop + # is not starved — if set_interval(0) were spinning we could not + # get here at all. + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "sched-interval-zero" + + # Coerce warning must fire at registration + try: + await asyncio.wait_for(coerce_warning, timeout=5.0) + except TimeoutError: + pytest.fail("Expected coerce warning 'would spin main loop' not seen") + + # The coerced 1ms interval should fire 50 times quickly — this + # confirms the callback actually runs (not just registered) and the + # scheduler yields back to the main loop each time. + try: + await asyncio.wait_for(reached_50, timeout=5.0) + except TimeoutError: + pytest.fail( + "Coerced interval=0→1ms did not reach 50 fires within 5s, " + "which would indicate either the coercion failed or the " + "main loop is still being starved." + ) diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py new file mode 100644 index 000000000..ffbc7c7f6 --- /dev/null +++ b/tests/integration/test_status_flags.py @@ -0,0 +1,209 @@ +"""Integration tests for Component::status_set/clear_warning/error propagation. + +Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual +components correctly updates the app-wide bits on Application::app_state_, +AND that the status_led_light component actually responds to those bits +by writing to its output (the full chain from component.status_set_warning +→ App.app_state_ → status_led_light.loop() reading get_app_state()). + +Exercises the multi-component OR semantics (the app bit stays set while +any component still has the flag, and only clears when the last component +clears its bit), the independence of warning and error, and the actual +status_led_light read of the bits via a fake template output that counts +writes. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Time to let the host-mode main loop run so status_led_light.loop() can +# execute enough iterations to produce measurable write-count changes on +# the fake template output. 300 ms is well above the minimum needed. +STATUS_LED_SETTLE_S = 0.3 + + +@pytest.mark.asyncio +async def test_status_flags( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + # Map every custom API service by name for the test to execute. + svc = {s.name: s for s in services} + for name in ( + "set_warning_a", + "clear_warning_a", + "set_warning_b", + "clear_warning_b", + "set_error_a", + "clear_error_a", + "set_error_b", + "clear_error_b", + "snapshot_led", + ): + assert name in svc, f"service {name} not registered" + + # Track every sensor we care about. SensorTracker gives us + # expect(value) / expect_any() futures that resolve when a + # matching state arrives; much simpler than manual bookkeeping. + tracker = SensorTracker( + [ + "app_warning_bit", + "app_error_bit", + "status_led_writes", + "status_led_last_state", + ] + ) + tracker.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys())) + ) + + # Swallow initial state broadcasts so the test only reacts to + # state changes triggered by our service calls. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + async def call(name: str) -> None: + await client.execute_service(svc[name], {}) + + async def call_and_expect_bits( + service_name: str, *, warning: float, error: float + ) -> None: + """Execute a service and wait for both app bit sensors to match. + + Each bit-toggling service calls component.update on both + app_warning_bit and app_error_bit, so both sensors publish. + """ + futures = tracker.expect_all( + {"app_warning_bit": warning, "app_error_bit": error} + ) + await call(service_name) + await tracker.await_all(futures) + + async def snapshot_led_writes() -> int: + """Trigger a publish of the fake status_led output counter and return it.""" + future = tracker.expect_any("status_led_writes") + await call("snapshot_led") + await tracker.await_change(future, "status_led_writes") + return int(tracker.sensor_states["status_led_writes"][-1]) + + # ---- Baseline: everything clean ---- + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ + # ================================================================ + + # Single component set/clear + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0) + + # Opposite clear order + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 2 — STATUS_LED_ERROR propagation (same scenarios) + # ================================================================ + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("set_error_b", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 3 — warning and error are independent + # ================================================================ + + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_error_b", warning=1.0, error=1.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 4 — status_led_light actually reads App.app_state_ + # ================================================================ + # The fake status_led_light output increments status_led_write_count + # on every write. status_led_light::loop() writes its output on every + # iteration while an error/warning bit is set, so after holding a + # warning for ~300 ms we should see the counter move significantly. + # This is the end-to-end proof that the bits we set above actually + # reach status_led_light and drive its behavior. + + count_before_warning = await snapshot_led_writes() + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + # Let status_led_light's loop run long enough to toggle the pin + # several times (it reads get_app_state() every main loop iteration). + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_warning = await snapshot_led_writes() + assert count_after_warning > count_before_warning, ( + "status_led_light did not respond to STATUS_LED_WARNING being set: " + f"write count stayed at {count_before_warning} → {count_after_warning}. " + "The full chain Component::status_set_warning → App.app_state_ → " + "status_led_light::loop reading get_app_state() is broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Same check for ERROR + count_before_error = await snapshot_led_writes() + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_error = await snapshot_led_writes() + assert count_after_error > count_before_error, ( + "status_led_light did not respond to STATUS_LED_ERROR being set: " + f"write count stayed at {count_before_error} → {count_after_error}. " + ) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + # ---- Set → clear → re-set round-trip ---- + # After clearing, status_led_light stops writing (steady state). + # Re-setting the flag must make it resume. This guards against a + # future idle optimization (e.g. #15642) where status_led disables + # its own loop when idle: if the re-enable path were broken, the + # second set would not produce writes. + # + # Snapshot AFTER the clear to avoid counting writes that were still + # in-flight from the error-set phase. + count_after_clear = await snapshot_led_writes() + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_idle = await snapshot_led_writes() + assert count_after_idle - count_after_clear <= 5, ( + "status_led_light kept writing after warning/error was cleared: " + f"count grew from {count_after_clear} to {count_after_idle}. " + "Expected it to stop writing once all status bits were clear." + ) + # Re-set warning — writes must resume. + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_reset = await snapshot_led_writes() + assert count_after_reset > count_after_idle + 5, ( + "status_led_light did not resume writing after re-setting " + f"STATUS_LED_WARNING: count went from {count_after_idle} to " + f"{count_after_reset}. If an idle optimization disabled the " + "loop, the re-enable path may be broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index 12aa3f839..ea2ec38b2 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -325,9 +325,13 @@ async def test_uart_mock_ld2412_engineering_truncated( ], ) - # Signal when we see Phase 3 recovery values (gate_0_move=50) + # Signal when we see ALL Phase 3 recovery values to avoid race where some + # arrive after the waiter fires but before we index into the lists recovery_received = collector.add_waiter( - lambda: pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + lambda: ( + pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + and pytest.approx(42.0) in collector.sensor_states["light"] + ) ) async with ( diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py new file mode 100644 index 000000000..5f05f07dd --- /dev/null +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -0,0 +1,76 @@ +"""Test that wake_loop_threadsafe() forces a component-phase iteration. + +Regression test for the wake-request flag added to Application::loop()'s +Phase A / Phase B gate. Background producers (MQTT RX, USB RX, BLE event, +etc.) call App.wake_loop_threadsafe() expecting their component's loop() +to drain queued work; if the component phase stays gated by loop_interval_, +the work waits up to loop_interval_ ms instead of running on the next tick. + +Setup: +- App.set_loop_interval(2000) — a wide gate that would clearly mask the bug. +- A test component spawns a detached std::thread that sleeps 50 ms and then + calls App.wake_loop_threadsafe() from a non-main thread. +- The on_boot block snapshots the component's loop counter before/after a + 500 ms observation window. + +Without the fix, delta=0 (the gate holds Phase B for ~2 s). +With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wake_loop_forces_phase_b( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A wake_loop_threadsafe() call from a background thread must trigger the + component phase within the next tick, even when loop_interval_ is raised + well above the observation window.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + result: asyncio.Future[tuple[int, int]] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line) + if match and not result.done(): + result.set_result((int(match.group(1)), int(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "wake-loop-phase-b" + + try: + delta, elapsed = await asyncio.wait_for(result, timeout=15.0) + except TimeoutError: + pytest.fail("WAKE_RESULT marker never appeared") + + # Without the fix, delta would be 0 — loop_interval_=2000ms held + # Phase B off for the full 500ms observation window. With the fix + # the wake from the background thread (~50ms after start) forces + # Phase B on the next tick, so the counter increments at least once. + assert delta >= 1, ( + f"wake_loop_threadsafe() from a background thread should force " + f"Phase B within the next tick; observed delta={delta} after " + f"{elapsed}ms with loop_interval_=2000ms" + ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b..2c726734f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_host_platform_change() -> None: + """Test benchmarks trigger on host platform changes. + + Benchmarks build and run on the host platform, so changes to its + millis()/micros()/etc. implementations affect every benchmark. + """ + for host_file in [ + "esphome/components/host/core.cpp", + "esphome/components/host/__init__.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[host_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {host_file}" + ) + + def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 948aabaa6..db0d2908f 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -1468,3 +1468,159 @@ def test_cache_miss_corrupted_json( result = helpers.create_components_graph() # Should handle corruption gracefully and rebuild assert result == {} + + +# --------------------------------------------------------------------------- +# parse_component_metadata / split_conflicting_groups +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_components(tmp_path: Path) -> Path: + """Create a fake esphome/components/ tree and return the repo root. + + Component layout (tested against split_conflicting_groups): + + alpha -- CONFLICTS_WITH=["beta"] + beta -- CONFLICTS_WITH=["alpha"] + beta_variant -- AUTO_LOAD=["beta"] + gamma -- (no metadata) + one_sided -- CONFLICTS_WITH=["plain"] (plain does not reject back) + plain -- no CONFLICTS_WITH + callable_auto -- AUTO_LOAD is a function (not a list literal) -> ignored + broken -- __init__.py has a SyntaxError + """ + components = tmp_path / "esphome" / "components" + components.mkdir(parents=True) + + def write(name: str, body: str) -> None: + (components / name).mkdir() + (components / name / "__init__.py").write_text(body) + + write("alpha", 'CONFLICTS_WITH = ["beta"]\n') + write("beta", 'CONFLICTS_WITH = ["alpha"]\n') + write("beta_variant", 'AUTO_LOAD = ["beta"]\n') + write("gamma", "") + write("one_sided", 'CONFLICTS_WITH = ["plain"]\n') + write("plain", "") + write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") + write("broken", "this is not valid python !!!") + helpers.parse_component_metadata.cache_clear() + return tmp_path + + +def test_parse_component_metadata_list_literals( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + meta = helpers.parse_component_metadata("alpha") + assert meta.conflicts_with == frozenset({"beta"}) + assert meta.auto_load == frozenset() + + variant = helpers.parse_component_metadata("beta_variant") + assert variant.auto_load == frozenset({"beta"}) + assert variant.conflicts_with == frozenset() + + +def test_parse_component_metadata_missing_empty_and_callable( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + # Unknown component -> empty metadata, not an error. + unknown = helpers.parse_component_metadata("does_not_exist") + assert unknown == helpers.ComponentMetadata() + + # Empty __init__.py -> empty metadata. + assert helpers.parse_component_metadata("gamma") == helpers.ComponentMetadata() + + # Callable AUTO_LOAD cannot be statically evaluated -> empty. + callable_meta = helpers.parse_component_metadata("callable_auto") + assert callable_meta.auto_load == frozenset() + + # SyntaxError in __init__.py must not raise. + assert helpers.parse_component_metadata("broken") == helpers.ComponentMetadata() + + +def test_split_conflicting_groups_splits_direct_conflict( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta", "gamma"]} + ) + # alpha and beta must end up in different buckets; gamma has no conflicts. + buckets = list(result.values()) + assert any("alpha" in b for b in buckets) + assert any("beta" in b for b in buckets) + for bucket in buckets: + assert not ({"alpha", "beta"} <= set(bucket)) + # Gamma sticks with whichever bucket it landed in first (alpha's). + all_members = {c for b in buckets for c in b} + assert all_members == {"alpha", "beta", "gamma"} + + +def test_split_conflicting_groups_propagates_through_auto_load( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A component that AUTO_LOADs a conflicting one must also be split out.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["alpha", "beta_variant"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "beta_variant"} <= set(bucket)) + assert sum(len(b) for b in buckets) == 2 + + +def test_split_conflicting_groups_symmetric_one_sided_declaration( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """If only one side declares CONFLICTS_WITH, the pair must still be split.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups( + {("esp32", "i2c"): ["one_sided", "plain"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"one_sided", "plain"} <= set(bucket)) + + +def test_split_conflicting_groups_preserves_non_conflicting_group( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + original = {("esp32", "i2c"): ["alpha", "gamma", "plain"]} + result = helpers.split_conflicting_groups(original) + # All three are mutually compatible -- the group must not be split. + assert result == original + + +def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """When a group is split, the first bucket keeps the original signature key.""" + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + helpers.parse_component_metadata.cache_clear() + + result = helpers.split_conflicting_groups({("esp32", "i2c"): ["alpha", "beta"]}) + keys = set(result.keys()) + assert ("esp32", "i2c") in keys + # One additional bucket with a disambiguated signature. + extra = keys - {("esp32", "i2c")} + assert len(extra) == 1 + platform, signature = next(iter(extra)) + assert platform == "esp32" + assert signature.startswith("i2c__conflict") diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 48988fb03..6325bfbe7 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,11 @@ """Tests for time component cron expression parsing.""" -from esphome.components.time import _parse_cron_part +import errno +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: @@ -78,3 +83,63 @@ def test_range() -> None: def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} + + +def _mock_resources_with_error(error: Exception) -> MagicMock: + """Return a mock of importlib.resources.files where read_bytes raises error.""" + leaf = MagicMock() + leaf.read_bytes.side_effect = error + package = MagicMock() + package.__truediv__.return_value = leaf + return MagicMock(return_value=package) + + +def test_load_tzdata_returns_none_on_windows_einval() -> None: + """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). + + Regression test for crash when the system TZ resolves to a POSIX string like + "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by + validate_tz to check whether it is also a valid IANA key. + """ + err = OSError(errno.EINVAL, "Invalid argument") + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(err), + ): + assert _load_tzdata("<+08>-8") is None + + +def test_load_tzdata_propagates_unexpected_oserror() -> None: + """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" + with ( + patch( + "esphome.components.time.resources.files", + _mock_resources_with_error( + PermissionError(errno.EACCES, "Permission denied") + ), + ), + pytest.raises(PermissionError), + ): + _load_tzdata("Some/Zone") + + +def test_load_tzdata_returns_none_on_file_not_found() -> None: + """Existing behavior: missing tz file returns None rather than raising.""" + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(FileNotFoundError()), + ): + assert _load_tzdata("Not/A/Zone") is None + + +def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: + """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. + + Simulates the Windows case where the auto-detected POSIX TZ string is fed + back through _load_tzdata and the underlying read_bytes raises errno 22. + """ + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), + ): + assert validate_tz("<+08>-8") == "<+08>-8" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 1a1bfffd0..dfd4305c4 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -84,9 +84,9 @@ def mock_decode_pc() -> Generator[Mock, None, None]: @pytest.fixture -def mock_run_external_command() -> Generator[Mock, None, None]: - """Mock run_external_command for platformio_api.""" - with patch("esphome.platformio_api.run_external_command") as mock: +def mock_run_external_process() -> Generator[Mock, None, None]: + """Mock run_external_process for platformio_api.""" + with patch("esphome.platformio_api.run_external_process") as mock: yield mock diff --git a/tests/unit_tests/fixtures/bundle/bundle_test.yaml b/tests/unit_tests/fixtures/bundle/bundle_test.yaml index f834a8d86..247f5cc8b 100644 --- a/tests/unit_tests/fixtures/bundle/bundle_test.yaml +++ b/tests/unit_tests/fixtures/bundle/bundle_test.yaml @@ -11,9 +11,9 @@ esp32: logger: <<: !include common/base.yaml -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password +# Plain nested !include — deferred as an IncludeFile until the substitution +# pass. The bundle must force-resolve it to pick up common/wifi.yaml. +wifi: !include common/wifi.yaml api: diff --git a/tests/unit_tests/fixtures/bundle/common/wifi.yaml b/tests/unit_tests/fixtures/bundle/common/wifi.yaml new file mode 100644 index 000000000..d7e7b3cd4 --- /dev/null +++ b/tests/unit_tests/fixtures/bundle/common/wifi.yaml @@ -0,0 +1,2 @@ +ssid: !secret wifi_ssid +password: !secret wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml new file mode 100644 index 000000000..7863def19 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml new file mode 100644 index 000000000..7a3b4970d --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml @@ -0,0 +1,4 @@ +packages: !include 13-packages_list.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml new file mode 100644 index 000000000..23161db3d --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml @@ -0,0 +1,2 @@ +- wifi: + password: pkg_password diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml new file mode 100644 index 000000000..7863def19 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml new file mode 100644 index 000000000..8b9fc5ec3 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml @@ -0,0 +1,4 @@ +packages: !include 14-packages_dict.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml new file mode 100644 index 000000000..55e8b38a4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml @@ -0,0 +1,3 @@ +network: + wifi: + password: pkg_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml new file mode 100644 index 000000000..14aa707de --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml new file mode 100644 index 000000000..5909e7bf4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_as_include.input.yaml @@ -0,0 +1,5 @@ +substitutions: !include 15-substitutions_inc.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml new file mode 100644 index 000000000..44d9a4b9e --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/15-substitutions_inc.yaml @@ -0,0 +1 @@ +wifi_password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml new file mode 100644 index 000000000..14aa707de --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.approved.yaml @@ -0,0 +1,5 @@ +substitutions: + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml new file mode 100644 index 000000000..a2e72f33a --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/16-substitutions_as_include_with_packages.input.yaml @@ -0,0 +1,9 @@ +substitutions: !include 15-substitutions_inc.yaml + +packages: + wifi_pkg: + wifi: + password: $wifi_password + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml new file mode 100644 index 000000000..f1fd5fb07 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.approved.yaml @@ -0,0 +1,6 @@ +substitutions: + subs_file: 15-substitutions_inc + wifi_password: sub_password +wifi: + ssid: main_ssid + password: sub_password diff --git a/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml new file mode 100644 index 000000000..3248504b4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/17-substitutions_include_cli_var.input.yaml @@ -0,0 +1,8 @@ +command_line_substitutions: + subs_file: 15-substitutions_inc + +substitutions: !include ${subs_file}.yaml + +wifi: + ssid: main_ssid + password: $wifi_password diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index de43830d5..1ca28c4f0 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -121,6 +121,26 @@ def test_get_addresses_auto_detection() -> None: assert cache.get_addresses("unknown.com") is None +def test_add_mdns_addresses_stores_and_normalizes() -> None: + """add_mdns_addresses inserts entries under the normalized hostname.""" + cache = AddressCache() + cache.add_mdns_addresses("Device.Local.", ["192.168.1.10", "192.168.1.11"]) + + assert cache.mdns_cache == { + normalize_hostname("Device.Local."): ["192.168.1.10", "192.168.1.11"] + } + # Overwrites on subsequent calls for the same host + cache.add_mdns_addresses("device.local", ["10.0.0.1"]) + assert cache.mdns_cache[normalize_hostname("device.local")] == ["10.0.0.1"] + + +def test_add_mdns_addresses_empty_is_noop() -> None: + """Passing an empty address list must not create an entry.""" + cache = AddressCache() + cache.add_mdns_addresses("device.local", []) + assert cache.mdns_cache == {} + + def test_has_cache() -> None: """Test checking if cache has entries.""" # Empty cache diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index b8b2d0ffd..89bf1a33b 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -5,8 +5,10 @@ from __future__ import annotations import io import json from pathlib import Path +import shutil import tarfile from typing import Any +from unittest.mock import patch import pytest @@ -20,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _force_load_include_files, extract_bundle, is_bundle_path, prepare_bundle_for_compile, @@ -485,7 +488,7 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None: result = read_bundle_manifest(bundle_path) assert result.esphome_version == "unknown" - assert result.files == [] + assert not result.files assert result.has_secrets is False @@ -862,6 +865,117 @@ def test_discover_files_skips_missing_directory(tmp_path: Path) -> None: assert len(files) == 1 +def test_discover_files_nested_include(tmp_path: Path) -> None: + """Nested !include files (e.g. wifi: !include wifi.yaml) are bundled.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include wifi.yaml\n" + ) + (config_dir / "wifi.yaml").write_text('ssid: "a"\npassword: "b"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert "wifi.yaml" in paths + + +def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: + """Chains of !include (a includes b includes c) are fully resolved.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include level1.yaml\n" + ) + (config_dir / "level1.yaml").write_text("nested: !include level2.yaml\n") + (config_dir / "level2.yaml").write_text('value: "leaf"\n') + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "level1.yaml" in paths + assert "level2.yaml" in paths + + +def test_discover_files_nested_include_unresolved_substitution( + tmp_path: Path, +) -> None: + """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" + ) + + creator = ConfigBundleCreator({}) + # Should not raise + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + + +def test_discover_files_nested_include_load_failure( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A nested !include pointing at a missing file is logged and skipped.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\nwifi: !include missing.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "test.yaml" in paths + assert any( + "failed to load !include" in r.message and "missing.yaml" in r.message + for r in caplog.records + ) + + +def test_force_load_skips_duplicate_include_file() -> None: + """The same IncludeFile referenced twice is only loaded once.""" + + class _StubInclude: + """Mimics yaml_util.IncludeFile minimally for _force_load testing.""" + + def __init__(self) -> None: + self.file = Path("dup.yaml") + self.parent_file = Path("root.yaml") + self.load_calls = 0 + + def has_unresolved_expressions(self) -> bool: + return False + + def load(self) -> dict[str, Any]: + self.load_calls += 1 + return {} + + stub = _StubInclude() + # Same instance appears twice — second visit must hit the _seen guard. + tree = {"a": stub, "b": [stub]} + + with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude): + _force_load_include_files(tree) + + assert stub.load_calls == 1 + + +def test_force_load_handles_cyclic_containers() -> None: + """Cyclic dict/list references don't cause infinite recursion.""" + cyclic_dict: dict[str, Any] = {} + cyclic_dict["self"] = cyclic_dict + + cyclic_list: list[Any] = [] + cyclic_list.append(cyclic_list) + + # Should return without recursing forever + _force_load_include_files(cyclic_dict) + _force_load_include_files(cyclic_list) + + def test_discover_files_yaml_reload_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1008,6 +1122,40 @@ def test_discover_files_walk_tuple_values(tmp_path: Path) -> None: assert "a.pem" in paths +# --------------------------------------------------------------------------- +# ConfigBundleCreator - fixture-based end-to-end +# --------------------------------------------------------------------------- + + +def test_discover_files_fixture_config(fixture_path: Path, tmp_path: Path) -> None: + """Use the real ``fixtures/bundle/`` tree as an end-to-end reproducer. + + The fixture config uses ``wifi: !include common/wifi.yaml`` — a plain + nested !include that is returned as a deferred ``IncludeFile`` and only + resolved during the substitution pass. Before this fix, bundle discovery + never ran substitutions, so ``common/wifi.yaml`` was silently missing + from the bundle. + """ + # Copy the fixture tree into a tmp dir so the test doesn't rely on the + # source repo being writable and so we can set CORE.config_path freely. + src = fixture_path / "bundle" + dst = tmp_path / "bundle" + shutil.copytree(src, dst) + + CORE.config_path = dst / "bundle_test.yaml" + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + paths = {f.path for f in files} + + # Root and top-level !secret-referenced files + assert "bundle_test.yaml" in paths + assert "secrets.yaml" in paths + # The nested !include — this is what regressed when IncludeFile became + # deferred (PR #12213). + assert "common/wifi.yaml" in paths + + # --------------------------------------------------------------------------- # ConfigBundleCreator - create_bundle # --------------------------------------------------------------------------- diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ac84ce7cc..f038272d8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -24,6 +24,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, + SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda @@ -765,3 +766,30 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.unbounded_possibly_negative_percentage(value) + + +def test_update_interval__coerces_zero_to_one_ms( + caplog: pytest.LogCaptureFixture, +) -> None: + """update_interval: 0ms must be coerced to 1ms (not rejected) because a + literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps + existing configs compiling on upgrade while emitting a user-facing + warning that directs them to set a non-zero value.""" + with caplog.at_level("WARNING"): + result = config_validation.update_interval("0ms") + assert result.total_milliseconds == 1 + assert "update_interval of 0ms is not supported" in caplog.text + assert "1ms" in caplog.text + + +def test_update_interval__preserves_nonzero_values() -> None: + """Non-zero update_interval values must pass through unchanged.""" + assert config_validation.update_interval("1ms").total_milliseconds == 1 + assert config_validation.update_interval("50ms").total_milliseconds == 50 + assert config_validation.update_interval("60s").total_milliseconds == 60000 + + +def test_update_interval__never_passes_through() -> None: + """update_interval: never must still map to SCHEDULER_DONT_RUN.""" + result = config_validation.update_interval("never") + assert result.total_milliseconds == SCHEDULER_DONT_RUN diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 85536d2f1..8ec9e70cf 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import dataclass import json import logging @@ -12,16 +12,18 @@ import re import sys import time from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from pytest import CaptureFixture +from zeroconf import ServiceStateChange from esphome import platformio_api from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _resolve_network_devices, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -36,6 +38,7 @@ from esphome.__main__ import ( has_mqtt, has_mqtt_ip_lookup, has_mqtt_logging, + has_name_add_mac_suffix, has_non_ip_address, has_ota, has_resolvable_address, @@ -48,6 +51,7 @@ from esphome.__main__ import ( upload_using_picotool, upload_using_platformio, ) +from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( @@ -62,6 +66,7 @@ from esphome.const import ( CONF_MDNS, CONF_MQTT, CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, @@ -79,6 +84,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError from esphome.util import BootselResult +from esphome.zeroconf import _await_discovery, discover_mdns_devices def strip_ansi_codes(text: str) -> str: @@ -1231,6 +1237,48 @@ def test_upload_using_esptool_path_conversion( assert partitions_path.endswith("partitions.bin") +def test_upload_using_esptool_skips_missing_extra_flash_images( + tmp_path: Path, + mock_run_external_command_main: Mock, + mock_get_idedata: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A non-existent path in extra_flash_images must be filtered out with a + warning, and must not appear in the esptool command line. Only the valid + images are flashed. Regression test for + https://github.com/esphome/esphome/issues/15634. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [ + platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + platformio_api.FlashImage(path=missing_path, offset="0x2d0000"), + ] + mock_get_idedata.return_value = mock_idedata + + (tmp_path / "firmware.bin").touch() + (tmp_path / "bootloader.bin").touch() + # Intentionally do NOT create missing_path + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + assert "Skipping missing flash image" in caplog.text + assert str(missing_path) in caplog.text + + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + assert str(missing_path) not in cmd_list + assert "0x2d0000" not in cmd_list + + def test_upload_using_esptool_with_file_path( tmp_path: Path, mock_run_external_command_main: Mock, @@ -2176,6 +2224,509 @@ def test_has_resolvable_address() -> None: assert has_resolvable_address() is False +def test_has_name_add_mac_suffix() -> None: + """Test has_name_add_mac_suffix function.""" + + # Test with name_add_mac_suffix enabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}}) + assert has_name_add_mac_suffix() is True + + # Test with name_add_mac_suffix disabled + setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: False}}) + assert has_name_add_mac_suffix() is False + + # Test with name_add_mac_suffix not set (defaults to False) + setup_core(config={CONF_ESPHOME: {}}) + assert has_name_add_mac_suffix() is False + + # Test with no esphome config + setup_core(config={}) + assert has_name_add_mac_suffix() is False + + # Test with no config at all + CORE.config = None + assert has_name_add_mac_suffix() is False + + +@pytest.fixture +def mock_mdns_discovery() -> Generator[MagicMock]: + """Fixture to mock the async mDNS discovery infrastructure. + + Patches ``AsyncEsphomeZeroconf``, ``AsyncServiceBrowser`` and + ``AddressResolver`` in ``esphome.zeroconf`` and exposes hooks for tests to + stage browser events and control resolution results. The default + ``AddressResolver`` stub simulates a cache hit returning no addresses, so + matched hosts appear in the discovery output with empty address lists + unless the test overrides ``_resolver_setup``. + """ + with ( + patch("esphome.zeroconf.AsyncEsphomeZeroconf") as mock_aiozc_class, + patch("esphome.zeroconf.AsyncServiceBrowser") as mock_browser_class, + patch("esphome.zeroconf.AddressResolver") as mock_resolver_class, + ): + mock_aiozc = MagicMock() + mock_aiozc.zeroconf = MagicMock() + mock_aiozc.async_close = AsyncMock(return_value=None) + mock_aiozc_class.return_value = mock_aiozc + + mock_browser = MagicMock() + mock_browser.async_cancel = AsyncMock(return_value=None) + + # Default: each host gets a fresh resolver that hits the cache and + # returns no addresses. Tests can override via ``_resolver_setup``. + def default_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_resolver_class.side_effect = default_resolver_factory + + # Store references for test access + mock_aiozc._mock_browser_class = mock_browser_class + mock_aiozc._mock_browser = mock_browser + mock_aiozc._mock_class = mock_aiozc_class + mock_aiozc._mock_resolver_class = mock_resolver_class + yield mock_aiozc + + +@pytest.mark.parametrize( + ("discovered_services", "base_name", "expected_hosts"), + [ + # Matching devices; different-prefix device is filtered out + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-def456._esphomelib._tcp.local.", ServiceStateChange.Added), + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + ["mydevice-abc123.local", "mydevice-def456.local"], + ), + # No matches at all + ( + [ + ( + "otherdevice-abcdef._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + ], + "mydevice", + [], + ), + # Deduplication (same device Added then Updated) + ( + [ + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added), + ("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Updated), + ], + "mydevice", + ["mydevice-abc123.local"], + ), + # Suffix must be exactly 6 hex chars: wrong length and non-hex are rejected + ( + [ + # too short + ("mydevice-abcd._esphomelib._tcp.local.", ServiceStateChange.Added), + # too long + ( + "mydevice-abcdef1._esphomelib._tcp.local.", + ServiceStateChange.Added, + ), + # non-hex + ("mydevice-xyz123._esphomelib._tcp.local.", ServiceStateChange.Added), + # valid + ("mydevice-012345._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "mydevice", + ["mydevice-012345.local"], + ), + # Prefix-collision: base "foo" must not match "foo-bar-abc123" + ( + [ + ("foo-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ("foo-bar-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added), + ], + "foo", + ["foo-abcdef.local"], + ), + ], + ids=[ + "matching_with_filter", + "no_matches", + "deduplication", + "hex_suffix_filter", + "prefix_collision", + ], +) +def test_discover_mdns_devices( + mock_mdns_discovery: MagicMock, + discovered_services: list[tuple[str, ServiceStateChange]], + base_name: str, + expected_hosts: list[str], +) -> None: + """Test discover_mdns_devices filtering and deduplication.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + callback = handlers[0] + for service_name, state_change in discovered_services: + callback( + mock_mdns_discovery.zeroconf, service_type, service_name, state_change + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Each discovered host gets a resolver that returns a unique IP string + # derived from its server name so we can assert per-host. + def resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver._name = name + resolver.load_from_cache.return_value = True + resolver.async_request = AsyncMock(return_value=True) + resolver.parsed_scoped_addresses.return_value = [f"10.0.0.1#{name}"] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = resolver_factory + + result = discover_mdns_devices(base_name, timeout=0) + + assert sorted(result) == expected_hosts + # Resolved addresses should be stored for matched hosts. AddressResolver + # receives the fully-qualified name (``.local.``). + for host in expected_hosts: + short = host.partition(".")[0] + assert result[host] == [f"10.0.0.1#{short}.local."] + mock_browser.async_cancel.assert_awaited_once() + mock_mdns_discovery.async_close.assert_awaited_once() + + +def test_discover_mdns_devices_init_failure(caplog: pytest.LogCaptureFixture) -> None: + """If AsyncEsphomeZeroconf fails to init, return empty dict and log warning.""" + with ( + patch( + "esphome.zeroconf.AsyncEsphomeZeroconf", + side_effect=OSError("no network"), + ), + caplog.at_level(logging.WARNING, logger="esphome.zeroconf"), + ): + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + assert "mDNS discovery failed to initialize" in caplog.text + + +def test_discover_mdns_devices_resolution_failure( + mock_mdns_discovery: MagicMock, +) -> None: + """If resolution raises, the host is still listed with an empty address list.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + # Resolver misses the cache, then async_request raises. + def failing_resolver_factory(name: str) -> MagicMock: + resolver = MagicMock() + resolver.load_from_cache.return_value = False + resolver.async_request = AsyncMock(side_effect=OSError("boom")) + resolver.parsed_scoped_addresses.return_value = [] + return resolver + + mock_mdns_discovery._mock_resolver_class.side_effect = failing_resolver_factory + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_discover_mdns_devices_ignores_removed_state( + mock_mdns_discovery: MagicMock, +) -> None: + """``Removed`` state changes are ignored and do not appear in the result.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Removed, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {} + # No AddressResolver should have been constructed since no host matched. + mock_mdns_discovery._mock_resolver_class.assert_not_called() + + +def test_discover_mdns_devices_empty_resolution( + mock_mdns_discovery: MagicMock, +) -> None: + """Host is listed with empty addresses when resolver returns no addresses.""" + mock_browser = mock_mdns_discovery._mock_browser + + def capture_callback( + zc: MagicMock, + service_type: str, + handlers: list[Callable[..., None]], + ) -> MagicMock: + handlers[0]( + mock_mdns_discovery.zeroconf, + service_type, + "mydevice-abc123._esphomelib._tcp.local.", + ServiceStateChange.Added, + ) + return mock_browser + + mock_mdns_discovery._mock_browser_class.side_effect = capture_callback + # Default fixture resolver is a cache-hit with no addresses — simulates + # the "browse found it but no A/AAAA records are available" case. + + result = discover_mdns_devices("mydevice", timeout=0) + + assert result == {"mydevice-abc123.local": []} + + +def test_resolve_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded to their cached IPs.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + result = _resolve_network_devices( + ["device-abc123.local", "192.168.1.50", "device-abc123.local"], + CORE.config, + MockArgs(), + ) + + # Cached hostname is replaced with its IPs (deduplicated across repeats) + # and the literal IP is preserved after. + assert result == ["10.0.0.1", "10.0.0.2", "192.168.1.50"] + + +def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: + """Hostnames not in the cache pass through unchanged.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache() + + result = _resolve_network_devices( + ["unknown.local", "192.168.1.50"], + CORE.config, + MockArgs(), + ) + + assert result == ["unknown.local", "192.168.1.50"] + + +def test_await_discovery_timeout_returns_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the discovery runner never sets its event, return {} and warn.""" + stub = MagicMock() + stub.event.wait.return_value = False + stub.exception = None + stub.result = {"should_not_be_read": ["1.2.3.4"]} + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=0.01) + + assert result == {} + assert "mDNS discovery timed out after 0.0s" in caplog.text + stub.event.wait.assert_called_once_with(timeout=pytest.approx(2.01)) + + +def test_await_discovery_propagates_exception_as_empty( + caplog: pytest.LogCaptureFixture, +) -> None: + """If the coroutine raised, log and return {} rather than re-raise.""" + stub = MagicMock() + stub.event.wait.return_value = True + stub.exception = RuntimeError("boom") + stub.result = None + + with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"): + result = _await_discovery(stub, timeout=5.0) + + assert result == {} + assert "mDNS discovery failed: boom" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> None: + """Interactive mode discovers MAC-suffixed devices and populates the cache.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with ( + patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover, + patch( + "esphome.__main__.choose_prompt", return_value="mydevice-abc123.local" + ) as mock_prompt, + ): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["mydevice-abc123.local"] + mock_discover.assert_called_once_with("mydevice") + mock_prompt.assert_called_once_with( + [ + ("Over The Air (mydevice-abc123.local)", "mydevice-abc123.local"), + ("Over The Air (mydevice-def456.local)", "mydevice-def456.local"), + ], + purpose=Purpose.UPLOADING, + ) + # Resolved IPs should be cached so downstream resolution skips a second + # Zeroconf lookup. + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + assert CORE.address_cache.get_mdns_addresses("mydevice-def456.local") == [ + "10.0.0.2" + ] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_mac_suffix_no_devices_found( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """When discovery finds nothing, no OTA option is offered and a warning logs.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + + with ( + patch("esphome.__main__.discover_mdns_devices", return_value={}), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + pytest.raises(EsphomeError), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "No devices matching 'mydevice-.local'" in caplog.text + + +def test_choose_upload_log_host_default_ota_discovers_mac_suffix( + tmp_path: Path, +) -> None: + """``--device OTA`` also runs mDNS discovery when name_add_mac_suffix is on.""" + setup_core( + config={ + CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + }, + address="mydevice.local", + tmp_path=tmp_path, + name="mydevice", + ) + CORE.address_cache = None + + discovered = { + "mydevice-abc123.local": ["10.0.0.1"], + "mydevice-def456.local": ["10.0.0.2"], + } + with patch( + "esphome.__main__.discover_mdns_devices", return_value=discovered + ) as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + # Both discovered hostnames are returned so aioesphomeapi / espota2 can + # try each in turn with the cached IPs. + assert result == ["mydevice-abc123.local", "mydevice-def456.local"] + mock_discover.assert_called_once_with("mydevice") + assert CORE.address_cache is not None + assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [ + "10.0.0.1" + ] + + +def test_choose_upload_log_host_default_ota_no_suffix_discovery( + tmp_path: Path, +) -> None: + """``--device OTA`` without name_add_mac_suffix uses CORE.address as-is.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + tmp_path=tmp_path, + name="mydevice", + ) + + with patch("esphome.__main__.discover_mdns_devices") as mock_discover: + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert result == ["192.168.1.100"] + # Discovery must NOT run when name_add_mac_suffix is disabled. + mock_discover.assert_not_called() + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index e1b3908c2..67e64e5f6 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,7 +1,8 @@ """Tests for platformio_api.py path functions.""" +# pylint: disable=protected-access + import json -import logging import os from pathlib import Path import shutil @@ -10,7 +11,7 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest -from esphome import platformio_api +from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError @@ -281,13 +282,13 @@ def test_run_idedata_raises_on_invalid_json( def test_run_platformio_cli_sets_environment_variables( - setup_core: Path, mock_run_external_command: Mock + setup_core: Path, mock_run_external_process: Mock ) -> None: """Test run_platformio_cli sets correct environment variables.""" CORE.build_path = str(setup_core / "build" / "test") with patch.dict(os.environ, {}, clear=False): - mock_run_external_command.return_value = 0 + mock_run_external_process.return_value = 0 platformio_api.run_platformio_cli("test", "arg") # Check environment variables were set @@ -300,10 +301,12 @@ def test_run_platformio_cli_sets_environment_variables( assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ - # Check command was called correctly - mock_run_external_command.assert_called_once() - args = mock_run_external_command.call_args[0] - assert "platformio" in args + # Check command was called correctly — runs PlatformIO as a subprocess + # via the esphome.platformio_runner entry point. + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert "-m" in args + assert "esphome.platformio_runner" in args assert "test" in args assert "arg" in args @@ -444,7 +447,7 @@ def test_patch_structhash(setup_core: Path) -> None: }, ): # Call patch_structhash - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Verify both modules had clean_build_dir patched # Check that clean_build_dir was set on both modules @@ -496,7 +499,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -546,7 +549,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -594,7 +597,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -719,7 +722,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None: ), }, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -758,7 +761,7 @@ def test_patch_file_downloader_retries_on_failure() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -799,7 +802,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -847,7 +850,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> ), patch("time.sleep"), ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -882,9 +885,9 @@ def test_patch_file_downloader_idempotent() -> None: }, ): # Patch multiple times - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -895,19 +898,18 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 -def test_platformio_log_filter_allows_non_platformio_messages() -> None: - """Test that non-platformio logger messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="esphome.core", - level=logging.INFO, - pathname="", - lineno=0, - msg="Some esphome message", - args=(), - exc_info=None, +def _filter_through_redirect(line: str) -> str: + """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" + import io + + from esphome.util import RedirectText + + captured = io.StringIO() + redirect = RedirectText( + captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES ) - assert log_filter.filter(record) is True + redirect.write(line + "\n") + return captured.getvalue() @pytest.mark.parametrize( @@ -930,19 +932,9 @@ def test_platformio_log_filter_allows_non_platformio_messages() -> None: "Memory Usage -> https://bit.ly/pio-memory-usage", ], ) -def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: - """Test that noisy platformio messages are filtered out.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio output lines are filtered out by RedirectText.""" + assert _filter_through_redirect(msg) == "" @pytest.mark.parametrize( @@ -954,39 +946,6 @@ def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: "warning: unused variable", ], ) -def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: - """Test that non-noisy platformio messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is True - - -@pytest.mark.parametrize( - "logger_name", - [ - "PLATFORMIO.builder", - "PlatformIO.core", - "platformio.run", - ], -) -def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: - """Test that platformio logger name matching is case insensitive.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name=logger_name, - level=logging.INFO, - pathname="", - lineno=0, - msg="Found 5 compatible libraries", - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_allows_other_messages(msg: str) -> None: + """Test that non-noisy platformio output lines pass through RedirectText.""" + assert _filter_through_redirect(msg) == msg + "\n" diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index b4cca05d9..7862c268c 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -4,7 +4,7 @@ from __future__ import annotations import re import socket -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr @@ -115,24 +115,21 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: - """Test timeout when thread doesn't complete in time.""" - # Mock the start method to prevent actual thread execution - with ( - patch.object(AsyncResolver, "start"), - patch("esphome.resolver.hr.async_resolve_host"), - ): - resolver = AsyncResolver(["test.local"], 6053) - # Override event.wait to simulate timeout (return False = timeout occurred) - with ( - patch.object(resolver.event, "wait", return_value=False), - pytest.raises( - EsphomeError, match=re.escape("Timeout resolving IP address") - ), - ): - resolver.resolve() + """Test timeout when the runner thread doesn't complete in time.""" + # Patch AsyncThreadRunner inside esphome.resolver so we never actually + # start a thread and can control the wait return value directly. + fake_runner = MagicMock() + fake_runner.start = MagicMock() + fake_runner.event.wait.return_value = False # simulate timeout - # Verify thread start was called - resolver.start.assert_called_once() + with ( + patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner), + patch("esphome.resolver.hr.async_resolve_host"), + pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")), + ): + AsyncResolver(["test.local"], 6053).resolve() + + fake_runner.start.assert_called_once() def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 01c669e54..215ec291f 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -14,6 +14,7 @@ from esphome.components.packages import ( do_packages_pass, merge_packages, ) +from esphome.components.substitutions.jinja import UndefinedError from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, merge_config import esphome.config_validation as cv @@ -658,7 +659,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", ): - processor.resolve_package(package_config, substitutions.ContextVars()) + processor.resolve_package(package_config, substitutions.ContextVars(), []) def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: @@ -675,6 +676,90 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_raise_first_undefined_logs_extras_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the first undefined error is raised; extras are logged at debug.""" + errors: substitutions.ErrList = [ + (UndefinedError("'a' is undefined"), ["url"], None), + (UndefinedError("'b' is undefined"), ["ref"], None), + (UndefinedError("'c' is undefined"), ["path"], None), + ] + + with ( + caplog.at_level(logging.DEBUG, logger="esphome.components.substitutions"), + pytest.raises(cv.Invalid) as exc_info, + ): + substitutions.raise_first_undefined(errors, "package definition") + + # First error is surfaced as the cv.Invalid message. + raised = str(exc_info.value) + assert "'a' is undefined" in raised + assert "'b' is undefined" not in raised + assert "'c' is undefined" not in raised + + # Remaining errors are captured via debug logging for troubleshooting. + assert "Additional undefined variables in package definition" in caplog.text + assert "'b' is undefined at 'ref'" in caplog.text + assert "'c' is undefined at 'path'" in caplog.text + + +def test_raise_first_undefined_noop_on_empty() -> None: + """An empty errors list is a no-op — no exception, no log.""" + substitutions.raise_first_undefined([], "package definition") + + +def test_do_substitution_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` where the file holds a list raises cv.Invalid. + + Locks in the shape check that runs after the deferred IncludeFile has been + resolved. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict({CONF_SUBSTITUTIONS: include}) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + substitutions.do_substitution_pass(config) + + +def test_do_packages_pass_included_substitutions_must_be_mapping( + tmp_path: Path, +) -> None: + """`substitutions: !include list.yaml` alongside `packages:` raises cv.Invalid. + + Without the shape check, ``UserDict(...)`` would surface a low-level + ``TypeError``; the explicit ``cv.Invalid`` points at the substitutions path. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(path: Path): + return ["not", "a", "mapping"] + + include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader) + config = OrderedDict( + { + CONF_SUBSTITUTIONS: include, + "packages": {"noop": {"wifi": {"ssid": "main"}}}, + } + ) + + with pytest.raises( + cv.Invalid, match="Substitutions must be a key to value mapping" + ): + do_packages_pass(config) + + def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None: """An undefined substitution in a package include filename raises cv.Invalid. @@ -693,4 +778,43 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No ) processor = _PackageProcessor({}, None, False) with pytest.raises(cv.Invalid, match="unresolved substitutions"): - processor.resolve_package(package_config, substitutions.ContextVars()) + processor.resolve_package(package_config, substitutions.ContextVars(), []) + + +def test_resolve_include_error_shows_expanded_from_when_substituted( + tmp_path: Path, +) -> None: + """When a substituted filename fails to load, the error includes '(expanded from ...)'.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + def failing_loader(_path: Path) -> None: + raise EsphomeError("File not found") + + include = yaml_util.IncludeFile(parent, "${device}.yaml", None, failing_loader) + context = substitutions.ContextVars({"device": "my_device"}) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], context) + + msg = str(exc_info.value) + assert "my_device.yaml" in msg + assert "expanded from '${device}.yaml'" in msg + + +def test_resolve_include_error_no_expanded_from_for_literal_filename( + tmp_path: Path, +) -> None: + """When a literal filename fails to load, the error has no 'expanded from' clause.""" + parent = tmp_path / "main.yaml" + parent.write_text("") + + def failing_loader(_path: Path) -> None: + raise EsphomeError("File not found") + + include = yaml_util.IncludeFile(parent, "literal.yaml", None, failing_loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + assert "expanded from" not in str(exc_info.value) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 940a394c0..e76769e6a 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -7,6 +7,7 @@ from datetime import datetime import json import os from pathlib import Path +import re import stat from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from esphome.writer import ( clean_build, clean_cmake_cache, copy_src_tree, + generate_build_info_data_cpp, generate_build_info_data_h, get_build_info, storage_should_clean, @@ -1615,49 +1617,62 @@ def test_get_build_info_build_time_str_format( def test_generate_build_info_data_h_format() -> None: """Test generate_build_info_data_h produces correct header content.""" - config_hash = 0x12345678 - build_time = 1700000000 - build_time_str = "2023-11-14 22:13:20 +0000" - comment = "Test comment" - - result = generate_build_info_data_h( - config_hash, build_time, build_time_str, comment - ) + result = generate_build_info_data_h() assert "#pragma once" in result - assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result - assert "#define ESPHOME_BUILD_TIME 1700000000" in result - assert "#define ESPHOME_COMMENT_SIZE 13" in result # len("Test comment") + 1 - assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result - assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const time_t ESPHOME_BUILD_TIME;" in result + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result + assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result + assert "extern const char ESPHOME_COMMENT_STR[]" in result def test_generate_build_info_data_h_esp8266_progmem() -> None: """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" - result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test", "comment") + result = generate_build_info_data_h() # Should have ESP8266 PROGMEM conditional assert "#ifdef USE_ESP8266" in result assert "#include " in result assert "PROGMEM" in result - # Both build time and comment should have PROGMEM versions + + +def test_generate_build_info_data_cpp_format() -> None: + """Test generate_build_info_data_cpp produces correct data definitions.""" + result = generate_build_info_data_cpp( + 0x12345678, 1700000000, "2023-11-14 22:13:20 +0000", "Test comment" + ) + + assert '#include "esphome/core/build_info_data.h"' in result + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x12345678U;" in result + assert "const time_t ESPHOME_BUILD_TIME = 1700000000;" in result + assert "const size_t ESPHOME_COMMENT_SIZE = 13;" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + + +def test_generate_build_info_data_cpp_esp8266_progmem() -> None: + """Test generate_build_info_data_cpp includes PROGMEM definitions.""" + result = generate_build_info_data_cpp(0xABCDEF01, 1700000000, "test", "comment") + + assert "#ifdef USE_ESP8266" in result assert 'ESPHOME_BUILD_TIME_STR[] PROGMEM = "test"' in result assert 'ESPHOME_COMMENT_STR[] PROGMEM = "comment"' in result -def test_generate_build_info_data_h_hash_formatting() -> None: - """Test generate_build_info_data_h formats hash with leading zeros.""" +def test_generate_build_info_data_cpp_hash_formatting() -> None: + """Test generate_build_info_data_cpp formats hash with leading zeros.""" # Test with small hash value that needs leading zeros - result = generate_build_info_data_h(0x00000001, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + result = generate_build_info_data_cpp(0x00000001, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x00000001U;" in result # Test with larger hash value - result = generate_build_info_data_h(0xFFFFFFFF, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + result = generate_build_info_data_cpp(0xFFFFFFFF, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xffffffffU;" in result -def test_generate_build_info_data_h_comment_escaping() -> None: - r"""Test generate_build_info_data_h properly escapes special characters in comment. +def test_generate_build_info_data_cpp_comment_escaping() -> None: + r"""Test generate_build_info_data_cpp properly escapes special characters in comment. Uses cpp_string_escape which outputs octal escapes for special characters: - backslash (ASCII 92) -> \134 @@ -1665,26 +1680,52 @@ def test_generate_build_info_data_h_comment_escaping() -> None: - newline (ASCII 10) -> \012 """ # Test backslash escaping (ASCII 92 = octal 134) - result = generate_build_info_data_h(0, 0, "test", "backslash\\here") + result = generate_build_info_data_cpp(0, 0, "test", "backslash\\here") assert 'ESPHOME_COMMENT_STR[] = "backslash\\134here"' in result # Test quote escaping (ASCII 34 = octal 042) - result = generate_build_info_data_h(0, 0, "test", 'has "quotes"') + result = generate_build_info_data_cpp(0, 0, "test", 'has "quotes"') assert 'ESPHOME_COMMENT_STR[] = "has \\042quotes\\042"' in result # Test newline escaping (ASCII 10 = octal 012) - result = generate_build_info_data_h(0, 0, "test", "line1\nline2") + result = generate_build_info_data_cpp(0, 0, "test", "line1\nline2") assert 'ESPHOME_COMMENT_STR[] = "line1\\012line2"' in result -def test_generate_build_info_data_h_empty_comment() -> None: - """Test generate_build_info_data_h handles empty comment.""" - result = generate_build_info_data_h(0, 0, "test", "") +def test_generate_build_info_data_cpp_empty_comment() -> None: + """Test generate_build_info_data_cpp handles empty comment.""" + result = generate_build_info_data_cpp(0, 0, "test", "") - assert "#define ESPHOME_COMMENT_SIZE 1" in result # Just null terminator + assert "const size_t ESPHOME_COMMENT_SIZE = 1;" in result # Just null terminator assert 'ESPHOME_COMMENT_STR[] = ""' in result +def test_generate_build_info_data_cpp_comment_size_counts_utf8_bytes() -> None: + """Comment size is in encoded UTF-8 bytes, not characters.""" + # "héllo" = 6 UTF-8 bytes + NUL. + result = generate_build_info_data_cpp(0, 0, "test", "héllo") + assert "const size_t ESPHOME_COMMENT_SIZE = 7;" in result + + +def test_generate_build_info_data_cpp_comment_clamped_to_buffer() -> None: + """Generator clamps at byte level and never truncates mid-codepoint.""" + # 100 thermometer-with-VS-16 sequences = 700 bytes, past the 256 buffer. + result = generate_build_info_data_cpp(0, 0, "test", "🌡️" * 100) + + match = re.search(r"ESPHOME_COMMENT_SIZE = (\d+);", result) + assert match is not None + size = int(match.group(1)) + assert 1 < size <= 256 + + lit_match = re.search(r'ESPHOME_COMMENT_STR\[\] = "([^"]*)"', result) + assert lit_match is not None + raw = re.sub( + r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), lit_match.group(1) + ).encode("latin-1") + raw.decode("utf-8") # raises if truncation left a partial UTF-8 sequence + assert len(raw) == size - 1 + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") @@ -1758,15 +1799,21 @@ def test_copy_src_tree_writes_build_info_files( ): copy_src_tree() - # Verify build_info_data.h was written + # Verify build_info_data.h declarations and build_info_data.cpp values were written build_info_h_path = esphome_core_path / "build_info_data.h" assert build_info_h_path.exists() build_info_h_content = build_info_h_path.read_text() - assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content - assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in build_info_h_content assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content - assert "#define ESPHOME_COMMENT_SIZE" in build_info_h_content + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in build_info_h_content assert "ESPHOME_COMMENT_STR" in build_info_h_content + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + build_info_cpp_content = build_info_cpp_path.read_text() + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xdeadbeefU;" in build_info_cpp_content + assert "const time_t ESPHOME_BUILD_TIME" in build_info_cpp_content + assert "const size_t ESPHOME_COMMENT_SIZE" in build_info_cpp_content + assert "ESPHOME_COMMENT_STR" in build_info_cpp_content # Verify build_info.json was written build_info_json_path = build_path / "build_info.json" @@ -1833,7 +1880,9 @@ def test_copy_src_tree_detects_config_hash_change( # Verify build_info files were updated due to config_hash change assert build_info_h_path.exists() - new_content = build_info_h_path.read_text() + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + new_content = build_info_cpp_path.read_text() assert "0xdeadbeef" in new_content.lower() new_json = json.loads(build_info_json_path.read_text()) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index bfd60de44..3815ac1d7 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -9,8 +9,15 @@ from esphome import core, yaml_util from esphome.components import substitutions from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict +from esphome.yaml_util import ( + ESPHomeDataBase, + ESPLiteralValue, + format_path, + make_data_base, + make_literal, +) @pytest.fixture(autouse=True) @@ -712,3 +719,235 @@ def test_yaml_merge_chain_include_depth_exceeded() -> None: yaml_text = "base:\n <<: !include loop.yaml\n" with pytest.raises(EsphomeError, match="Maximum include chain depth"): yaml_util.parse_yaml(parent, io.StringIO(yaml_text), self_referencing_loader) + + +def _located(value, doc: str, line: int, col: int): + """Return *value* wrapped with a fake ESPHomeDataBase source location.""" + loc = DocumentLocation(doc, line, col) + obj = make_data_base(value) + if isinstance(obj, ESPHomeDataBase): + obj._esp_range = DocumentRange(loc, loc) + return obj + + +def test_format_path_no_location_info_returns_flat_path(): + """Plain path items with no esp_range produce a simple flat 'In:' line.""" + result = format_path(["wifi", "ssid"], None) + assert result == "In: wifi->ssid" + + +def test_format_path_no_location_info_current_obj_adds_file(): + """When path has no location but current_obj does, its location is shown.""" + obj = _located("${var}", "main.yaml", 5, 10) + result = format_path(["wifi", "ssid"], obj) + assert result == "In: wifi->ssid in main.yaml 6:11" + + +def test_format_path_single_frame_no_include_boundary(): + """All located keys from the same document → single 'In:' line, no 'Included from'.""" + path = ["packages", _located("pkg1", "root.yaml", 5, 2)] + result = format_path(path, None) + assert result.startswith("In: packages->pkg1 in root.yaml 6:3") + assert "Included from" not in result + + +def test_format_path_two_frames_shows_included_from(): + """Keys from two different documents produce 'In:' + one 'Included from' line.""" + path = [ + "packages", + _located("device", "root.yaml", 10, 2), + "packages", + _located("inner", "hardware.yaml", 3, 2), + ] + result = format_path(path, None) + assert "In: packages->inner in hardware.yaml 4:3" in result + assert "Included from packages->device in root.yaml 11:3" in result + + +def test_format_path_three_frames_full_include_stack(): + """Three document levels produce two 'Included from' lines in correct order.""" + path = [ + "packages", + _located("device", "root.yaml", 10, 2), + "packages", + _located("_wifi_", "hardware.yaml", 43, 2), + "packages", + _located("_roam_", "wifi.yaml", 25, 2), + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0].startswith("In: packages->_roam_ in wifi.yaml") + assert lines[1].startswith(" Included from packages->_wifi_ in hardware.yaml") + assert lines[2].startswith(" Included from packages->device in root.yaml") + + +def test_format_path_current_obj_overrides_innermost_location(): + """current_obj's esp_range replaces the key's column for the 'In:' line.""" + path = ["packages", _located("pkg1", "root.yaml", 5, 2)] + # Value (the expression) sits at column 10, not column 2 like the key + value = _located("${undefined}", "root.yaml", 5, 10) + result = format_path(path, value) + assert "6:11" in result + assert "6:3" not in result + + +def test_format_path_empty_path_with_no_location(): + """Empty path with no location info returns 'In: '.""" + result = format_path([], None) + assert result == "In: " + + +def test_format_path_integer_path_items_formatted_as_subscript(): + """Integer indices are rendered as [n] subscripts in the flat fallback.""" + result = format_path(["packages", 0], None) + assert result == "In: packages[0]" + + +def test_format_path_integer_list_index_attached_to_previous_frame(): + """A list index between two include boundaries attaches to the outer frame.""" + path = [ + "packages", + _located("packages", "main.yaml", 5, 0), + 0, + _located("packages", "level1.yaml", 2, 0), + 0, + _located("esphome", "level2.yaml", 0, 0), + _located("name", "level2.yaml", 1, 8), + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0].startswith("In: esphome->name in level2.yaml") + assert "packages[0]" in lines[1] and "level1.yaml" in lines[1] + assert "packages[0]" in lines[2] and "main.yaml" in lines[2] + + +def test_format_path_trailing_unlocated_string_after_located_key(): + """Plain string keys after the last located key must still appear in output.""" + path = [_located("packages", "main.yaml", 5, 0), "sub", "key"] + result = format_path(path, None) + assert result == "In: packages->sub->key in main.yaml 6:1" + + +def test_format_path_trailing_unlocated_int_attaches_to_current_frame(): + """Trailing ints attach to the open frame's last key (subscript), strings + buffer until end-of-path and then flush behind.""" + path = [_located("packages", "main.yaml", 5, 0), 0, "sub"] + result = format_path(path, None) + # Int attaches to 'packages' as [0] subscript; trailing 'sub' is flushed + # at end and appears after. + assert result == "In: packages[0]->sub in main.yaml 6:1" + + +def test_format_path_only_trailing_unlocated_strings_are_preserved(): + """Trailing pending items must not be silently dropped after the last frame.""" + path = [ + _located("packages", "main.yaml", 5, 0), + _located("inner", "hardware.yaml", 3, 0), + "tail1", + "tail2", + ] + result = format_path(path, None) + lines = result.splitlines() + assert lines[0] == "In: inner->tail1->tail2 in hardware.yaml 4:1" + assert lines[1] == " Included from packages in main.yaml 6:1" + + +def test_format_path_leading_int_with_no_current_doc_goes_to_pending(): + """An int before any located key is buffered and shown in the first frame.""" + path = [0, _located("name", "main.yaml", 1, 0)] + result = format_path(path, None) + # Leading ints have no preceding name to subscript onto, so they render + # as bare [n] in the formatted segment. + assert result == "In: [0]->name in main.yaml 2:1" + + +def test_format_path_only_unlocated_int_returns_flat_fallback(): + """Path with only an int and no location info renders via the flat fallback.""" + result = format_path([0], None) + assert result == "In: [0]" + + +def test_format_path_current_obj_in_different_doc_than_innermost_frame(): + """current_obj's location is preferred even when its document differs from the frame's.""" + path = [_located("packages", "root.yaml", 1, 0)] + value = _located("${var}", "other.yaml", 9, 4) + result = format_path(path, value) + # Innermost line uses current_obj's mark (other.yaml 10:5), not the key's. + assert result == "In: packages in other.yaml 10:5" + + +def test_format_path_current_obj_without_location_falls_back_to_key(): + """An ESPHomeDataBase current_obj with no esp_range falls back to the key's location.""" + + class _NoRange(ESPHomeDataBase, str): + pass + + obj = _NoRange.__new__(_NoRange, "value") + str.__init__(obj) + # No _esp_range set on this instance. + assert obj.esp_range is None + + path = [_located("packages", "main.yaml", 5, 2)] + result = format_path(path, obj) + assert result == "In: packages in main.yaml 6:3" + + +def test_format_path_empty_path_with_located_current_obj(): + """An empty path with a located current_obj still surfaces the location.""" + obj = _located("${var}", "main.yaml", 0, 0) + result = format_path([], obj) + assert result == "In: in main.yaml 1:1" + + +def test_make_literal_wraps_dict() -> None: + """A dict is wrapped so it becomes an ESPLiteralValue instance.""" + value = {"key": "${var}"} + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, dict) + assert result == {"key": "${var}"} + + +def test_make_literal_wraps_list() -> None: + """A list is wrapped so it becomes an ESPLiteralValue instance.""" + value = ["${var}", "plain"] + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, list) + assert result == ["${var}", "plain"] + + +def test_make_literal_wraps_string() -> None: + """A string is wrapped so it becomes an ESPLiteralValue instance.""" + result = make_literal("${var}") + assert isinstance(result, ESPLiteralValue) + assert result == "${var}" + + +def test_make_literal_returns_already_wrapped_value_unchanged() -> None: + """Wrapping a value that is already an ESPLiteralValue returns it as-is.""" + value = make_literal({"key": "value"}) + assert isinstance(value, ESPLiteralValue) + result = make_literal(value) + assert result is value + + +def test_make_literal_returns_none_unchanged() -> None: + """Values whose class cannot be augmented (e.g. ``None``) are returned as-is.""" + result = make_literal(None) + assert result is None + + +def test_make_literal_blocks_substitution() -> None: + """A value wrapped with make_literal is skipped by the substitution pass.""" + value = make_literal({"pin": "${PIN}"}) + result = substitutions.substitute( + value, + path=[], + parent_context=substitutions.ContextVars(), + strict_undefined=False, + ) + # The literal block must remain untouched, even though the variable is + # undefined in the context. + assert result == {"pin": "${PIN}"} + assert isinstance(result, ESPLiteralValue)