diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3b39d519c4..977fe9428d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,7 +8,7 @@ contact_links: url: https://github.com/esphome/esphome-webserver/issues/new/choose about: Report an issue with the ESPHome web server. - name: Report an issue with the ESPHome Builder / Dashboard - url: https://github.com/esphome/dashboard/issues/new/choose + url: https://github.com/esphome/device-builder/issues/new/choose about: Report an issue with the ESPHome Builder / Dashboard. - name: Report an issue with the ESPHome API client url: https://github.com/esphome/aioesphomeapi/issues/new/choose diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 08def88577..e708ae41b2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) +- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change) - [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api) - [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations) @@ -20,6 +21,10 @@ - esphome/esphome.io# +**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):** + +- esphome/developers.esphome.io# + ## Test Environment - [ ] ESP32 diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index f566ba4c43..b884e1e4c6 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -3,8 +3,10 @@ description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF natively (clang-tidy for IDF/Arduino and the component test batches) shares - one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS - defaults to "all", so all toolchains are present regardless of the chip). + one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", and _get_configured_targets() in espidf/toolchain.py + skips per-variant narrowing whenever CI is set, so all toolchains are + present regardless of the chip a job builds. Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. inputs: diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9d6dc5301c..daf041819c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 2938fd923c..5e8acb09c9 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -13,6 +13,7 @@ module.exports = { 'merging-to-release', 'merging-to-beta', 'chained-pr', + 'stacked-pr', 'core', 'small-pr', 'medium-pr', @@ -22,11 +23,13 @@ module.exports = { 'has-tests', 'needs-tests', 'needs-docs', + 'needs-developer-docs', 'needs-codeowners', 'too-big', 'labeller-recheck', 'bugfix', 'new-feature', + 'new-feature-developer', 'breaking-change', 'developer-breaking-change', 'undocumented-api-change', @@ -40,5 +43,17 @@ module.exports = { // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ + ], + + DEVELOPER_DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/, + /esphome\/developers\.esphome\.io#\d+/ + ], + + // Files whose developer-facing changes are documented via Python docstrings + // only - developers.esphome.io has no reference page for them yet, so PRs + // touching nothing but these files (and tests/) skip needs-developer-docs. + DEV_DOCS_EXEMPT_FILES: [ + 'esphome/config_validation.py' ] }; diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 4406370a27..bb85ccd681 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -1,4 +1,4 @@ -const { DOCS_PR_PATTERNS } = require('./constants'); +const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants'); const { COMPONENT_REGEX, detectComponents, @@ -33,8 +33,41 @@ async function fetchPrFileContent(github, context, path) { } } +// Check whether a pull request is part of a GitHub stack. +// +// GitHub's stacked pull request feature adds a `stack` object to the pull +// request resource. It is present on every pull request in the stack - +// including the bottom one, whose base is already `dev` - and is absent +// entirely on standalone pull requests. +// +// The `pull_request_target` webhook payload is not guaranteed to carry this +// field, so fall back to asking the API when it is missing. Guessing wrong +// here is costly: a stacked pull request mistaken for a manually chained one +// gets a label that blocks merging. +async function isStackedPr(github, context) { + const pr = context.payload.pull_request; + if (pr.stack != null) { + return true; + } + + try { + const { owner, repo } = context.repo; + const { data } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + return data.stack != null; + } catch (error) { + // Treat an API failure as "not stacked" so a chained pull request still + // gets its blocking label rather than silently slipping through. + console.log('Failed to check stack membership:', error.message); + return false; + } +} + // Strategy: Merge branch detection -async function detectMergeBranch(context) { +async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; @@ -42,7 +75,11 @@ async function detectMergeBranch(context) { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); + } else if (await isStackedPr(github, context)) { + // GitHub manages the merge order for a stack, so these are not blocked. + labels.add('stacked-pr'); } else if (baseRef !== 'dev') { + // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } @@ -245,6 +282,7 @@ async function detectPRTemplateCheckboxes(context) { const checkboxPatterns = [ { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, + { pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' }, @@ -355,12 +393,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) const labels = new Set(); // Check for missing tests - if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { + if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) { labels.add('needs-tests'); } // Check for missing docs. - // `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform` + // `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is + // deliberately excluded here: its docs live on developers.esphome.io and are + // checked separately below. `new-component` / `new-platform` // only count when at least one newly added file defines a top-level CONFIG_SCHEMA, // i.e. the new component/platform is actually loadable from YAML. const docsEligible = @@ -376,6 +416,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) } } + // Check for missing developer docs. `new-feature-developer` requires a + // developers.esphome.io PR link, unless every changed file outside tests/ is + // in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only). + if (allLabels.has('new-feature-developer')) { + const prBody = context.payload.pull_request.body || ''; + const nonTestFiles = prFiles + .map(file => file.filename) + .filter(file => !file.startsWith('tests/')); + const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file)); + const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); + + if (!onlyExemptFiles && !hasDevDocsLink) { + labels.add('needs-developer-docs'); + } + } + // Check for missing CODEOWNERS if (allLabels.has('new-component')) { const codeownersModified = prFiles.some(file => diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index c8bdcfb2f3..8b0c821503 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => { // Early exit for release and beta branches only if (baseRef === 'release' || baseRef === 'beta') { - const branchLabels = await detectMergeBranch(context); + const branchLabels = await detectMergeBranch(github, context); const finalLabels = Array.from(branchLabels); console.log('Computed labels (merge branch only):', finalLabels.join(', ')); @@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => { deprecatedResult, maintainerAccess ] = await Promise.all([ - detectMergeBranch(context), + detectMergeBranch(github, context), detectComponentPlatforms(changedFiles, apiData), detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index aab1827c44..f30ceff8c1 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,14 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); +const { + detectMergeBranch, + detectNewPlatforms, + detectNewComponents, + detectPRSize, + detectPRTemplateCheckboxes, + detectRequirements, +} = require('../detectors'); +const { MANAGED_LABELS } = require('../constants'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -29,6 +37,107 @@ const API_DATA = { const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; +// --------------------------------------------------------------------------- +// detectMergeBranch +// --------------------------------------------------------------------------- + +// Builds a fresh context for detectMergeBranch tests instead of mutating the +// shared CONTEXT fixture above (which other describe blocks rely on). +function makeMergeContext(baseRef, { stack } = {}) { + const pull_request = { number: 1, base: { ref: baseRef } }; + if (stack !== undefined) { + pull_request.stack = stack; + } + return { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request } + }; +} + +// A GitHub API mock exposing only rest.pulls.get, with a call counter so +// tests can assert whether the API fallback was actually invoked. +function makeStackGithub({ stack = null, error = null } = {}) { + const state = { calls: 0 }; + const github = { + rest: { + pulls: { + get: async () => { + state.calls++; + if (error) throw error; + return { data: { stack } }; + } + } + } + }; + return { github, state }; +} + +const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 }; + +describe('detectMergeBranch', () => { + it('base ref release adds merging-to-release only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('release', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']); + assert.equal(state.calls, 0); + }); + + it('base ref beta adds merging-to-beta only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('beta', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']); + assert.equal(state.calls, 0); + }); + + it('stack present on the webhook payload adds stacked-pr without calling the API', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('feature-branch', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('stack absent from payload falls back to the API and adds stacked-pr', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 1); + }); + + it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('dev', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('not stacked, base ref not dev adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + + it('not stacked, base ref dev adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => { + const { github, state } = makeStackGithub({ error: new Error('API unavailable') }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + assert.equal(state.calls, 1); + }); +}); + // --------------------------------------------------------------------------- // detectNewPlatforms // --------------------------------------------------------------------------- @@ -146,6 +255,125 @@ describe('detectNewComponents', () => { }); }); +// --------------------------------------------------------------------------- +// detectPRTemplateCheckboxes +// --------------------------------------------------------------------------- + +const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)'; +const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; +const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; + +function makeBodyContext(body) { + return { payload: { pull_request: { body } } }; +} + +describe('detectPRTemplateCheckboxes', () => { + it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE)); + assert.ok(labels.has('new-feature-developer')); + assert.ok(!labels.has('new-feature')); + }); + + it('unticked developer-facing feature checkbox adds no label', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED)); + assert.ok(!labels.has('new-feature-developer')); + }); + + it('ticked new feature checkbox does not add new-feature-developer', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE)); + assert.ok(labels.has('new-feature')); + assert.ok(!labels.has('new-feature-developer')); + }); +}); + +// --------------------------------------------------------------------------- +// detectRequirements +// --------------------------------------------------------------------------- + +describe('detectRequirements', () => { + // PR body without any docs-PR link. + const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.'); + const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234'); + const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234'); + const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234'); + + // File sets: a normal source change vs. one confined to the exempt core validators. + const SOURCE_FILES = [ + { filename: 'esphome/components/foo/foo.py' }, + { filename: 'tests/components/foo/common.yaml' }, + ]; + const VALIDATOR_FILES = [ + { filename: 'esphome/config_validation.py' }, + { filename: 'tests/unit_tests/test_config_validation.py' }, + ]; + + it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-tests')); + assert.ok(!labels.has('needs-docs')); + }); + + it('new-feature-developer with has-tests does not add needs-tests', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-tests')); + }); + + it('new-feature without a docs link still adds needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developer docs link does not satisfy needs-docs for new-feature', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('changes confined to core validator files are exempt from needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('validator changes mixed with other source files are not exempt', async () => { + const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }]; + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); +}); + +// --------------------------------------------------------------------------- +// MANAGED_LABELS +// --------------------------------------------------------------------------- + +describe('MANAGED_LABELS', () => { + it('includes new-feature-developer so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('new-feature-developer')); + }); + + it('includes needs-developer-docs so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('needs-developer-docs')); + }); +}); + // --------------------------------------------------------------------------- // detectPRSize // --------------------------------------------------------------------------- diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d034227ef6..b49c66b976 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate a token id: generate-token @@ -35,7 +35,7 @@ jobs: # Scope the minted App token to the minimum needed by auto-label-pr/*.js. permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment - permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview + permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 58fc83e3f5..820081cc46 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,15 +21,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2740ca76ca..71dedd65aa 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -61,9 +61,9 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up Docker Buildx @@ -71,11 +71,13 @@ jobs: - name: Determine tag and whether to push id: tag + env: + HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | # Sanitize the branch name into a valid docker tag: replace invalid # characters, ensure the first character is valid (tags must start # with [A-Za-z0-9_]), and cap the length at 128 characters. - branch="${{ github.head_ref || github.ref_name }}" + branch="$HEAD_REF" tag="${branch//[^a-zA-Z0-9_.-]/-}" case "$tag" in [a-zA-Z0-9_]*) ;; @@ -96,7 +98,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -145,16 +147,16 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -180,8 +182,8 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Cap concurrency so this smoke test doesn't hog all the shared runners. - max-parallel: 2 + # Modest cap so this smoke test leaves room on the shared runner pool. + max-parallel: 8 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant @@ -202,7 +204,7 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 3313ced690..ea039de9b9 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index ac0322e2fa..0dc653bd39 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf98478fd..7e695bb46b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,13 +28,13 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -65,202 +65,9 @@ jobs: python -m venv venv . venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - pylint: - name: Check pylint - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.python-linters == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Run pylint - run: | - . venv/bin/activate - pylint -f parseable --persistent=n esphome - - name: Suggested changes - run: script/ci-suggest-changes - if: always() - - ci-custom: - name: Run script/ci-custom - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.core-ci == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" - - name: Run script/ci-custom - run: | - . venv/bin/activate - script/ci-custom.py - script/build_codeowners.py --check - script/build_language_schema.py --check - script/generate-esp32-boards.py --check - script/generate-rp2-boards.py --check - script/ci_check_duplicate_test_ids.py - - import-time: - name: Check import esphome.__main__ time - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.import-time == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Check import time against budget and write waterfall HAR - run: | - . venv/bin/activate - script/check_import_time.py --check --har importtime.har - - name: Upload waterfall HAR - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: import-time-waterfall - path: importtime.har - if-no-files-found: ignore - retention-days: 14 - - device-builder: - name: Test downstream esphome/device-builder - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.device-builder == 'true' - steps: - - name: Check out esphome (this PR) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - path: esphome - - name: Check out esphome/device-builder - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: esphome/device-builder - ref: main - path: device-builder - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.13" - - name: Set up uv - # Mirrors the install shape device-builder's own CI uses - # (esphome/device-builder#192): uv replaces pip for the - # install step (order-of-magnitude faster on cold boots, - # with its own wheel cache). actions/setup-python still - # provides the interpreter. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - with: - enable-cache: true - # Pull request saves land in per-PR scopes nothing else can - # reuse; dev pushes seed the shared copy instead. - save-cache: ${{ github.event_name != 'pull_request' }} - # Pin uv version so the action does not have to fetch the - # manifest from raw.githubusercontent.com on every cache - # miss; that fetch flakes on Windows runners. - version: "0.11.15" - - name: Install device-builder + esphome from PR - # Install device-builder with its esphome + test extras - # first so its pinned versions of pytest/etc. land, then - # overlay the PR's esphome so the downstream tests run - # against this PR's Python code. ``--system`` installs into - # the runner's Python instead of a venv. - run: | - uv pip install --system -e './device-builder[esphome,test]' - uv pip install --system -e ./esphome - - name: Run device-builder pytest - # ``-n auto`` runs under pytest-xdist (matches device-builder's - # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. ``tests/e2e/slow`` - # is excluded: those are real multi-minute toolchain compiles - # (LibreTiny SDK clone, native ESP-IDF install) that device-builder - # runs in its own dedicated jobs, not this smoke check. - working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow - - pytest: - name: Run pytest - strategy: - fail-fast: false - matrix: - python-version: - - "3.12" - - "3.13" - - "3.14" - os: - - ubuntu-latest - - macOS-latest - - windows-latest - exclude: - # Minimize CI resource usage - # by only running the Python version - # version used for docker images on Windows and macOS - - python-version: "3.13" - os: windows-latest - - python-version: "3.13" - os: macOS-latest - runs-on: ${{ matrix.os }} - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.core-ci == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - id: restore-python - uses: ./.github/actions/restore-python - with: - python-version: ${{ matrix.python-version }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - - name: Run pytest - if: matrix.os == 'windows-latest' - run: | - . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - - name: Run pytest - if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' - run: | - . venv/bin/activate - pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - name: Save Python virtual environment cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: venv - key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -291,7 +98,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -350,6 +157,169 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + ci-custom: + name: Run script/ci-custom + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" + - name: Run script/ci-custom + run: | + . venv/bin/activate + script/ci-custom.py + script/build_codeowners.py --check + script/build_language_schema.py --check + script/generate-esp32-boards.py --check + script/generate-rp2-boards.py --check + script/ci_check_duplicate_test_ids.py + script/ci_check_test_fixture_list_form.py + + pylint: + name: Check pylint + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Run pylint + run: | + . venv/bin/activate + pylint -f parseable --persistent=n esphome + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + lint-format: + name: Check lint and formatting + runs-on: ubuntu-latest + needs: + - determine-jobs + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run prek + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 + with: + # Keep in sync with requirements_test.txt. + prek-version: "0.4.11" + # This job only runs on pull requests, so nothing ever populates + # the cache on dev. Every run would miss and then write a per-pull + # request copy, which is what the old seed-cache job existed to + # avoid. Building the hooks from scratch takes seconds, so skip it. + cache: false + env: + PREK_SKIP: pylint,ci-custom + # Pushes any fixes the hooks made back to the pull request. This step + # must keep its default name: the GitHub App that performs the push + # locates the workflow run by that name. + - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 + if: always() + with: + msg: apply automatic formatting fixes + + pytest: + name: Run pytest + strategy: + fail-fast: false + matrix: + python-version: + - "3.12" + - "3.13" + - "3.14" + os: + - ubuntu-latest + - macOS-latest + - windows-latest + exclude: + # Minimize CI resource usage + # by only running the Python version + # version used for docker images on Windows and macOS + - python-version: "3.13" + os: windows-latest + - python-version: "3.13" + os: macOS-latest + runs-on: ${{ matrix.os }} + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + id: restore-python + uses: ./.github/actions/restore-python + with: + python-version: ${{ matrix.python-version }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run pytest + if: matrix.os == 'windows-latest' + run: | + . ./venv/Scripts/activate.ps1 + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Run pytest + if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' + run: | + . venv/bin/activate + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + - name: Save Python virtual environment cache + if: github.ref == 'refs/heads/dev' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: venv + key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + + codecov-empty-upload: + name: Report no coverage to Codecov + runs-on: ubuntu-24.04 + needs: + - determine-jobs + # ``pytest`` is the only job that uploads coverage, and it is skipped when + # every changed file is CI-irrelevant (see ``should_run_core_ci`` in + # ``script/determine-jobs.py``). With no upload Codecov never reports a + # result, so the required ``codecov/patch`` status stays pending forever and + # the pull request can never be merged. Tell Codecov up front that this + # commit has nothing to cover so it publishes a passing status instead. + # + # ``force`` skips Codecov's own check that every changed file is ignorable; + # ``determine-jobs`` has already decided none of these files can affect + # coverage, and Codecov would otherwise fail the status for paths it does + # not recognise as non-testable (``docker/**``, ``.yamllint``). + if: needs.determine-jobs.outputs.core-ci == 'false' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Report empty upload to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + run_command: empty-upload + force: true + fail_ci_if_error: true + integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) runs-on: ubuntu-latest @@ -363,10 +333,28 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install ccache + # Speeds up the host compiles: tests in a bucket compile overlapping + # component sets, so later tests reuse earlier tests' objects. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ccache + - name: Restore ccache (restore-only) + # esphome stores the PlatformIO ccache under the machine-global cache + # dir (see _ccache_env() in esphome/platformio/toolchain.py). The + # bucket-name prefix prefers a same-bucket seed; the bare prefix falls + # back to any seed when the bucket layout differs from dev. + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/esphome/platformio-ccache + key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} + restore-keys: | + integration-ccache-${{ matrix.bucket.name }}- + integration-ccache- - name: Set up Python 3.13 id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - name: Restore Python virtual environment @@ -378,7 +366,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -408,33 +396,46 @@ jobs: mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" + - name: Print ccache statistics + # esphome stores the PlatformIO ccache under the machine-global cache + # dir (see _ccache_env() in esphome/platformio/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s + - name: Save ccache + # Pull request saves land in per-PR scopes nothing else can reuse; + # dev pushes seed the shared copy instead. + if: github.event_name != 'pull_request' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/esphome/platformio-ccache + key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - cpp-unit-tests: - name: Run C++ unit tests + import-time: + name: Check import esphome.__main__ time runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') + if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - - name: Run cpp_unit_test.py + - name: Check import time against budget and write waterfall HAR run: | . venv/bin/activate - if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then - script/cpp_unit_test.py --all - else - ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) - script/cpp_unit_test.py $ARGS - fi + script/check_import_time.py --check --har importtime.har + - name: Upload waterfall HAR + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: import-time-waterfall + path: importtime.har + if-no-files-found: ignore + retention-days: 14 benchmarks: name: Run CodSpeed benchmarks @@ -447,7 +448,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -465,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 + uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: run: | . venv/bin/activate @@ -473,6 +474,33 @@ jobs: pytest tests/benchmarks/python/ --codspeed --no-cov mode: simulation + cpp-unit-tests: + name: Run C++ unit tests + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Run cpp_unit_test.py + run: | + . venv/bin/activate + if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then + script/cpp_unit_test.py --all + else + ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) + script/cpp_unit_test.py $ARGS + fi + clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 @@ -488,7 +516,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: 2 matrix: include: - id: clang-tidy @@ -504,10 +531,19 @@ jobs: options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 cache_sdk_nrf: true ignore_errors: false + - id: clang-tidy + name: Run script/clang-tidy for RP2 + options: --environment rp2-tidy --grep USE_RP2 + pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for LibreTiny + environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + pio_cache_key: tidylibretiny steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -567,10 +603,21 @@ jobs: . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" - script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="" else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="--changed" + fi + if [ -n "${{ matrix.environments }}" ]; then + rc=0 + for env in ${{ matrix.environments }}; do + echo "::group::clang-tidy $env" + script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1 + echo "::endgroup::" + done + exit $rc + else + script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -594,7 +641,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -659,7 +706,6 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 matrix: include: - id: clang-tidy @@ -674,7 +720,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -739,12 +785,12 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 matrix: include: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 - options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3 + # yamllint disable-line rule:line-length + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, @@ -754,11 +800,11 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 # yamllint disable-line rule:line-length - options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -825,7 +871,7 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} + max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }} matrix: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: @@ -845,7 +891,7 @@ jobs: sudo apt-get install -y --no-install-recommends libsdl2-dev ccache - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -990,7 +1036,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -1016,69 +1062,62 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio - pre-commit-seed-cache: - name: Seed pre-commit cache - runs-on: ubuntu-latest - needs: - - common - # Saves a dev-scoped pre-commit cache that pull request runs can - # restore, since pre-commit.ci lite itself never runs on dev pushes. - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache pre-commit environments - id: cache-pre-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pre-commit - # Must match the restore key in pre-commit-ci-lite - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install pre-commit hook environments - if: steps.cache-pre-commit.outputs.cache-hit != 'true' - run: | - python -m pip install pre-commit - pre-commit install-hooks - - pre-commit-ci-lite: - name: pre-commit.ci lite - runs-on: ubuntu-latest + device-builder: + name: Test downstream esphome/device-builder + runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' + if: needs.determine-jobs.outputs.device-builder == 'true' steps: - - name: Check out code from GitHub - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python + - name: Check out esphome (this PR) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - # Inlined from esphome/pre-commit-action with a restore-only cache - # step: the pre-commit-seed-cache job owns saving this cache, so - # pull request runs never write per-PR copies. - - name: Restore pre-commit cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + path: esphome + - name: Check out esphome/device-builder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - path: ~/.cache/pre-commit - # Must match the key pre-commit-seed-cache saves - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Run pre-commit - env: - SKIP: pylint,ci-custom + repository: esphome/device-builder + ref: main + path: device-builder + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Set up uv + # Mirrors the install shape device-builder's own CI uses + # (esphome/device-builder#192): uv replaces pip for the + # install step (order-of-magnitude faster on cold boots, + # with its own wheel cache). actions/setup-python still + # provides the interpreter. + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} + # Pin uv version so the action does not have to fetch the + # manifest from raw.githubusercontent.com on every cache + # miss; that fetch flakes on Windows runners. + version: "0.11.15" + - name: Install device-builder + esphome from PR + # Install device-builder with its esphome + test extras + # first so its pinned versions of pytest/etc. land, then + # overlay the PR's esphome so the downstream tests run + # against this PR's Python code. ``--system`` installs into + # the runner's Python instead of a venv. run: | - python -m pip install pre-commit - pre-commit run --show-diff-on-failure --color=always --all-files - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - if: always() + uv pip install --system -e './device-builder[esphome,test]' + uv pip install --system -e ./esphome + - name: Run device-builder pytest + # ``-n auto`` runs under pytest-xdist (matches device-builder's + # own CI). No ``--cov`` here -- this is purely a downstream + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. + working-directory: device-builder + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow memory-impact-target-branch: name: Build target branch for memory impact @@ -1094,7 +1133,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.base_ref }} @@ -1276,7 +1315,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1345,7 +1384,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1379,21 +1418,27 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-24.04 + # Listed in the same order the jobs are defined above. One job is + # deliberately left out: "benchmarks" reports through CodSpeed rather than + # this check. needs: - common + - determine-jobs - ci-custom - pylint + - lint-format - pytest + - codecov-empty-upload - integration-tests + - import-time + - cpp-unit-tests - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split - clang-tidy-esp32-variants - - determine-jobs - - device-builder - test-build-components-split - test-esp32-platformio - - pre-commit-ci-lite + - device-builder - memory-impact-target-branch - memory-impact-pr-branch - memory-impact-comment diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 9b1333734e..bb1d1e2d7a 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index da9c5f63d6..38a4b8ff0e 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e718b481e0..4e164cd9f6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,11 +52,11 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 5e70117652..ec736a2002 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 + uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 2bb6505b74..3a89c26cd3 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63067ab4b..10b28ace38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -20,9 +31,11 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else @@ -60,9 +84,9 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Build @@ -70,7 +94,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true @@ -92,9 +116,9 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -102,12 +126,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -168,7 +192,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -182,13 +206,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7003f6c482..3c471b6efb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,61 +6,46 @@ on: - cron: "30 0 * * *" workflow_dispatch: -permissions: - issues: write # actions/stale labels, comments on, and closes stale issues - pull-requests: write # actions/stale labels, comments on, and closes stale pull requests - -concurrency: - group: lock +# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN +# needs no permissions at all. +permissions: {} jobs: stale: if: github.repository_owner == 'esphome' - runs-on: ubuntu-latest - steps: - - name: Stale - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - with: - debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch - remove-stale-when-updated: true - operations-per-run: 400 + # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome + # GitHub App token so the labels, comments and closures come from + # esphome[bot] instead of github-actions[bot]. + uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main + secrets: + ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + with: + # Live only on dev: a workflow_dispatch from any other branch is a dry run + dry-run: ${{ github.ref != 'refs/heads/dev' }} + days-before-stale: 90 + days-before-close: 7 + stale-label: stale + exempt-label: not-stale + ignored-users: esphbot,codecov-commenter + stale-pr-message: > + There hasn't been any activity on this pull request recently. This + pull request has been automatically marked as stale because of that + and will be closed if no further activity occurs within 7 days. - # The 90 day stale policy for PRs - # - PRs - # - No PRs marked as "not-stale" - # - No Issues (see below) - days-before-pr-stale: 90 - days-before-pr-close: 7 - stale-pr-label: "stale" - exempt-pr-labels: "not-stale" - stale-pr-message: > - There hasn't been any activity on this pull request recently. This - pull request has been automatically marked as stale because of that - and will be closed if no further activity occurs within 7 days. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. - If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev - branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + stale-issue-message: > + There hasn't been any activity on this issue recently. Due to the + high number of incoming GitHub notifications, we have to clean some + of the old issues, as many of them have already been resolved with + the latest updates. - Thank you for your contribution! + Please make sure to update to the latest ESPHome version and + check if that solves the issue. Let us know if that works for you by + adding a comment 👍 - # The 90 day stale policy for Issues - # - Issues - # - No Issues marked as "not-stale" - # - No PRs (see above) - days-before-issue-stale: 90 - days-before-issue-close: 7 - stale-issue-label: "stale" - exempt-issue-labels: "not-stale" - stale-issue-message: > - There hasn't been any activity on this issue recently. Due to the - high number of incoming GitHub notifications, we have to clean some - of the old issues, as many of them have already been resolved with - the latest updates. - - Please make sure to update to the latest ESPHome version and - check if that solves the issue. Let us know if that works for you by - adding a comment 👍 - - This issue has now been marked as stale and will be closed if no - further activity occurs. Thank you for your contributions. + This issue has now been marked as stale and will be closed if no + further activity occurs. Thank you for your contributions. diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index d27cc0cbec..72987c25b1 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -5,7 +5,7 @@ on: types: [opened, reopened, labeled, unlabeled, synchronize] permissions: - pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr) + pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr) concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -20,7 +20,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; + const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2f350d09b3..a299e76584 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,26 +28,26 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout Home Assistant - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: home-assistant/core path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" - name: Set up uv # An order of magnitude faster than pip on cold boots, with its # own wheel cache. ``--system`` (below) installs into the - # setup-python interpreter so subsequent ``pre-commit`` / + # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -58,19 +58,19 @@ jobs: - name: Install Home Assistant run: | uv pip install --system -e lib/home-assistant - uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -r requirements.txt -r requirements_test.txt - name: Sync run: | python ./script/sync-device_class.py - - name: Apply pre-commit auto-fixes + - name: Apply prek auto-fixes # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify - # files. pre-commit exits non-zero whenever a hook touches anything, + # files. prek exits non-zero whenever a hook touches anything, # which would otherwise abort the workflow before the auto-fixes # can flow into the sync PR. # - # SKIP: + # PREK_SKIP: # - no-commit-to-branch is a local guard against committing on # dev/release/beta; CI runs on dev by definition, and # peter-evans/create-pull-request creates the branch itself. @@ -79,25 +79,25 @@ jobs: # the runtime deps (HA + requirements*.txt); main CI already # gates pylint on real PRs. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files || true + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files || true - - name: Verify pre-commit clean + - name: Verify prek clean # Second pass: re-run all hooks against the now-fixed tree. # Auto-fixers exit 0 (nothing to change); any remaining failure # from a check-only hook (flake8 / yamllint / ci-custom) is a - # real issue and fails the workflow loudly. Same SKIP list as + # real issue and fails the workflow loudly. Same PREK_SKIP list as # above for the same reasons. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" - committer: esphomebot - author: esphomebot + committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> + author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> branch: sync/device-classes delete-branch: true title: "Synchronise Device Classes from Home Assistant" diff --git a/.gitignore b/.gitignore index de3e4fa68e..fdb75824fb 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,8 @@ CTestTestfile.cmake .gcc-flags.json config/ +# Test fixture config/ directories are tracked (the rule above is the dashboard dir) +!tests/component_tests/**/config/ tests/build/ tests/.esphome/ /.temp-clang-tidy.cpp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da424f516f..99a4f40201 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.15 + rev: v0.16.0 hooks: # Run the linter. - id: ruff diff --git a/AGENTS.md b/AGENTS.md index 75a9cdb2bf..fa0f61c263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Enumerator names: prefix every value of an `enum class` with the enum name converted to + `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare + names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with + these common names (for example the Realtek SDKs used by LibreTiny define + `#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator + before the compiler sees it, breaking the build and clang-tidy on those platforms. * **Python Idioms:** * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: @@ -191,11 +197,14 @@ This document provides essential context for AI models interacting with this pro my_component_ns = cg.esphome_ns.namespace("my_component") MyComponent = my_component_ns.class_("MyComponent", cg.Component) - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Required(CONF_KEY): cv.string, - cv.Optional(CONF_PARAM, default=42): cv.int_, - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Required(CONF_KEY): cv.string, + cv.Optional(CONF_PARAM, default=42): cv.int_, + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -229,7 +238,12 @@ This document provides essential context for AI models interacting with this pro - **Sensor:** ```python from esphome.components import sensor - CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s")) + + CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend( + cv.polling_component_schema("60s") + ) + + async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -238,7 +252,10 @@ This document provides essential context for AI models interacting with this pro - **Binary Sensor:** ```python from esphome.components import binary_sensor - CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... }) + + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...}) + + async def to_code(config): var = await binary_sensor.new_binary_sensor(config) ``` @@ -246,7 +263,10 @@ This document provides essential context for AI models interacting with this pro - **Switch:** ```python from esphome.components import switch - CONFIG_SCHEMA = switch.switch_schema().extend({ ... }) + + CONFIG_SCHEMA = switch.switch_schema().extend({...}) + + async def to_code(config): var = await switch.new_switch(config) ``` @@ -263,10 +283,13 @@ This document provides essential context for AI models interacting with this pro ```python from esphome import automation - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Optional(CONF_ON_STATE): automation.validate_automation({}), - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -316,11 +339,14 @@ This document provides essential context for AI models interacting with this pro ```python TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template()) - CONFIG_SCHEMA = cv.Schema({ - cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} - ), - }) + CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} + ), + } + ) + async def to_code(config): for conf in config.get(CONF_ON_TURN_ON, []): @@ -368,7 +394,10 @@ This document provides essential context for AI models interacting with this pro ``` Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. +* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + * **Configuration Validation:** + * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`. @@ -381,6 +410,7 @@ This document provides essential context for AI models interacting with this pro .extend(i2c.i2c_device_schema(0x48)) .extend(spi.spi_device_schema(cs_pin_required=True)) ``` + * **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check. ## 5. Key Files & Entrypoints @@ -388,7 +418,7 @@ This document provides essential context for AI models interacting with this pro * **Configuration:** * `pyproject.toml`: Defines the Python project metadata and dependencies. * `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. - * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting. + * `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`. * **CI/CD Pipeline:** Defined in `.github/workflows`. * **Static Analysis & Development:** * `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms. @@ -396,7 +426,7 @@ This document provides essential context for AI models interacting with this pro ## 6. Development & Testing Workflow * **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`. -* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`. +* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`. * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. @@ -469,9 +499,9 @@ This document provides essential context for AI models interacting with this pro 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. - 4. **Lint:** Run `pre-commit` to ensure code is compliant. + 4. **Lint:** Run `prek` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome.io` repository. @@ -617,6 +647,7 @@ This document provides essential context for AI models interacting with this pro _component_state = [] _use_feature = None + def enable_feature(): global _use_feature _use_feature = True @@ -636,20 +667,24 @@ This document provides essential context for AI models interacting with this pro DOMAIN = "my_component" + @dataclass class MyComponentData: feature_enabled: bool = False item_count: int = 0 items: list[str] = field(default_factory=list) + def _get_data() -> MyComponentData: if DOMAIN not in CORE.data: CORE.data[DOMAIN] = MyComponentData() return CORE.data[DOMAIN] + def request_feature() -> None: _get_data().feature_enabled = True + def add_item(item: str) -> None: _get_data().items.append(item) ``` @@ -704,10 +739,22 @@ This document provides essential context for AI models interacting with this pro ``` * **Deprecation Pattern (Python):** + For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates: + ```python + CONFIG_SCHEMA = cv.All( + cv.rename_key( + CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" + ), + cv.Schema({ ... }), + ) + ``` + For other deprecations, warn manually during validation: ```python # Remove before 2026.6.0 if CONF_OLD_KEY in config: - _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") + _LOGGER.warning( + f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0" + ) config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` ## 9. English Language diff --git a/CODEOWNERS b/CODEOWNERS index 0f43cd9749..9ddbca5c71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,12 +69,16 @@ esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 +esphome/components/bk72xx_ble/* @Bl00d-B0b +esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @dan-s-github @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow +esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_nus/* @tomaszduda23 +esphome/components/bluetooth_connection/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core @@ -145,6 +149,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its +esphome/components/ds248x/* @tomwellnitz esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M @@ -233,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 @@ -284,6 +290,7 @@ esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu esphome/components/ld24xx/* @kbx81 +esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter esphome/components/libretiny/* @kuba2k2 esphome/components/libretiny_pwm/* @kuba2k2 @@ -291,6 +298,8 @@ esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lm75b/* @beormund +esphome/components/ln882h_ble/* @Bl00d-B0b +esphome/components/ln882h_ble_tracker/* @Bl00d-B0b esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core @@ -345,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff esphome/components/mmc5983/* @agoode +esphome/components/modbus_client/* @exciton esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/number/* @martgras @@ -433,6 +443,7 @@ esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz +esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex @@ -455,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt @@ -623,6 +635,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 +esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt diff --git a/Doxyfile b/Doxyfile index d4dd0a8d26..006f97acb7 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.7.4 +PROJECT_NUMBER = 2026.8.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/docker/Dockerfile b/docker/Dockerfile index ebce522454..a4f5d3c3a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 27bb64a4df..0ac5898268 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2,39 +2,33 @@ import argparse from collections.abc import Callable from contextlib import suppress -from datetime import datetime import functools -import getpass import importlib import logging import os from pathlib import Path import re -import shutil -import subprocess import sys import time from typing import Protocol -import argcomplete - # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. -from esphome import const -import esphome.codegen as cg -from esphome.config import iter_component_configs, read_config, strip_default_ids +from esphome import const, platform_hooks from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, + BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, CONF_DISABLED, + CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -48,10 +42,12 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_ESP32, + KEY_VARIANT, SECRETS_FILES, Toolchain, ) @@ -59,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType from esphome.upload_targets import PortType, get_port_type from esphome.util import ( @@ -276,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -317,9 +314,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -331,7 +331,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -393,7 +397,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -486,10 +490,23 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" - from esphome.components.mqtt import CONF_DISCOVER_IP - if CONF_MQTT not in CORE.config: return False # Default Enabled @@ -622,6 +639,8 @@ def _resolve_network_devices( def run_miniterm(config: ConfigType, port: str, args) -> int: + from datetime import datetime + from aioesphomeapi import LogParser import serial @@ -634,18 +653,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - process_stacktrace = None - - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = module.process_stacktrace - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - backtrace_state = False + # Decoder resolution, crash isolation, and disable-after-failure + # all live in LogLineProcessor, shared with the API log path. + processor = LogLineProcessor(config, CORE.target_platform) ser = serial.Serial() ser.baudrate = baud_rate ser.port = port @@ -685,11 +695,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: "utf8", "backslashreplace" ) safe_print(parser.parse_line(line, time_str)) - - if process_stacktrace is not None: - backtrace_state = process_stacktrace( - config, line, backtrace_state - ) + processor.process_line(line) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 @@ -704,6 +710,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: def _wrap_to_code(name, comp, yaml_util): + import esphome.codegen as cg + coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) @@ -739,6 +747,7 @@ def write_cpp(config: ConfigType) -> int: def generate_cpp_contents(config: ConfigType) -> None: from esphome import yaml_util + from esphome.config import iter_component_configs _LOGGER.info("Generating C++ source...") @@ -931,9 +940,10 @@ def upload_using_esptool( mcu = "esp8266" if CORE.is_esp32: - from esphome.components.esp32 import get_esp32_variant - - mcu = get_esp32_variant().lower() + # Same lookup as esp32.get_esp32_variant(), read directly so the + # serial upload path does not import the esp32 package; both the + # validator and the warm-cache apply_to_core populate this key. + mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower() line_callbacks: list[Callable[[str], str | None]] = [] if ( @@ -987,6 +997,8 @@ def upload_using_esptool( def upload_using_platformio(config: ConfigType, port: str) -> int: + import shutil + from esphome.platformio import toolchain # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for @@ -1024,6 +1036,8 @@ def upload_using_picotool(config: ConfigType) -> int: the mass storage copy approach that causes "disk not ejected properly" warnings on macOS. """ + import subprocess + from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -1130,6 +1144,8 @@ def check_permissions(port: str): "the USB cable can be used for data and is not a power-only cable." ) if not (os.access(port, os.R_OK | os.W_OK)): + import getpass + raise EsphomeError( "You do not have read or write permission on the selected serial port. " "To resolve this issue, you can add your user to the dialout group " @@ -1142,12 +1158,11 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if module.upload_program(config, args, host): - return 0, host - except AttributeError: - pass + platform_upload = platform_hooks.get_platform_hook( + CORE.target_platform, "upload_program" + ) + if platform_upload is not None and platform_upload(config, args, host): + return 0, host port_type = get_port_type(host) @@ -1298,25 +1313,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1407,12 +1420,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if module.show_logs(config, args, devices): - return 0 - except AttributeError: - pass + platform_show_logs = platform_hooks.get_platform_hook( + CORE.target_platform, "show_logs" + ) + if platform_show_logs is not None and platform_show_logs(config, args, devices): + return 0 if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -1430,7 +1442,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if has_api() and ( network_devices := _resolve_network_devices(devices, config, args) ): - from esphome.components.api.client import run_logs + from esphome.api_client import run_logs return run_logs( config, @@ -1445,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") @@ -1464,6 +1483,7 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util + from esphome.config import strip_default_ids if getattr(args, "no_defaults", False): user_config = getattr(config, "user_config", None) @@ -1713,7 +1733,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator + from esphome.bundle import ConfigBundleCreator creator = ConfigBundleCreator(config) @@ -1948,7 +1968,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1961,7 +1981,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -2008,7 +2028,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -2026,7 +2048,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2036,7 +2058,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2044,7 +2066,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -2052,7 +2074,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -2061,7 +2083,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -2087,7 +2109,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 @@ -2498,7 +2520,12 @@ def parse_args(argv): # a deprecation warning). arguments = argv[1:] - argcomplete.autocomplete(parser) + # argcomplete only does anything when the shell-completion machinery + # invokes us with _ARGCOMPLETE set; skip the import otherwise. + if "_ARGCOMPLETE" in os.environ: + import argcomplete + + argcomplete.autocomplete(parser) if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: args, unknown_args = parser.parse_known_args(arguments) @@ -2509,6 +2536,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2527,6 +2597,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: @@ -2558,10 +2629,11 @@ def run_esphome(argv): return 0 # Bundle support: if the configuration is a .esphomebundle, extract it - # and rewrite conf_path to the extracted YAML config. - from esphome.bundle import is_bundle_path, prepare_bundle_for_compile + # and rewrite conf_path to the extracted YAML config. The suffix check + # stays inline so the ordinary run never imports esphome.bundle. + if conf_path.name.lower().endswith(BUNDLE_EXTENSION): + from esphome.bundle import prepare_bundle_for_compile - if is_bundle_path(conf_path): _LOGGER.info("Extracting config bundle %s...", conf_path) conf_path = prepare_bundle_for_compile(conf_path) # Update the argument so downstream code sees the extracted path @@ -2597,9 +2669,13 @@ def run_esphome(argv): ) if config is None: + from esphome.config import read_config + config = read_config( command_line_substitutions, skip_external_update=skip_external, + # Snapshot only needed by `esphome config --no-defaults`. + snapshot_user_config=getattr(args, "no_defaults", False), ) # Refresh the cache so the next upload/logs hits the fast path # instead of re-running read_config. Skip when the storage diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 4fbceb7e5e..ab20e4d076 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -20,6 +20,7 @@ from . import ( RAM_SECTIONS, MemoryAnalyzer, ) +from .toolchain import find_elf_path, find_idedata_path, idedata_candidates if TYPE_CHECKING: from . import ComponentMemory @@ -759,45 +760,25 @@ def main(): print(f"Error: {build_path} is not a directory", file=sys.stderr) sys.exit(1) - # Find firmware.elf - elf_file = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - ]: - if elf_candidate.exists(): - elf_file = str(elf_candidate) - break - - if not elf_file: - print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr) + elf_path = find_elf_path(build_path) + if not elf_path: + print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr) sys.exit(1) - - # Find idedata.json - check current directory first, then home - device_name = build_path.name - idedata_candidates = [ - Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json", - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - ] + elf_file = str(elf_path) idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) if not idedata: - print( - f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})", - file=sys.stderr, - ) + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) analyzer.analyze() diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index a724d52f25..19041ac807 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [ ] +def find_elf_path(build_path: Path) -> Path | None: + """Locate the firmware ELF inside an ESPHome build directory. + + The layout depends on the toolchain that produced the build, so try each + known one in turn. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the ELF file, or None if no known layout matches + """ + name = build_path.name + for candidate in ( + # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # to build/firmware.elf (see espidf.toolchain.create_elf_copy) + build_path / "build" / "firmware.elf", + # PlatformIO + build_path / "firmware.elf", + build_path / ".pioenvs" / name / "firmware.elf", + # LibreTiny uses raw_firmware.elf + build_path / "raw_firmware.elf", + build_path / ".pioenvs" / name / "raw_firmware.elf", + # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2 + build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf", + build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf", + ): + if candidate.is_file(): + return candidate + return None + + +def idedata_candidates(build_path: Path) -> list[Path]: + """Return the idedata locations searched for a build directory, in order. + + Exposed so a caller reporting "not found" can name the paths it tried + without keeping its own copy of the list. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + The candidate idedata JSON paths, most specific first + """ + name = build_path.name + return [ + # In .pioenvs for test builds + build_path / ".pioenvs" / name / "idedata.json", + # Both toolchains cache it in the data dir, which holds this build dir: + # /idedata/.json next to /build/ + build_path.parent.parent / "idedata" / f"{name}.json", + # Regular builds, invoked from the config dir or from anywhere + Path.cwd() / ".esphome" / "idedata" / f"{name}.json", + Path.home() / ".esphome" / "idedata" / f"{name}.json", + ] + + +def find_idedata_path(build_path: Path) -> Path | None: + """Locate the idedata JSON belonging to an ESPHome build directory. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the idedata JSON, or None if it was not found + """ + for candidate in idedata_candidates(build_path): + if candidate.is_file(): + return candidate + return None + + def _find_in_platformio_packages(tool_name: str) -> str | None: """Search for a tool in PlatformIO package directories. diff --git a/esphome/api_client.py b/esphome/api_client.py new file mode 100644 index 0000000000..a75f219b17 --- /dev/null +++ b/esphome/api_client.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +import logging +from typing import TYPE_CHECKING, Any +import warnings + +# Suppress protobuf version warnings +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*Protobuf gencode version.*" + ) + from aioesphomeapi import APIClient, parse_log_message + from aioesphomeapi.log_runner import async_run + +from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__ +from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor +from esphome.util import safe_print + +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import ( + SubscribeLogsResponse, # pylint: disable=no-name-in-module + ) + + +_LOGGER = logging.getLogger(__name__) + + +async def async_run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, +) -> None: + """Run the logs command in the event loop.""" + from datetime import datetime + + conf = config["api"] + name = config["esphome"]["name"] + port: int = int(conf[CONF_PORT]) + noise_psk: str | None = None + if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): + noise_psk = key + + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + + cli = APIClient( + addresses[0], # Primary address for compatibility + port, + "", # Password auth removed in 2026.1.0 + client_info=f"ESPHome Logs {__version__}", + noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, + ) + + # Decoder resolution policy lives in LogLineProcessor. + processor = LogLineProcessor(config, CORE.target_platform) + + def on_log(msg: SubscribeLogsResponse) -> None: + """Handle a new log message.""" + time_ = datetime.now().astimezone() + message: bytes = msg.message + text = message.decode("utf8", "backslashreplace") + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): + # safe_print handles the dashboard \033 escaping and falls back + # to backslashreplace encoding on stdouts that can't represent + # the wifi signal-bar block characters (Windows redirected + # cp1252 pipe). + safe_print(parsed_msg) + for raw_line in text.splitlines(): + processor.process_line(raw_line) + + # 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, + # A top-level ``deep_sleep:`` block means the device is only awake + # briefly; cap the reconnect backoff so a wake window is not missed. + deep_sleep="deep_sleep" in config, + ) + try: + await asyncio.Event().wait() + finally: + await stop() + + +def run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, +) -> None: + """Run the logs command.""" + with suppress(KeyboardInterrupt): + asyncio.run( + async_run_logs(config, addresses, subscribe_states=subscribe_states) + ) diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 3972d735f5..3296d65af6 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -11,43 +11,136 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from itertools import count +import logging import threading +from typing import cast + +_LOGGER = logging.getLogger(__name__) + +# How long the orphan watcher waits for an abandoned coroutine before giving +# up, so a hung operation does not park a watcher thread forever. +ORPHAN_WAIT_TIMEOUT = 300.0 + + +_runner_ids = count(1) + + +class AsyncDispatchTimeout(TimeoutError): + """The caller stopped waiting; the coroutine was abandoned. + + A subclass so callers can tell the dispatcher's own expiry apart from a + ``TimeoutError`` raised inside the coroutine, while existing + ``except TimeoutError`` handlers keep working. + """ class AsyncThreadRunner[T](threading.Thread): """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 + ``event`` is always set, even when the coroutine crashes, so waiters + never hang; ``completed`` distinguishes a delivered result (even a + legitimate ``None``) from a coroutine that never finished. Prefer + :func:`run_async`; use this class directly only when a failure should + degrade to a default value instead of raising. """ def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: - super().__init__(daemon=True) + super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}") self._coro_factory = coro_factory self.result: T | None = None self.exception: BaseException | None = None + self.completed = False self.event = threading.Event() async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - # Capture all exceptions so ``event`` is always set — otherwise a - # crash would hang the waiter forever. + # Distinguishes a delivered result from "never ran", since None + # is a valid result value. + self.completed = True + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # Capture everything, including BaseException — otherwise a + # cancellation or SystemExit would leave ``exception`` unset and + # waiters would mistake the empty ``result`` for success. self.exception = exc finally: self.event.set() def run(self) -> None: - asyncio.run(self._runner()) + try: + asyncio.run(self._runner()) + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # asyncio.run itself can fail before _runner executes (e.g. loop + # creation under fd exhaustion); record it so waiters never hang. + # A failure during loop cleanup after the coroutine completed + # must not clobber the delivered result, hence the guard. + if self.exception is None and not self.completed: + self.exception = exc + else: + _LOGGER.debug( + "Event loop teardown failed after outcome recorded", + exc_info=True, + ) + finally: + self.event.set() + + +def run_async[T]( + coro_factory: Callable[[], Awaitable[T]], + timeout: float | None = None, + on_orphan: Callable[[T], None] | None = None, +) -> T: + """Run a coroutine in a daemon-thread event loop and return its result. + + Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish + within ``timeout`` seconds; the thread is abandoned and exits with the + interpreter. If the abandoned coroutine later produces a result, + ``on_orphan`` (if given) is called with it so resources such as a + connected socket can be released; delivery is best effort and bounded + by ``ORPHAN_WAIT_TIMEOUT``. + """ + runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory) + runner.start() + if not runner.event.wait(timeout): + + def _cleanup() -> None: + if not runner.event.wait(ORPHAN_WAIT_TIMEOUT): + # The one state where a resource can genuinely leak; leave + # a trace so a recurring hang is attributable. + _LOGGER.info( + "Orphan watcher gave up after %.0fs; a late result may leak", + ORPHAN_WAIT_TIMEOUT, + ) + return + if not runner.completed: + # The only place an abandoned thread's real error surfaces; + # without it a late failure hides behind the TimeoutError. + # INFO, not DEBUG: it fires at most once per abandoned + # operation and the cause may not reproduce on a rerun. + _LOGGER.info( + "Abandoned async operation failed", + exc_info=runner.exception, + ) + return + if (result := runner.result) is None: + return + if on_orphan is None: + _LOGGER.debug("Discarding late result; no on_orphan handler") + return + try: + on_orphan(result) + except Exception: # pylint: disable=broad-except + # INFO, not DEBUG: a failed release means a real leak, and + # it fires at most once per abandoned operation. + _LOGGER.info("Error releasing orphaned result", exc_info=True) + + threading.Thread( + target=_cleanup, daemon=True, name="async-orphan-cleanup" + ).start() + raise AsyncDispatchTimeout("Timed out waiting for async operation") + if (exc := runner.exception) is not None: + raise exc + if not runner.completed: + raise RuntimeError("Async operation finished without a result or an exception") + return cast("T", runner.result) diff --git a/esphome/bundle.py b/esphome/bundle.py index dcaea03646..b633c5ca4f 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -20,6 +20,7 @@ from typing import Any from esphome import const, yaml_util from esphome.const import ( + BUNDLE_EXTENSION, CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_INCLUDES, @@ -29,12 +30,12 @@ from esphome.const import ( CONF_TYPE, ) from esphome.core import CORE, EsphomeError +from esphome.util import filter_yaml_files _LOGGER = logging.getLogger(__name__) DOMAIN = "bundle" -BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB @@ -128,6 +129,9 @@ class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) + # Directories whose YAML files are scanned for !secret references but + # never bundled, e.g. git package checkouts the builder re-fetches. + secret_scan_dirs: set[Path] = field(default_factory=set) # Original config dir parsed from an extracted bundle's manifest.json, # kept in the path flavor of the machine the bundle was created on. # The checked flag makes the manifest lookup happen at most once per run; @@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None: _get_data().extra_files.append(CORE.relative_config_path(path)) +def add_secret_scan_dir(path: Path) -> None: + """Register a directory to scan for ``!secret`` references when bundling. + + The directory's files are not added to the bundle. Components call this + for YAML the build consumes without bundling it — such as git-fetched + packages, which the builder re-fetches — so the secrets those files + reference are still shipped in the filtered secrets file. + + A relative path is taken as relative to the config directory. + """ + if not path.is_absolute(): + path = CORE.relative_config_path(path) + _get_data().secret_scan_dirs.add(path) + + +def _secret_scan_yaml_files() -> list[Path]: + """Return the YAML files inside registered secret-scan directories.""" + return filter_yaml_files( + f + for scan_dir in _get_data().secret_scan_dirs + for f in yaml_util.find_files(scan_dir, "*") + ) + + # Windows paths start with a drive letter or contain backslashes; POSIX # paths do neither in practice, so this is how the flavor of a recorded # path string is recognized on any host. @@ -310,6 +338,7 @@ class ConfigBundleCreator: yaml_sources = [ bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") ] + yaml_sources.extend(_secret_scan_yaml_files()) used_secret_keys = _find_used_secret_keys(yaml_sources) filtered_secrets = self._build_filtered_secrets(used_secret_keys) @@ -394,6 +423,13 @@ class ConfigBundleCreator: """ discovered = yaml_util.discover_user_yaml_files(self._config_path) self._secrets_paths.update(discovered.secrets) + # A !secret inside a file this re-parse does not reach (for example + # a git-fetched package the builder re-fetches) still resolves + # against the config-dir secrets.yaml at build time, so always + # consider that file; filtering no-ops when no key matches. + default_secrets = self._config_dir / yaml_util.SECRET_YAML + if default_secrets.is_file(): + self._secrets_paths.add(default_secrets.resolve()) config_resolved = self._config_path.resolve() for fpath in discovered.files: if fpath == config_resolved: @@ -719,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: ) -def is_bundle_path(path: Path) -> bool: - """Check if a path looks like a bundle file.""" - return path.name.lower().endswith(BUNDLE_EXTENSION) - - def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: """Add in-memory bytes to a tar archive with deterministic metadata.""" info = tarfile.TarInfo(name=name) diff --git a/esphome/codegen.py b/esphome/codegen.py index 56a47d146e..2430f17f3a 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -49,10 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401 build_registry_entry, build_registry_list, extract_registry_entry_config, + get_slot_count, gpio_pin_expression, past_safe_mode, register_component, register_parented, + set_setup_priority, + slot_counter, ) from esphome.cpp_types import ( # noqa: F401 NAN, diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index f4fd205285..303af99e66 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,48 +1,69 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +compile dumps the validated config to /storage/.validated.json; the next upload/logs for that YAML reuses it instead of running the full -read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. """ from __future__ import annotations +import json import logging from pathlib import Path +from typing import Any -from esphome.core import CORE +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, Lambda from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + def compiled_config_path(config_filename: str) -> Path: """Path to the cached validated config alongside the storage sidecar.""" - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: - """True iff the cache file exists and isn't older than the source.""" - try: - return cache_path.stat().st_mtime >= source_path.stat().st_mtime - except OSError: - return False + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" def save_compiled_config(config: ConfigType) -> None: """Write the validated-config cache. Always-write so mtime stays fresh. - Mode 0600 because show_secrets=True resolves !secret inline. + Mode 0600 because config validation resolved !secret inline. Failures are non-fatal: the fast path falls back to read_config. """ - from esphome import yaml_util - try: - rendered = yaml_util.dump(config, show_secrets=True) + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # non-basic dict key), so every upload/logs pays the slow path. + _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -51,18 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. Returns None (caller falls back to read_config) when the cache is - missing, older than the source YAML, unparseable, or the sidecar - is incomplete. + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): return None - from esphome import yaml_util - try: - config = yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # noqa: BLE001 # pylint: disable=broad-except + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object + ) + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) @@ -74,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage.apply_to_core() return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 555d511f6e..1c50b6b81b 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -231,6 +231,7 @@ def validate_adc_pin(value): return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") + # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0 if str(value).upper() == "TEMPERATURE": return cv.only_on_rp2("TEMPERATURE") diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 6cb9ef113f..8652a46029 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -19,6 +19,25 @@ namespace esphome::adc { static const char *const TAG = "adc.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif + void ADCSensor::setup() { static bool initialized = false; if (!initialized) { @@ -52,7 +71,7 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - adc_select_input(4); + adc_select_input(TEMPERATURE_ADC_INPUT); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 86e2b771ab..b2a4382a21 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -10,8 +10,8 @@ from esphome.components.esp32 import ( from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, + zephyr_add_overlay_builder, zephyr_add_prj_conf, - zephyr_add_user, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -67,6 +67,13 @@ def validate_config(config): # Alter value here so `config` command prints the recommended change config[CONF_ATTENUATION] = _attenuation("12db") + # Remove before 2027.2.0 + if config[CONF_PIN] == "TEMPERATURE": + _LOGGER.warning( + "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` " + "sensor platform instead. Will be removed in 2027.2.0" + ) + return config @@ -113,6 +120,18 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" +def _overlay_io_channels(): + channel_count = CORE.data[CONF_ADC_CHANNEL_ID] + entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) + return f""" + / {{ + zephyr,user {{ + io-channels = {entries}; + }}; + }}; + """ + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -121,6 +140,7 @@ async def to_code(config): if config[CONF_PIN] == "VCC": cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": + # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) @@ -173,9 +193,8 @@ async def to_code(config): if isinstance(pin_number, int): GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} pin_number = GPIO_TO_AIN[pin_number] - zephyr_add_user("io-channels", f"<&adc {channel_id}>") - zephyr_add_overlay( - f""" + zephyr_add_overlay_builder(_overlay_io_channels) + zephyr_add_overlay(f""" &adc {{ #address-cells = <1>; #size-cells = <0>; @@ -190,8 +209,7 @@ async def to_code(config): zephyr,oversampling = <8>; }}; }}; - """ - ) + """) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 1545110798..d0cb7631d2 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( - "AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener + "AirthingsListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(AirthingsListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("airthings_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AirthingsListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 881b3e297b..f2625a7832 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -2,15 +2,13 @@ #include "esphome/core/log.h" #include -#ifdef USE_ESP32 - namespace esphome::airthings_ble { static const char *const TAG = "airthings_ble"; -bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { - if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) { + if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) { if (it.data.size() < 4) continue; @@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 8105ac32eb..8fdfeb972f 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::airthings_ble { -class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0719cee352..8ec94df1db 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_ENCRYPTION, CONF_EVENT, CONF_ID, CONF_KEY, @@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { for name, t in _SERVICE_ARG_SCALAR_TYPES.items() }, } -CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4b3df62ec4..f1bc9b003a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -19,6 +19,7 @@ service APIConnection { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { option (needs_authentication) = false; } + rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -243,6 +244,12 @@ message SerialProxyInfo { // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) +// +// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They +// have moved to that message as of API 1.15, but are still sent here so that +// older clients keep working. Do NOT mark them (deprecated) until the removal +// release: in this repo (deprecated) makes the generator drop the field +// entirely, so the device would stop sending it. message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -280,6 +287,8 @@ message DeviceInfoResponse { // Deprecated in API version 1.9 uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; + + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; string manufacturer = 12 [(max_data_length) = 20, (force) = true]; @@ -288,11 +297,14 @@ message DeviceInfoResponse { // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; + + // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15. uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15. string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key @@ -305,10 +317,13 @@ message DeviceInfoResponse { AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; // Indicates if Z-Wave proxy support is available and features supported + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; // Serial proxy instance metadata + // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15. repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; // Device is unprovisioned and accepts Noise handshakes with the well-known @@ -317,6 +332,63 @@ message DeviceInfoResponse { bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } +// ==================== DEVICE CAPABILITIES ==================== + +// Asks the device which optional features it supports. +// +// This message exists so that DeviceInfoResponse does not have to keep growing +// a flat list of feature flags. DeviceInfoResponse is served before +// authentication, so it is limited to identity information. Capabilities are +// only served on an authenticated connection (encrypted as well, when +// encryption is configured). +// +// Clients that see api_version >= 1.15 should read these values from +// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields. +// Older clients keep reading DeviceInfoResponse, which still carries the same +// values, so this is not a breaking change. +message DeviceCapabilitiesRequest { + option (id) = 149; + option (source) = SOURCE_CLIENT; + // Empty +} + +// Each feature gets its own sub-message so that it can gain fields over time +// without crowding the top-level field numbering. +// +// Note: a sub-message whose fields are all at their default value is not sent +// at all, so the presence of a sub-message is not a reliable test for "this +// feature is compiled in". Clients should test a value inside it, for example +// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse. + +message BluetoothProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + string mac_address = 2 [(max_data_length) = 17, (force) = true]; +} + +message VoiceAssistantCapabilities { + // Bitmask of the features this voice assistant supports + uint32 feature_flags = 1; +} + +message ZWaveProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + uint32 home_id = 2; +} + +message DeviceCapabilitiesResponse { + option (id) = 150; + option (source) = SOURCE_SERVER; + + BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; + ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + repeated SerialProxyInfo serial_proxies = 4 + [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; +} + message ListEntitiesRequest { option (id) = 11; option (source) = SOURCE_CLIENT; @@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType { message BluetoothDeviceRequest { option (id) = 68; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; BluetoothDeviceRequestType request_type = 2; @@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest { message BluetoothDeviceConnectionResponse { option (id) = 69; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool connected = 2; @@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse { message BluetoothGATTGetServicesRequest { option (id) = 70; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1754,7 +1826,7 @@ message BluetoothGATTService { message BluetoothGATTGetServicesResponse { option (id) = 71; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; repeated BluetoothGATTService services = 2; @@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesDoneResponse { option (id) = 72; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTReadRequest { option (id) = 73; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest { message BluetoothGATTReadResponse { option (id) = 74; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse { message BluetoothGATTWriteRequest { option (id) = 75; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest { message BluetoothGATTReadDescriptorRequest { option (id) = 76; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest { message BluetoothGATTWriteDescriptorRequest { option (id) = 77; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTNotifyRequest { option (id) = 78; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyDataResponse { option (id) = 79; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse { message SubscribeBluetoothConnectionsFreeRequest { option (id) = 80; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; } message BluetoothConnectionsFreeResponse { option (id) = 81; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint32 free = 1; uint32 limit = 2; @@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse { message BluetoothGATTErrorResponse { option (id) = 82; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse { message BluetoothGATTWriteResponse { option (id) = 83; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse { message BluetoothGATTNotifyResponse { option (id) = 84; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse { message BluetoothDevicePairingResponse { option (id) = 85; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool paired = 2; @@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse { message BluetoothDeviceUnpairingResponse { option (id) = 86; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest { message BluetoothDeviceClearCacheResponse { option (id) = 88; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse { message BluetoothSetConnectionParamsRequest { option (id) = 145; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 min_interval = 2; // units of 1.25ms @@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsResponse { option (id) = 146; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; int32 error = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 880b7cc404..d05f98d03b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -23,6 +23,7 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING @@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what) { + esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"), + LOG_STR_ARG(what)); +} +#endif #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; #endif @@ -440,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_object_id_hash(); + msg.key = entity->get_entity_key(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -451,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_object_id_hash(); + msg.key = entity->get_entity_key(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -794,6 +802,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); // Current feature flags and other supported parameters msg.feature_flags = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); @@ -1140,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = camera::Camera::instance()->get_entity_key(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES @@ -1234,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request( void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } @@ -1267,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() { } } +void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { + bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); +} +#endif + void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } -void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); -} #endif #ifdef USE_VOICE_ASSISTANT @@ -1328,7 +1340,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno } } -bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { +bool APIConnection::send_voice_assistant_get_configuration_response_( + const VoiceAssistantConfigurationRequest & /*msg*/) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { // send_message encodes synchronously, so this stack local outlives the encode @@ -1348,22 +1361,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice } } - // Filter external wake words - for (auto &wake_word : msg.external_wake_words) { - if (wake_word.model_type != "micro") { - // microWakeWord only - continue; - } - - resp.available_wake_words.emplace_back(); - auto &resp_wake_word = resp.available_wake_words.back(); - resp_wake_word.id = StringRef(wake_word.id); - resp_wake_word.wake_word = StringRef(wake_word.wake_word); - for (const auto &lang : wake_word.trained_languages) { - resp_wake_word.trained_languages.push_back(lang); - } - } - resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp); @@ -1468,6 +1465,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec msg.target_temperature_step = traits.get_target_temperature_step(); msg.supported_modes = &traits.get_supported_modes(); msg.supported_features = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); return fill_and_encode_entity_info(wh, msg, conn, remaining_size); } @@ -1545,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + if (!this->send_message(msg)) { + // V: fires per decoded frame with no subscription gate, so a warning + // would flood the congested link it reports on. + ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); + } +} #endif #ifdef USE_SERIAL_PROXY @@ -1556,8 +1560,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure static_cast(proxies.size())); return; } - proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, - msg.data_size); + proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast(msg.parity), + msg.stop_bits, msg.data_size); } void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { @@ -1566,7 +1570,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } - proxies[msg.instance]->write_from_client(msg.data, msg.data_len); + proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len); } void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { @@ -1575,7 +1579,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } - proxies[msg.instance]->set_modem_pins(msg.line_states); + proxies[msg.instance]->set_modem_pins(this, msg.line_states); } void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { @@ -1587,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; resp.line_states = proxies[msg.instance]->get_modem_pins(); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } } void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { @@ -1619,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { resp.status = enums::SERIAL_PROXY_STATUS_ERROR; break; } - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } break; } default: @@ -1628,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { } } -void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + if (!this->send_message(msg)) { + ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); + } +} #endif #ifdef USE_INFRARED @@ -1746,15 +1758,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); - // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal) - if (!this->client_supports_api_version(1, 14)) { - ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(), - this->client_api_version_major_, this->client_api_version_minor_); - } - HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 15; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -1765,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Acknowledge the hello so the client can read the server name, then request // disconnect with the reason. Authentication is intentionally not completed. this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Hello response"); + } DisconnectRequest req; req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; return this->send_message(req); @@ -1790,9 +1798,8 @@ bool APIConnection::send_device_info_response_() { #ifdef USE_AREAS resp.suggested_area = StringRef(App.get_area()); #endif - // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char mac_address[18]; - uint8_t mac[6]; + char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); @@ -1868,8 +1875,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif @@ -1923,6 +1929,35 @@ bool APIConnection::send_device_info_response_() { return this->send_message(resp); } +bool APIConnection::send_device_capabilities_response_() { + // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks + // below in sync with send_device_info_response_() until those copies are removed. + DeviceCapabilitiesResponse resp; +#ifdef USE_BLUETOOTH_PROXY + resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); + resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac); +#endif +#ifdef USE_VOICE_ASSISTANT + resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); +#endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id(); +#endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + } +#endif + return this->send_message(resp); +} void APIConnection::on_hello_request(const HelloRequest &msg) { if (!this->send_hello_response_(msg)) { this->on_fatal_error(); @@ -1944,6 +1979,11 @@ void APIConnection::on_device_info_request() { this->on_fatal_error(); } } +void APIConnection::on_device_capabilities_request() { + if (!this->send_device_capabilities_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { @@ -2022,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.call_id = call_id; resp.success = success; resp.error_message = error_message; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, @@ -2033,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.error_message = error_message; resp.response_data = response_data; resp.response_data_len = response_data_len; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES +bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) { + if (!this->flags_.service_call_subscription) + return false; + if (!this->send_message(call)) { + API_LOG_MSG_DROPPED(TAG, "Action request"); + } + return true; +} +#endif // USE_API_HOMEASSISTANT_SERVICES + +#ifdef USE_HOMEASSISTANT_TIME +void APIConnection::send_time_request() { + GetTimeRequest req; + if (!this->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Time request"); + } +} +#endif // USE_HOMEASSISTANT_TIME + #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON @@ -2113,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { if (this->helper_->can_write_without_blocking()) return true; if (log_out_of_space) { - ESP_LOGV(TAG, "Cannot send message because of TCP buffer space"); + // VV: refusals are either reported by the sending call site (naming what + // was lost) or retried without loss (the deferred batch), so this generic + // line only duplicates them. + ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space"); } return false; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7df7ea1429..bb51a13000 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -25,6 +25,7 @@ #include "esphome/components/esp8266/crash_handler.h" #endif #include "esphome/core/entity_base.h" +#include "esphome/core/log.h" #include "esphome/core/string_ref.h" #include @@ -40,6 +41,16 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +// One shared flash string for every refused-frame warning: send_message() +// fails as soon as the TCP buffer is full, and each caller only pays for its +// short name. The guard drops the helper and its arguments below WARN. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what); +#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what)) +#else +#define API_LOG_MSG_DROPPED(tag, what) +#endif + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase { // Returns whether this client has subscribed to Home Assistant actions; the message // is only handed to the send path when subscribed. A true return does not guarantee // delivery - it lets the caller warn when no connected client has the subscription. - bool send_homeassistant_action(const HomeassistantActionRequest &call) { - if (!this->flags_.service_call_subscription) - return false; - this->send_message(call); - return true; - } + bool send_homeassistant_action(const HomeassistantActionRequest &call); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_unsubscribe_bluetooth_le_advertisements_request(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); @@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase { void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_subscribe_bluetooth_connections_free_request(); - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg); +#endif + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); #endif #ifdef USE_HOMEASSISTANT_TIME - void send_time_request() { - GetTimeRequest req; - this->send_message(req); - } + void send_time_request(); #endif #ifdef USE_VOICE_ASSISTANT @@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase { void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); + void on_device_capabilities_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase { // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); - template bool send_message(const T &msg) { + /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we + /// have no silent failures: every caller must handle (or log) a refusal. + template [[nodiscard]] bool send_message(const T &msg) { if constexpr (T::ESTIMATED_SIZE == 0) { return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); } else { @@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase { bool send_disconnect_response_(); bool send_ping_response_(); bool send_device_info_response_(); + bool send_device_capabilities_response_(); #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool send_subscribe_bluetooth_connections_free_response_(); #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9cae6ba92e..9c49956bbd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -149,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -312,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 190bd32425..1b8c6b05bd 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif return size; } +#ifdef USE_BLUETOOTH_PROXY +uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + return pos; +} +uint32_t BluetoothProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += 2 + this->mac_address.size(); + return size; +} +#endif +#ifdef USE_VOICE_ASSISTANT +uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + return pos; +} +uint32_t VoiceAssistantCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + return size; +} +#endif +#ifdef USE_ZWAVE_PROXY +uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + return pos; +} +uint32_t ZWaveProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, this->home_id); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_BLUETOOTH_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); +#endif +#ifdef USE_VOICE_ASSISTANT + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); +#endif +#ifdef USE_ZWAVE_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + } +#endif + return pos; +} +uint32_t DeviceCapabilitiesResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_BLUETOOTH_PROXY + size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); +#endif +#ifdef USE_VOICE_ASSISTANT + size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); +#endif +#ifdef USE_ZWAVE_PROXY + size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + size += ProtoSize::calc_message_force(1, it.calculate_size()); + } +#endif + return size; +} #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); @@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: @@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::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->state)); @@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { return size; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4d5866da0b..8335dae1f2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, @@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, }; +#endif +#ifdef USE_BLUETOOTH_PROXY enum BluetoothScannerState : uint32_t { BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_STARTING = 1, @@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + StringRef mac_address{}; + 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_VOICE_ASSISTANT +class VoiceAssistantCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{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_ZWAVE_PROXY +class ZWaveProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint32_t home_id{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 +class DeviceCapabilitiesResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 102; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } +#endif +#ifdef USE_BLUETOOTH_PROXY + BluetoothProxyCapabilities bluetooth_proxy{}; +#endif +#ifdef USE_VOICE_ASSISTANT + VoiceAssistantCapabilities voice_assistant{}; +#endif +#ifdef USE_ZWAVE_PROXY + ZWaveProxyCapabilities zwave_proxy{}; +#endif +#ifdef USE_SERIAL_PROXY + std::array serial_proxies{}; +#endif + 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: +}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; @@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; @@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; @@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 145; diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,7 +3,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_BLUETOOTH_PROXY +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 09570b09e4..4d5829e45d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string(enu } } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS template<> const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) { switch (value) { @@ -606,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet return ESPHOME_PSTR("UNKNOWN"); } } +#endif +#ifdef USE_BLUETOOTH_PROXY template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) { switch (value) { case enums::BLUETOOTH_SCANNER_STATE_IDLE: @@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } +#ifdef USE_BLUETOOTH_PROXY +const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); + return out.c_str(); +} +#endif +#ifdef USE_VOICE_ASSISTANT +const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif +#ifdef USE_ZWAVE_PROXY +const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("home_id"), this->home_id); + return out.c_str(); +} +#endif +const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); +#ifdef USE_BLUETOOTH_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": "); + this->bluetooth_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_VOICE_ASSISTANT + out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": "); + this->voice_assistant.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_ZWAVE_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": "); + this->zwave_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); + it.dump_to(out); + out.append("\n"); + } +#endif + return out.c_str(); +} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); @@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const } return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); @@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error"), this->error); return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state)); @@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 5c9df433dd..65c7b8858c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothDeviceRequest::MESSAGE_TYPE: { BluetoothDeviceRequest msg; msg.decode(msg_data, msg_size); @@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { BluetoothGATTGetServicesRequest msg; msg.decode(msg_data, msg_size); @@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadRequest::MESSAGE_TYPE: { BluetoothGATTReadRequest msg; msg.decode(msg_data, msg_size); @@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteRequest::MESSAGE_TYPE: { BluetoothGATTWriteRequest msg; msg.decode(msg_data, msg_size); @@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTReadDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTWriteDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { BluetoothGATTNotifyRequest msg; msg.decode(msg_data, msg_size); @@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); @@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { BluetoothSetConnectionParamsRequest msg; msg.decode(msg_data, msg_size); @@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif + case 149 /* DeviceCapabilitiesRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_capabilities_request")); +#endif + this->on_device_capabilities_request(); + break; + } default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index d1b51f4846..6abdf7093e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,6 +27,8 @@ class APIServerConnectionBase { void on_ping_response(){}; void on_device_info_request(){}; + void on_device_capabilities_request(){}; + void on_list_entities_request(){}; void on_subscribe_states_request(){}; @@ -113,32 +115,32 @@ class APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_subscribe_bluetooth_connections_free_request(){}; #endif @@ -233,7 +235,7 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif }; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6e3448121c..ef5b43d7b1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -123,7 +123,9 @@ void APIServer::setup() { // Best-effort: if the send buffer is full the reason is dropped, but the // client still learns the window is closed when it reconnects (rejected at // hello) or via the socket close. - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -394,8 +396,11 @@ 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->active_clients()) - c->send_message(msg); + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } + } } #endif @@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); for (auto &c : this->active_clients()) { DisconnectRequest req; - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 3473deec83..5e1c88b2ca 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -1,174 +1,10 @@ -from __future__ import annotations +"""Backward-compatibility shim; the log client lives in esphome.api_client. -import asyncio -from datetime import datetime -import importlib -import logging -from typing import TYPE_CHECKING, Any -import warnings +Importing this module executes the whole api component package, which pulls +in the validation stack. CLI code paths should import esphome.api_client +directly so the logs fast path stays light. +""" -# Suppress protobuf version warnings -with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*Protobuf gencode version.*" - ) - from aioesphomeapi import APIClient, parse_log_message - from aioesphomeapi.log_runner import async_run +from esphome.api_client import async_run_logs, run_logs -import contextlib - -from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE, EsphomeError -from esphome.util import safe_print - -from . import CONF_ENCRYPTION - -if TYPE_CHECKING: - from aioesphomeapi.api_pb2 import ( - SubscribeLogsResponse, # pylint: disable=no-name-in-module - ) - - -_LOGGER = logging.getLogger(__name__) - - -class _LogLineProcessor: - """Feeds incoming log lines to the stack-trace decoder. - - Two responsibilities beyond just calling the decoder: - 1. Catch EsphomeError. on_log runs inside an asyncio protocol - callback; if an exception escapes, the loop tears the transport - down with "Fatal error: protocol.data_received() call failed." - and ReconnectLogic immediately reconnects, the device replays - the same crash trace, and we loop forever. - 2. Disable decoding after the first failure. _decode_pc shells out - to PlatformIO via _run_idedata, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to - retry the failing subprocess for each one. - """ - - def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: - self._config = config - self._platform_handler = platform_handler - self._decode_enabled = True - self.backtrace_state = False - - def process_line(self, raw_line: str) -> None: - if not self._decode_enabled: - return - try: - if self._platform_handler is not None: - self.backtrace_state = self._platform_handler( - self._config, raw_line, self.backtrace_state - ) - except EsphomeError as exc: - self._decode_enabled = False - self.backtrace_state = False - # _run_idedata raises EsphomeError with no message; fall back - # to a generic explanation when str(exc) is empty. - detail = str(exc) or "build artifacts not found locally" - _LOGGER.warning( - "Crash trace decoding unavailable: %s. " - "Run 'esphome compile' for this device to enable PC decoding.", - detail, - ) - - -async def async_run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command in the event loop.""" - conf = config["api"] - name = config["esphome"]["name"] - port: int = int(conf[CONF_PORT]) - noise_psk: str | None = None - if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): - noise_psk = key - - if len(addresses) == 1: - _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) - else: - _LOGGER.info( - "Starting log output from %s using esphome API", " or ".join(addresses) - ) - - cli = APIClient( - addresses[0], # Primary address for compatibility - port, - "", # Password auth removed in 2026.1.0 - client_info=f"ESPHome Logs {__version__}", - noise_psk=noise_psk, - addresses=addresses, # Pass all addresses for automatic retry - provide_time=False, - ) - - # Try platform-specific stacktrace handler first, fall back to generic - platform_process_stacktrace = None - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = module.process_stacktrace - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - processor = _LogLineProcessor(config, platform_process_stacktrace) - - def on_log(msg: SubscribeLogsResponse) -> None: - """Handle a new log message.""" - time_ = datetime.now().astimezone() - message: bytes = msg.message - text = message.decode("utf8", "backslashreplace") - nanoseconds = time_.microsecond // 1000 - timestamp = ( - f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" - ) - for parsed_msg in parse_log_message(text, timestamp): - # safe_print handles the dashboard \033 escaping and falls back - # to backslashreplace encoding on stdouts that can't represent - # the wifi signal-bar block characters (Windows redirected - # cp1252 pipe). - safe_print(parsed_msg) - for raw_line in text.splitlines(): - processor.process_line(raw_line) - - # 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, - # A top-level ``deep_sleep:`` block means the device is only awake - # briefly; cap the reconnect backoff so a wake window is not missed. - deep_sleep="deep_sleep" in config, - ) - try: - await asyncio.Event().wait() - finally: - await stop() - - -def run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command.""" - with contextlib.suppress(KeyboardInterrupt): - asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) - ) +__all__ = ["async_run_logs", "run_logs"] diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 4b979ab406..17d434294a 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType") CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" +CONF_EXTENDED_RANGE = "extended_range" AQI_CALCULATION_TYPE = { "CAQI": AQICalculatorType.CAQI_TYPE, diff --git a/esphome/components/aqi/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h index 299962fa17..6b4c9c5e04 100644 --- a/esphome/components/aqi/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -6,7 +6,7 @@ namespace esphome::aqi { class AbstractAQICalculator { public: - virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0; + virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0; }; } // namespace esphome::aqi diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index bb8e402280..56b6069118 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -11,10 +11,12 @@ namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { - float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); - float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override { + float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range); + float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + // extended_range lets the index run past the standard maximum, so clamp to the sensor's range. + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } @@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator { {35.5f, 55.5f}, {55.5f, 125.5f}, {125.5f, 225.5f}, - {225.5f, std::numeric_limits::max()} + {225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3 // clang-format on }; @@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator { {155.0f, 255.0f}, {255.0f, 355.0f}, {355.0f, 425.0f}, - {425.0f, std::numeric_limits::max()} + {425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band) // clang-format on }; - static float calculate_index(float value, const float array[NUM_LEVELS][2]) { + static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) { int grid_index = get_grid_index(value, array); if (grid_index == -1) { return -1.0f; @@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; - return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + + // Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we + // clamp to the standard maximum; with extended_range we keep the extrapolated "over-range" + // value so heavy pollution reports numbers beyond what the standard defines. + if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) { + return aqi_hi; + } + return index; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it, + // and calculate_index() decides whether to clamp or extrapolate. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index 2d8a780cc7..4bb964d5ee 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -24,6 +24,7 @@ void AQISensor::setup() { void AQISensor::dump_config() { ESP_LOGCONFIG(TAG, "AQI Sensor:"); ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI"); + ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled"); if (this->pm_2_5_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); } @@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() { return; } - uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_); + uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_); this->publish_state(aqi); } diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index aa64fa5a4d..464c088188 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component { void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; } void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; } void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; } + void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; } protected: void calculate_aqi_(); @@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component { sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_10_0_sensor_{nullptr}; AQICalculatorType aqi_calc_type_{AQI_TYPE}; + bool extended_range_{false}; AQICalculatorFactory aqi_calculator_factory_; float pm_2_5_value_{NAN}; diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index 3f6da45aa9..56a98682d9 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -9,25 +9,28 @@ namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { + // The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We + // therefore always extrapolate the top band past 100 without limit, so the extended_range flag + // (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored. + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } protected: - static constexpr int NUM_LEVELS = 5; + static constexpr int NUM_LEVELS = 4; - static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; + static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}}; static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { // clang-format off {0.0f, 15.1f}, {15.1f, 30.1f}, {30.1f, 55.1f}, - {55.1f, 110.1f}, - {110.1f, std::numeric_limits::max()} + {55.1f, 110.1f} // clang-format on }; @@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator { {0.0f, 25.1f}, {25.1f, 50.1f}, {50.1f, 90.1f}, - {90.1f, 180.1f}, - {180.1f, std::numeric_limits::max()} + {90.1f, 180.1f} // clang-format on }; @@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; + // The top band is open-ended (see get_grid_index), so for concentrations above the last + // breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class. return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index 5842aea88c..9c361560df 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -8,14 +8,25 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, ) -from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns +from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["sensor"] AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = ( + +def _validate_extended_range(config): + if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI": + raise cv.Invalid( + f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. " + "CAQI has no maximum value by specification, so it is always reported unbounded.", + [CONF_EXTENDED_RANGE], + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( AQISensor, accuracy_decimals=0, @@ -29,9 +40,11 @@ CONFIG_SCHEMA = ( cv.Required(CONF_CALCULATION_TYPE): cv.enum( AQI_CALCULATION_TYPE, upper=True ), + cv.Optional(CONF_EXTENDED_RANGE): cv.boolean, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + _validate_extended_range, ) @@ -46,3 +59,4 @@ async def to_code(config): cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) + cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 7b5cdcfa20..22cb2b3150 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,8 +1,6 @@ #include "atc_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { static const char *const TAG = "atc_mithermometer"; @@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device return success; } -optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional &result, cons } } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 0f472c11b9..3f5ca4c784 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 5286d29d1b..5c2d75753c 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer") ATCMiThermometer = atc_mithermometer_ns.class_( - "ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("atc_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(ATCMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d87f32fc36..1c522cbb5d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -371,7 +371,7 @@ async def to_code(config): data.wav_support = True if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.2.0") + add_idf_component(name="esphome/micro-decoder", ref="0.4.0") # 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: @@ -380,6 +380,8 @@ async def to_code(config): 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) + # Vorbis is unsupported in ESPHome, so always disable it + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False) if not data.wav_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 160d22a5b6..e0ae824b0d 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,8 +1,6 @@ #include "b_parasite.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::b_parasite { static const char *const TAG = "b_parasite"; @@ -16,7 +14,7 @@ void BParasite::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index 1d5ac6e702..65540e82fb 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::b_parasite { -class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; } @@ -35,5 +33,3 @@ class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceL }; } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index 041303ad8b..673c5981b7 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, @@ -23,14 +23,15 @@ from esphome.const import ( CODEOWNERS = ["@rbaron"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] b_parasite_ns = cg.esphome_ns.namespace("b_parasite") BParasite = b_parasite_ns.class_( - "BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BParasite", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("b_parasite"), cv.Schema( { cv.GenerateID(): cv.declare_id(BParasite), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 4e22489844..9e14615d7a 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip { static const char *const TAG = "beken_spi_led_strip"; -struct spi_data_t { +struct SpiData { SemaphoreHandle_t dma_tx_semaphore; volatile bool tx_in_progress; bool first_run; }; -static spi_data_t *spi_data = nullptr; +static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static void set_spi_ctrl_register(unsigned long bit, bool val) { +static void set_spi_ctrl_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CTRL); if (val == 0) { value &= ~bit; @@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) { REG_WRITE(SPI_CTRL, value); } -static void set_spi_config_register(unsigned long bit, bool val) { +static void set_spi_config_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CONFIG); if (val == 0) { value &= ~bit; @@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) { void spi_dma_tx_enable(bool enable) { GDMA_CFG_ST en_cfg; - set_spi_config_register(SPI_TX_EN, enable ? 1 : 0); + set_spi_config_register(SPI_TX_EN, enable); en_cfg.channel = SPI_TX_DMA_CHANNEL; en_cfg.param = enable ? 1 : 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg); @@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) { param &= ~(SPI_CKR_MASK << SPI_CKR_POSI); param |= (div << SPI_CKR_POSI); REG_WRITE(SPI_CTRL, param); - ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div); + ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div); } void spi_dma_tx_finish_callback(unsigned int param) { spi_data->tx_in_progress = false; xSemaphoreGive(spi_data->dma_tx_semaphore); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); } void BekenSPILEDStripLightOutput::setup() { @@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() { return; } - spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t)); + spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) if (spi_data == nullptr) { ESP_LOGE(TAG, "Cannot allocate spi_data!"); this->mark_failed(); @@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() { spi_data->first_run = true; - set_spi_ctrl_register(MSTEN, 0); - set_spi_ctrl_register(BIT_WDTH, 0); + set_spi_ctrl_register(MSTEN, false); + set_spi_ctrl_register(BIT_WDTH, false); spi_set_clock(this->spi_frequency_); - set_spi_ctrl_register(CKPOL, 0); - set_spi_ctrl_register(CKPHA, 0); - set_spi_ctrl_register(MSTEN, 1); - set_spi_ctrl_register(SPIEN, 1); + set_spi_ctrl_register(CKPOL, false); + set_spi_ctrl_register(CKPHA, false); + set_spi_ctrl_register(MSTEN, true); + set_spi_ctrl_register(SPIEN, true); - set_spi_ctrl_register(TXINT_EN, 0); - set_spi_ctrl_register(RXINT_EN, 0); - set_spi_config_register(SPI_TX_FINISH_EN, 1); - set_spi_config_register(SPI_RX_FINISH_EN, 1); - set_spi_ctrl_register(RXOVR_EN, 0); - set_spi_ctrl_register(TXOVR_EN, 0); + set_spi_ctrl_register(TXINT_EN, false); + set_spi_ctrl_register(RXINT_EN, false); + set_spi_config_register(SPI_TX_FINISH_EN, true); + set_spi_config_register(SPI_RX_FINISH_EN, true); + set_spi_ctrl_register(RXOVR_EN, false); + set_spi_ctrl_register(TXOVR_EN, false); value = REG_READ(SPI_CTRL); value &= ~CTRL_NSSMD_3; @@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() { value = GFUNC_MODE_SPI_DMA; sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value); - set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0); + set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false); GDMA_CFG_ST en_cfg; GDMACFG_TPYES_ST init_cfg; @@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() { init_cfg.dstptr_incr = 0; init_cfg.srcptr_incr = 1; init_cfg.src_start_addr = this->dma_buf_; - init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO + init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO init_cfg.channel = SPI_TX_DMA_CHANNEL; init_cfg.prio = 0; // 10 init_cfg.u.type4.src_loop_start_addr = this->dma_buf_; @@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() { en_cfg.param = 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); value = REG_READ(SPI_CONFIG); value &= ~(0xFFF << 8); @@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { // protect from refreshing too often uint32_t now = micros(); - if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) { + if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 && + (now - this->last_refresh_) < *this->max_refresh_rate_) { // try again next loop iteration, so that this change won't get lost this->schedule_show(); return; @@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } spi_data->first_run = false; - spi_dma_tx_enable(1); + spi_dma_tx_enable(true); this->status_clear_warning(); } @@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() { " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, *this->max_refresh_rate_, this->num_leds_); + rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 3ffab0f3a5..ee9bf1e0d4 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..23f3d06184 --- /dev/null +++ b/esphome/components/bk72xx_ble/__init__.py @@ -0,0 +1,99 @@ +"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips. + +The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack +bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build +on this component and contain no SDK calls of their own. + +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 +(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, +not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken +BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only +for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail +with a clear #error. + +No framework patch is needed: the LibreTiny beken-72xx builder already compiles +and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; +prebuilt libble_.a per SoC). This component only calls into it via the +public ble_api.h. +""" + +import logging + +import esphome.codegen as cg +from esphome.components import libretiny +from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["bk72xx"] +CODEOWNERS = ["@Bl00d-B0b"] + +_LOGGER = logging.getLogger(__name__) + +bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble") +BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLE), + # Default off: on the single-core BK72xx, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value + # is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0 + # on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this + # SAME key — and add_platformio_option appends list values, it never replaces. + # The platform therefore skips its disable when this component is configured, + # so this =1 is the single CFG_SUPPORT_BLE define emitted. + cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"]) + + # Pin the Beken BDK release the BLE 5.x stack is validated against. The + # bundled 3.0.33 has an older BLE header/library layout — and with + # CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot + # (the reason the libretiny platform sets =0 when BLE is unused), so a + # mismatched BDK can crash the device before WiFi comes up regardless of + # enable_on_boot. Pinning here makes a plain config build against the + # validated BDK without any manual platformio_options. + _LOGGER.warning( + "bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled " + "default: the default's older BLE layout can crash the device at boot when " + "BLE is compiled in" + ) + cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78") + + # The BDK exposes the controller's BLE address as `common_default_bdaddr` on + # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is + # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ + # which path is available so it doesn't reference a missing symbol. + family = libretiny.get_libretiny_family() + if family == FAMILY_BK7231N: + cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") + elif family == FAMILY_BK7238: + # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at + # WiFi STA startup when BLE init runs. This component re-enables BLE, so + # warn loudly: BK7238 is accepted but not hardware-verified and may be + # WiFi-unstable with BLE on. + _LOGGER.warning( + "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " + "hang on this family and is not yet hardware-verified. Expect possible " + "instability." + ) + + cg.add_define("USE_BK72XX_BLE") diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp new file mode 100644 index 0000000000..bd4e51d9b7 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,119 @@ +// Every SDK call the scan reconciler makes. The BDK's own start hardcodes +// passive (the active bit is commented out in both stacks), so +// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field +// the SDK's app_ble_start_scaning() except that prop takes the mode, armed +// through the SDK's own operation bookkeeping. The component pins +// beken-bdk 3.0.78; the static asserts catch a layout change on a bump. + +#include "bdk_scan.h" + +#ifdef USE_BK72XX_BLE + +// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") + +extern "C" { +#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, + // app_ble_actv_state_get, app_ble_env_state_get, + // app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX, + // bk_ble_* (via ble_api_5_x.h) +#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send +#if __has_include("gapm_msg.h") +#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_* +#else +#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name +#endif +} + +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// Pin the SDK surface this file depends on: a beken-bdk bump that moves these +// must fail the build, not corrupt the kernel message. +static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) && + sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4, + "beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() " + "against the SDK's app_ble_start_scaning()"); +static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX, + "beken-bdk activity sentinel changed; revalidate the scan reconciler"); +static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 && + GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5), + "beken-bdk GAPM report info changed; revalidate the tracker's demux constants"); + +bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; } + +BdkActivityState bdk_scan_state(uint8_t activity_idx) { + if (activity_idx == INVALID_ACTIVITY_IDX) + return BdkActivityState::IDLE; + switch (app_ble_actv_state_get(activity_idx)) { + case ACTV_IDLE: + return BdkActivityState::IDLE; + case ACTV_SCAN_CREATED: + return BdkActivityState::CREATED; + case ACTV_SCAN_STARTED: + return BdkActivityState::STARTED; + default: + return BdkActivityState::OTHER; + } +} + +uint8_t bdk_scan_acquire_activity() { + uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (idx == INVALID_ACTIVITY_IDX) + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return idx; +} + +BdkOpResult bdk_scan_create(uint8_t activity_idx) { + ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + if (ret == ERR_BLE_STATUS) + return BdkOpResult::BUSY; + ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast(ret)); + return BdkOpResult::FAILED; +} + +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) { + app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr); + struct gapm_activity_start_cmd *cmd = + KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd); + if (cmd == nullptr) { + app_ble_reset(); // the SDK's own failure path for an unsent operation + ESP_LOGE(TAG, "Scan start failed: kernel message allocation"); + return BdkOpResult::FAILED; + } + cmd->operation = GAPM_START_ACTIVITY; + cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx; + cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER; + cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0); + cmd->u_param.scan_param.scan_param_1m.scan_intv = interval; + cmd->u_param.scan_param.scan_param_1m.scan_wd = window; + cmd->u_param.scan_param.scan_param_coded.scan_intv = 0; + cmd->u_param.scan_param.scan_param_coded.scan_wd = 0; + cmd->u_param.scan_param.dup_filt_pol = 0; + cmd->u_param.scan_param.rsvd = 0; + cmd->u_param.scan_param.duration = 0; // scan until stopped + cmd->u_param.scan_param.period = 10; // matches the SDK's passive start + kernel_msg_send(cmd); + return BdkOpResult::OK; +} + +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { + ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr); + *err_out = static_cast(ret); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + // DEBUG on purpose: the reconciler WARNs once per streak and the stuck + // ERROR carries this code — a per-retry ERROR would be unbounded. + ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast(ret)); + return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // !CLANG_TIDY && ble_api.h +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bdk_scan.h b/esphome/components/bk72xx_ble/bdk_scan.h new file mode 100644 index 0000000000..47bce2449d --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include + +namespace esphome::bk72xx_ble { + +/// Activity index value marking "no scan activity", the BDK's own convention +/// (asserted against its symbol in bdk_scan.cpp). +inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF; + +/// Scan-relevant controller activity states, read live from the SDK. +enum class BdkActivityState : uint8_t { + IDLE, ///< No activity (or one whose create failed). + CREATED, ///< Created but not started. + STARTED, ///< Scanning. + OTHER, ///< A non-scan or transitional state; settles on a later read. +}; + +/// Outcome of a BDK scan operation request. +enum class BdkOpResult : uint8_t { + OK, ///< Accepted; completion is asynchronous. + BUSY, ///< Another controller operation is in flight; retry later. + FAILED, ///< Rejected. +}; + +/// True when no controller operation is in flight (APP_BLE_READY). +bool bdk_scan_ready(); +/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE. +BdkActivityState bdk_scan_state(uint8_t activity_idx); +/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free. +uint8_t bdk_scan_acquire_activity(); +/// Create the scan activity (asynchronous); started once CREATED is observed. +BdkOpResult bdk_scan_create(uint8_t activity_idx); +/// Start a created activity: the packed GAPM start, taking the scan mode the +/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the +/// kernel message could not be allocated (the armed SDK operation is rolled +/// back). +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active); +/// Release the activity: delete when never started (a stop would be +/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED +/// on any other error; err_out receives the SDK code (0 on success). +/// Teardown is asynchronous — observe IDLE to confirm. +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out); + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp new file mode 100644 index 0000000000..d40f08d111 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -0,0 +1,515 @@ +// bk72xx_ble.cpp +// +// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx +// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that +// talks to the Beken BDK BLE stack: +// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), +// - the controller BLE address, +// - the scan reconciler (request, pacing, bring-up budget) over the +// bdk_scan surface, +// - the scan-report ring: the BDK notice callback (BLE task) takes a report +// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, +// dispatches on the main task and returns reports to the pool — the same +// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at +// steady state. +// Consumers contain no SDK calls of their own. +// +// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny +// beken-72xx builder itself (prebuilt libble_.a + ble_5_x sources, gated +// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only +// calls into it via the public ble_api.h — no framework patch is required. + +#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE + +#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release) + +#ifdef USE_BK72XX_BLE + +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// SDK-capability gate (not a chip allowlist). +// This component drives the Beken BLE *5.x* controller via its public API, +// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the +// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the +// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header +// itself so any BLE-5.x Beken chip — present or future — is supported without a +// hard-coded list, and a non-5.x build fails here with a clear message instead +// of a cryptic "ble_api.h: No such file or directory". +// --------------------------------------------------------------------------- +#if defined(CLANG_TIDY) +// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API +// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing +// accurate to analyze the SDK calls against — skip the file under analysis. +#define BK72XX_BLE_NO_SDK +#elif !__has_include("ble_api.h") +#error \ + "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." +#endif + +#ifndef BK72XX_BLE_NO_SDK + +// --------------------------------------------------------------------------- +// Beken BDK BLE 5.x SDK — public API. +// Exposed on the include path by the LibreTiny beken-72xx builder +// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these +// are C headers consumed from C++ (a standard C-header-from-C++ pattern). +// --------------------------------------------------------------------------- +extern "C" { +#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t, + // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp) +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR +#include "common_bt_defines.h" // struct bd_addr +// The controller's public BLE address, populated by the BDK during ble_entry(). +// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the +// address is derived from the WiFi MAC instead (matching the BDK's own fallback). +extern struct bd_addr common_default_bdaddr; +#endif +// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so +// declare it here. +void ble_entry(void); +} + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops +static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release +static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED +static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence +static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED) + +// The BDK notice callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// --------------------------------------------------------------------------- +// BLE notice callback — runs in the BDK BLE task context. +// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice +// carrying a recv_adv_t. Copy it into the queue and return; all dispatch +// happens in loop() on the main task. +// --------------------------------------------------------------------------- +static void ble_notice_callback(ble_notice_t notice, void *param) { + if (s_ble == nullptr || param == nullptr) + return; + if (notice != BLE_5_REPORT_ADV) + return; + + const recv_adv_t *info = reinterpret_cast(param); + // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for + // a signed dBm value packed in a uint8_t). + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, + static_cast(info->evt_type), info->data, info->data_len); +} + +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, + const uint8_t *data, uint16_t data_len) { + BLEScanReport *report = this->report_pool_.allocate(); + if (report == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + return; + } + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); + report->rssi = rssi; + report->addr_type = addr_type; + report->evt_type = evt_type; + report->data_len = + (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); + memcpy(report->data, data, report->data_len); + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLE::setup() { + s_ble = this; + // The report pool grows lazily on purpose: the BDK notice callback runs in + // task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic + // stays far below the pool cap, so not warming contains RAM. + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up (it is re-read once ble_entry() has run). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK +// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order). +float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void BK72xxBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + // One-time BLE stack init: register the notice callback, then bring up the + // BDK BLE stack. The BDK has no teardown path — init happens at most once. + ble_set_notice_cb(ble_notice_callback); + ble_entry(); + + delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time + + // Re-read the BLE MAC now that the controller is up (common_default_bdaddr is + // populated by ble_entry()); resolve_mac_() may have fallen back earlier. + this->resolve_mac_(); + +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // Liveness heuristic (BK7231N): a healthy ble_entry() populates + // common_default_bdaddr during init, so all-zero after the settle delay + // suggests the stack did not come up. The BDK entry point returns void — no + // return code exists — so warn rather than fail: scan starts against a dead + // stack already fail cleanly downstream (no idle activity handle). + bool bdaddr_live = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + bdaddr_live = true; + break; + } + } + if (!bdaddr_live) + ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started"); +#endif + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void BK72xxBLE::loop() { + // Keep reconciling toward the requested scan state (e.g. complete a stop + // that arrived while a controller operation was in flight), and re-check a + // settled scan at low frequency: a controller-side drop re-enters the + // bring-up, and the budget's FAILED feeds the tracker's recovery. + // Keep driving until settled: any PENDING, plus a terminal stop whose slot + // must still be freed. A FAILED scan request is the one combination not + // re-driven here — that belongs to the tracker's backoff. + const uint32_t pump_now = App.get_loop_component_start_time(); + if (this->last_result_ == ScanOpResult::PENDING || + (!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) { + const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED) + ? RECONCILE_REJECTED_RETRY_MS + : RECONCILE_RETRY_MS; + if (pump_now - this->last_advance_ms_ >= gate) + this->advance_(); + } else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED && + pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) { + // Re-check a settled scan; scan_start() refills the bring-up budget. + // WARN: the only report of a drop that recovers inside its budget. + if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) != + ScanOpResult::SETTLED) + ESP_LOGW(TAG, "Controller dropped the scan; restarting"); + } + + // Drain the lock-free ring filled by the BLE task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + // Log dropped reports — only reachable when reports were processed; drops can + // only occur while the queue is full, and only this loop drains it. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); +} + +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + for (int i = 0; i < 6; i++) + out[i] = this->ble_mac_[i]; +} + +void BK72xxBLE::dump_config() { + // ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the + // MSB-first order Home Assistant shows. + ESP_LOGCONFIG(TAG, + "BK72xx BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void BK72xxBLE::resolve_mac_() { +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention) + // during ble_entry(). It may still be zero before the stack is up; if so, fall + // through to the WiFi-derived MAC below. + bool nonzero = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + nonzero = true; + break; + } + } + if (nonzero) { + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); + return; + } +#endif + // Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other + // BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the + // Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte + // incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next + // byte), OUI unchanged. This reproduces the address the controller advertises with + // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it + // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment + // would carry differently. + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; + get_mac_address_raw(wifi_mac); // MSB-first + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + // Store LSB-first to match recv_adv_t adv_addr ordering. + for (int i = 0; i < 6; i++) + this->ble_mac_[i] = ble[5 - i]; +} + +// --------------------------------------------------------------------------- +// Scan reconciler +// --------------------------------------------------------------------------- + +// Episode boundary: fresh teardown deadline and error bookkeeping. +void BK72xxBLE::reset_teardown_episode_() { + this->teardown_since_ms_ = 0; + this->restarting_ = false; + this->last_release_err_ = 0; +} + +ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + const ScanParams params{active, interval, window}; + // A new episode refills the budget and gets a fresh teardown deadline; a + // re-call observing an in-flight bring-up (last result PENDING) must not. + if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) { + this->pending_since_ms_ = App.get_loop_component_start_time(); + this->reset_teardown_episode_(); + } + this->scan_wanted_ = true; + this->requested_ = params; + return this->advance_(); +} + +void BK72xxBLE::scan_stop() { + if (this->scan_wanted_) { + // A stamp inherited from a stuck restart would fail the stop on its + // first advance. + this->reset_teardown_episode_(); + } + this->scan_wanted_ = false; + this->advance_(); +} + +bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) { + // millis() on both sides: the loop clock is frozen while this blocks. + const uint32_t start = millis(); + while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) { + if (millis() - start >= timeout_ms) + return false; + delay(RECONCILE_RETRY_MS); + this->advance_(); + } + return this->last_result_ == ScanOpResult::SETTLED; +} + +// Teardown is asynchronous: the handle is kept until an IDLE observation +// confirms the radio is idle. A rejection WARNs once per failure streak and +// widens the pump gate; the epilogue owns the stuck-teardown deadline. +void BK72xxBLE::release_activity_(BdkActivityState state) { + const BdkOpResult result = + bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_); + if (result == BdkOpResult::OK) { + this->release_warned_ = false; + return; + } + if (!this->release_warned_) { + // A hard error carries its code immediately; the 30 s stuck ERROR follows + // if it persists. + if (result == BdkOpResult::FAILED) { + ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_); + } else { + ESP_LOGW(TAG, "Scan activity release rejected; retrying"); + } + this->release_warned_ = true; + } +} + +// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged +// each interval) and report stuck. +bool BK72xxBLE::teardown_stuck_(uint32_t now) { + if (this->teardown_since_ms_ == 0) { + this->teardown_since_ms_ = now; + this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline + return false; + } + if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS) + return false; + if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) { + if (this->last_release_err_ != 0) { + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_); + } else { + // No rejected release this episode: stuck waiting on the controller. + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)"); + } + this->teardown_stuck_log_ms_ = now; + } + return true; +} + +// One SDK operation per call toward the latched request; controller state is +// read live each time (it changes on the BLE task, so nothing is mirrored). +// The epilogue owns all deadlines and episode bookkeeping. +ScanOpResult BK72xxBLE::advance_() { + if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + // Nothing to do; also keeps SDK reads off the pre-enable() path. + this->last_result_ = ScanOpResult::SETTLED; + return ScanOpResult::SETTLED; + } + const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_); + const bool ready = bdk_scan_ready(); + ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready); + + const uint32_t now = App.get_loop_component_start_time(); + this->last_advance_ms_ = now; + if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) { + // Any teardown episode is over (IDLE observed with the controller + // settled, or e.g. a mode flip that settled back without ever reaching + // IDLE). An IDLE read while an operation is in flight proves nothing — + // a stop deferred there must keep its episode running. + this->reset_teardown_episode_(); + this->release_warned_ = false; + } + if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) { + // The mode-change release is observed complete; the rest is a normal + // bring-up on a fresh budget. + this->restarting_ = false; + this->pending_since_ms_ = now; + } + // Not chained to the clear above: a bring-up waiting at IDLE (create still + // in flight) must keep spending its budget. + if (result == ScanOpResult::PENDING) { + if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) { + // A downed radio spends the bring-up budget; exhausting it hands + // recovery to the tracker's backoff. + if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) { + ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start"); + result = ScanOpResult::FAILED; + } + } else { + // A teardown is pending: a stop, or a mode-change release still in + // flight (restarting_); either way the bring-up budget waits. + if (this->scan_wanted_) + this->pending_since_ms_ = now; + if (this->teardown_stuck_(now)) { + // Terminal for stop AND restart: the tracker's backoff owns recovery + // (a stop's release keeps re-driving from loop(); a restart is + // re-requested through scan_start() with a fresh deadline). + result = ScanOpResult::FAILED; + } + } + } + this->last_result_ = result; + return result; +} + +ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::IDLE && ready) { + // Fully torn down (or never created): the radio is idle. IDLE is trusted + // only when the controller is settled — mid-create the slot still reads + // IDLE, and dropping the handle then would leak the activity once the + // create lands. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::SETTLED; + } + if (!ready) { + // Acting mid-operation could delete an activity whose start lands + // afterwards, leaking the slot with the radio on; wait. + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan stop deferred (controller busy)"); + return ScanOpResult::PENDING; + } + // Settled, so CREATED unambiguously means "never started". + this->release_activity_(state); + return ScanOpResult::PENDING; // confirmed once IDLE is observed +} + +ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::STARTED) { + if (this->applied_ == this->requested_) + return ScanOpResult::SETTLED; + // Running with different mode or parameters: tear down (the SDK stop + // chain also deletes the activity) and recreate on a later advance. + if (ready) { + this->release_activity_(state); + // Invalidate so a flip back to the old params cannot SETTLE against the + // activity being deleted (interval 0 never matches a real request). + this->applied_.interval = 0; + this->restarting_ = true; + } + return ScanOpResult::PENDING; + } + if (!ready) { + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan start deferred (controller busy)"); + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::CREATED) { + // Fire-and-forget: SETTLED only once a later advance observes the scan + // running, so a rejected start is retried rather than silently dead. On + // failure the created activity is intact; keep the handle. + if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window, + this->requested_.active) != BdkOpResult::OK) + return ScanOpResult::FAILED; + this->applied_ = this->requested_; + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::OTHER) + return ScanOpResult::PENDING; // transitional; settles on a later read + + // IDLE and ready: acquire a slot and create. A kept index is deliberately + // reused: SDK delete returns the slot to idle and create requires an idle + // slot, so it equals a fresh acquire — while clearing here would orphan a + // create still in flight (the BUSY race below). + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + this->scan_activity_idx_ = bdk_scan_acquire_activity(); + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) + return ScanOpResult::FAILED; + } + switch (bdk_scan_create(this->scan_activity_idx_)) { + case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot + case BdkOpResult::OK: + return ScanOpResult::PENDING; + case BdkOpResult::FAILED: + break; + } + // Safe to clear (unlike BUSY): acquire is a pure search, so a rejected + // create leaves the slot IDLE for re-acquire. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // BK72XX_BLE_NO_SDK +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h new file mode 100644 index 0000000000..687fd396e4 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -0,0 +1,158 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include + +#include "bdk_scan.h" + +namespace esphome::bk72xx_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +/// Outcome of one reconciliation step. +enum class ScanOpResult : uint8_t { + SETTLED, ///< The request is reached: scan observed running, or stopped + ///< with the activity fully released. + PENDING, ///< A step is in flight; loop() keeps advancing — call + ///< scan_start() again to learn the outcome. + FAILED, ///< The controller rejected a step; retry later. +}; + +/// One scan request: mode plus timing, in BLE units (0.625 ms). +struct ScanParams { + bool active; + uint16_t interval; + uint16_t window; + bool operator==(const ScanParams &) const = default; +}; + +/// One advertisement report from the controller. +struct BLEScanReport { + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm + uint8_t addr_type; + // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type + // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the + // tracker's merger tell the two frames apart. + uint8_t evt_type; + uint8_t data_len; // bytes valid in data[] + uint8_t data[62]; // legacy advertisement (31) + scan response (31) + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the BDK BLE task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the BLE task and loop(). +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + +class BK72xxBLE final : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the BDK BLE stack (one-time; the BDK has no teardown path). + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address, least-significant octet first (BLE convention). + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; + +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Request a scan (interval/window in 0.625 ms BLE units); enables the + /// stack first if needed. PENDING until the scan is observed running — + /// loop() keeps advancing, call again to learn the outcome. + ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active); + /// Request the scanner stopped and the activity released; steps that + /// cannot run yet are completed from loop(). + void scan_stop(); + /// Drive a requested stop until the radio is observed idle, bounded by + /// timeout_ms (for OTA). Returns false if it still has not settled. + bool flush_pending_stop(uint32_t timeout_ms); + /// Last reconciliation outcome; on FAILED the consumer's retry policy owns + /// recovery. + ScanOpResult last_scan_result() const { return this->last_result_; } + + /// Internal: buffer one controller report (BDK notice callback, BLE task + /// context — bounded copy under the scheduler lock, nothing else). + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data, + uint16_t data_len); + + protected: + void resolve_mac_(); + ScanOpResult advance_(); + ScanOpResult advance_stop_(BdkActivityState state, bool ready); + ScanOpResult advance_start_(BdkActivityState state, bool ready); + bool teardown_stuck_(uint32_t now); + void reset_teardown_episode_(); + void release_activity_(BdkActivityState state); + +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the BDK notice callback (BLE task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; + // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. + uint32_t last_advance_ms_{0}; + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) + uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; + bool scan_wanted_{false}; // the latched request is to scan (vs stopped) + bool release_warned_{false}; // gates the release WARN; widens the pump gate + bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released + bool enable_on_boot_{false}; + // PENDING means advance_() has more to do; loop() drives it, paced and + // (for a bring-up) bounded. + ScanOpResult last_result_{ScanOpResult::SETTLED}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; +}; + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..96b3536601 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -0,0 +1,190 @@ +"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable +LibreTiny Beken chips (beken-72xx family). + +Builds on the bk72xx_ble controller component (stack bring-up, BLE address, +scan primitives) and implements the platform-neutral ble_device_base BLEHub +contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner, +bthome_mithermometer, xiaomi_*, …) bind to this tracker through +cv.use_id(BLEHub) with no BK-specific code. + +Scan modes: + continuous: true — scan runs forever; never stops automatically. + Use this when the radio is dedicated to BLE. + continuous: false — a started scan runs for `duration` ms, then stops. The + FIRST start is external too: nothing in this component + starts a non-continuous scan on boot — the radio stays + idle until bk72xx_ble_tracker.start_scan fires (e.g. + from an api client-connected automation), so the + single-core radio can service WiFi in between scans. +""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import bk72xx_ble, ble_device_base, ota +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID +from esphome.types import ConfigType + +CONF_BK72XX_BLE_ID = "bk72xx_ble_id" + +DEPENDENCIES = ["bk72xx"] +AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +ble_device_base.register_hub_provider("bk72xx_ble_tracker") + +bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker") +BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( + "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component +) + +StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + + +# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms +# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in +# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLETracker), + cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +@automation.register_action( + "bk72xx_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + # Optional with no default, unlike esp32_ble_tracker: omitting it + # keeps whatever scan_parameters.continuous configured, instead of + # silently forcing one-shot. + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "bk72xx_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + 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: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_BK72XX_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the BDK delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + bk72xx_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h new file mode 100644 index 0000000000..9017d19d71 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -0,0 +1,48 @@ +// Automation triggers and actions for bk72xx_ble_tracker: triggers are the +// neutral ble_device_base classes; only the scan-control actions are +// platform-specific. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::bk72xx_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp new file mode 100644 index 0000000000..1b4e6245ae --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -0,0 +1,378 @@ +// bk72xx_ble_tracker.cpp +// +// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period +// timers and the rate-limited start retry. All controller access (stack +// bring-up, scan primitives, the BLE-task → main-task report queue) goes +// through the bk72xx_ble component — no SDK calls and no cross-task state here. + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble_tracker { + +static const char *const TAG = "bk72xx_ble_tracker"; + +// Minimum interval between scan (re)start attempts, so a failing controller start +// cannot be retried every main-loop iteration (single-core CPU starvation). The +// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller +// that never comes up — the controller logs each failure at ERROR — settles into a +// slow, quiet poll instead of an error line every second for the rest of uptime; +// a single WARN is emitted when the retry interval first saturates. +static constexpr uint32_t SCAN_START_RETRY_MS = 1000; +static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s +// Stable-run time before the failure streak clears; reset-on-start would keep +// a flapping controller at the 1 s gate. +static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000; + +// Radio-idle deadline for the bounded stop drain at OTA start. +static constexpr uint32_t OTA_STOP_FLUSH_MS = 100; + +// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part. +constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; } + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // BLE task and delivers here on the main task. + this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; unclaimed + // devices are logged only on one-shot scans (continuous would spam). + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core BK72xx the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + // scan_requested_ check: an on_boot start_scan latched before this setup() + // must keep the retry loop running (rp2/ln882h parity). + if (!this->scan_continuous_ && !this->scan_requested_) { + // Nothing to time until an explicit start_scan(); it re-enables the loop. + this->disable_loop(); + } +} + +#ifdef USE_OTA_STATE_LISTENER +void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_requested_before_ota_ = this->scan_requested_; + this->stop_scan(); + // The transfer starves the loop; a deferred stop would leave the radio + // scanning for the whole update, so drain it here, bounded. + if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) + ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update"); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted update; + // loop() restarts the scan on its next iteration (continuous idle branch). + if (this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() parked it + } + // A one-shot request that was still pending (latched, retrying) when the + // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. + if (this->scan_requested_before_ota_) { + this->scan_requested_before_ota_ = false; + this->scan_requested_ = true; + this->enable_loop(); + } + } +} +#endif // USE_OTA_STATE_LISTENER + +void BK72xxBLETracker::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + + // Before the drop branch: a drop after a stable run starts a fresh streak. + if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS) + this->failed_start_count_ = 0; + + // A terminal failure while we report running recovers via the normal retry + // path; the drop charges the backoff so a flapping controller escalates. + if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) { + ESP_LOGW(TAG, "Controller scan lost; retrying"); + this->scan_requested_ = true; + this->count_failed_start_(); + this->mark_scan_ended_(now); + } + + if (this->scan_continuous_) { + if (!this->scan_running_) { + // One-iteration deferral; all stamps share this iteration's cached + // timestamp, so the period check below cannot underflow. + if (this->try_start_with_backoff_(now)) + return; + } + // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring + // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan + // that never came up (start kept failing) does not fire spurious on_scan_end events. + if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { + this->fire_scan_end_(); + this->scan_period_start_ = now; + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. api: on_client_connected:). + // + // A requested start that failed (same controller failures the continuous branch + // absorbs) is retried with the same backoff — otherwise a failed one-shot start + // would be silent: the scan never runs, stop_scan_() is never reached and + // on_scan_end() never fires, leaving period-keyed consumers waiting forever. + if (this->scan_requested_ && !this->scan_running_) { + // Same one-iteration deferral as the continuous branch. + if (this->try_start_with_backoff_(now)) + return; + } + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + // A full-duration run proves the controller healthy even when duration is + // shorter than SCAN_STABLE_RESET_MS. + this->failed_start_count_ = 0; + this->stop_scan_(); + } +} + +bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { + // Rate-limit (re)start attempts. The controller start can fail (no idle activity + // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every + // main-loop iteration would spin the single-core CPU and starve WiFi (device + // becomes unresponsive). The interval backs off with consecutive failures so a + // controller that never comes up polls slowly and quietly. + // + // force bypasses the gate for an explicit user start (start_scan()) — but + // only while the failure streak is clean. Once the controller is failing, + // even user-initiated attempts respect the backoff, so a start_scan() action + // on a short cadence cannot hammer a failing controller; the attempt stays + // inside the failure accounting below either way. + // Mid bring-up, observe instead of re-issuing (the hub self-advances). A + // SETTLED outcome completes immediately; only fresh attempts after FAILED + // are rate-limited. + const auto hub = this->parent_->last_scan_result(); + if (hub == bk72xx_ble::ScanOpResult::PENDING) + return false; + if (hub == bk72xx_ble::ScanOpResult::FAILED) { + if (this->start_attempt_open_) { + // Our bring-up gave up asynchronously; charge it to the backoff. + this->start_attempt_open_ = false; + this->count_failed_start_(); + } + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_)) + return false; + } + this->start_scan_(); + if (!this->scan_running_) { + if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) { + this->start_attempt_open_ = true; + return false; // the controller is still bringing the scan up; not a failure + } + this->count_failed_start_(); + } + return this->scan_running_; +} + +void BK72xxBLETracker::count_failed_start_() { + if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + ++this->failed_start_count_; + if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { + ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", + (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); + } + } +} + +void BK72xxBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "BK72xx BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s (configured %s)\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_, + ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Scan report — delivered by the controller's loop() on the ESPHome main task +// (the controller queues reports from the BLE task), so publish_state() and +// listener dispatch run in main-loop context with no cross-task handling here. +// --------------------------------------------------------------------------- + +// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type, +// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and +// 5.2 fill it from gapm_ext_adv_report_ind.info). +static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5; + +// Demux advertisements vs scan responses into the shared merger: the BDK +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK; + if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; + } + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); +} + +// --------------------------------------------------------------------------- +// Public scan control +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + // + // Nothing to do while a scan is already running: latching here would leave + // scan_requested_ set after that scan ends and silently restart a one-shot + // scan nobody asked for. + if (this->scan_running_) + return; + + // The request is latched: if this immediate attempt fails (controller busy, + // WiFi/BLE coexistence), loop() keeps retrying it with backoff even in + // non-continuous mode, so a one-shot start cannot fail silently. + // + // Routed through the backoff helper (forced: the user asked for an immediate + // attempt) so a failure here still counts toward the backoff escalation and + // its WARN. The force bypass only applies while the failure streak is clean — + // against a failing controller, repeated start_scan() calls are rate-limited + // like any other attempt. + this->scan_requested_ = true; + this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_() + this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true); +} + +void BK72xxBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = App.get_loop_component_start_time(); +} + +void BK72xxBLETracker::stop_scan() { + this->scan_continuous_ = false; + this->scan_requested_ = false; // also cancels a pending (not yet successful) start + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + +void BK72xxBLETracker::start_scan_() { + if (this->scan_running_) + return; + + if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED) + return; + + const uint32_t now = App.get_loop_component_start_time(); + this->scan_running_ = true; + this->scan_requested_ = false; // the latched one-shot request is satisfied + this->start_attempt_open_ = false; + // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()). + this->scan_start_time_ = now; + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)", + this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_), + ble_units_to_ms(this->scan_interval_)); + // Re-anchor the on_scan_end period to every successful start — first start (so the + // period counts from the scan, not from boot) and every restart after a stop (so + // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous + // mode 10 minutes later, does not fire on_scan_end before an advertisement can + // arrive). scan_started_once_ purely gates the period timer. + this->scan_period_start_ = now; + this->scan_started_once_ = true; +} + +// Deliberate logical/physical split: on_scan_end() reports the tracker's +// intent while the hub winds the radio down asynchronously; OTA is the one +// path that must wait, and it flushes explicitly. +void BK72xxBLETracker::stop_scan_() { + this->start_attempt_open_ = false; // an abandoned bring-up is not charged + this->parent_->scan_stop(); // idempotent: releases whatever the hub holds + if (this->scan_running_) { + ESP_LOGD(TAG, "Scan stopped"); + this->mark_scan_ended_(App.get_loop_component_start_time()); + } + // Park when idle (the hub drives its own teardown); re-check because an + // on_scan_end automation may have restarted the scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_) + this->disable_loop(); +} + +// The period re-anchor keeps on_scan_end from double-firing in one iteration. +void BK72xxBLETracker::mark_scan_ended_(uint32_t now) { + this->scan_running_ = false; + this->fire_scan_end_(); + this->scan_period_start_ = now; +} + +void BK72xxBLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +// true = request latched, not applied: the reconciler applies it +// asynchronously and loop() recovers a failed re-arm (ln882h parity). +bool BK72xxBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); + // The controller reconciler restarts a running scan itself; the scan stays + // logically running. An idle scanner picks the mode up on its next start. + if (this->scan_running_) + this->controller_scan_start_(); + return true; +} + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h new file mode 100644 index 0000000000..2334cfe414 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -0,0 +1,182 @@ +// bk72xx_ble_tracker.h +// +// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family). +// Implements the platform-neutral ble_device_base::BLEHub contract on top of the +// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered +// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the +// hub's raw-advertisement callback. +// +// This component contains no Beken SDK calls and no cross-task state: the +// controller (stack bring-up, BLE address, scan primitives, and the BLE-task → +// main-task report queue) is owned by bk72xx_ble, which delivers every scan +// report on the ESPHome main task. The tracker owns scan policy — parameters, +// duration/period timers and the rate-limited start retry. +// +// YAML config (values shown are the defaults; interval/window are a 30 % duty +// cycle, the BK reference scan rate): +// +// bk72xx_ble_tracker: +// scan_parameters: +// interval: 100ms +// window: 30ms +// duration: 5min +// continuous: true +// active: true + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/bk72xx_ble/bk72xx_ble.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::bk72xx_ble_tracker { + +// --------------------------------------------------------------------------- +// BK72xxBLETracker +// --------------------------------------------------------------------------- + +class BK72xxBLETracker : public Component, + public bk72xx_ble::BLEScanListener, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.active); runtime mode requests change + /// only the resolved mode. + void set_scan_active(bool scan_active) { + this->scan_active_ = scan_active; + this->scan_active_configured_ = scan_active; + } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { + this->dispatcher_.register_listener(listener); + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->dispatcher_.set_raw_advertisement_callback(callback); + } + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // Active scanning is driven through bk72xx_ble's reconciler because the BDK + // API itself is passive-only. The controller delivers scan responses as + // separate reports; this tracker merges the pair before delivery (shared + // ScanResponseMerger, Bluedroid semantics). No GATT client. + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; + } + bool request_scan_mode(bool active); + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + + // ---- bk72xx_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // BLE-task → main-task handoff already happened in the controller's queue. + void on_scan_report(const bk72xx_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + void fire_scan_end_(); + void mark_scan_ended_(uint32_t now); + /// Stamp-and-start for every controller scan attempt, so the retry rate + /// limit covers all callers. + bk72xx_ble::ScanOpResult controller_scan_start_(); + /// Rate-limited (re)start; true when the scan is running (the caller must + /// not reuse a `now` older than the stamps this refreshed). Force and + /// backoff rules are documented at the definition. + bool try_start_with_backoff_(uint32_t now, bool force = false); + void count_failed_start_(); + + bool scan_running_{false}; + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once + // Defaults: the BK reference — 30 % duty cycle + // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. + uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms + uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it + bool scan_active_{true}; // resolved mode; see scan_parameters.active + bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure +#endif + uint32_t scan_start_time_{0}; + + uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries + uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop()) + uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end() + bool scan_started_once_{false}; // true after first successful scan start; gates the period timer + + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time + // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this + // component's — same App.loop() pass, so the delta stays non-negative and + // the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 56ac2ea147..1ef7967fa8 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_MAC_ADDRESS, CONF_NAME, + CONF_NOTIFY, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_SERVICE_UUID, @@ -16,11 +17,46 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import ID +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +CONF_DESCRIPTOR_UUID = "descriptor_uuid" +CONF_ON_NOTIFY = "on_notify" + + +def validate_descriptor_not_notify(config: ConfigType) -> ConfigType: + """Reject descriptor_uuid combined with notify or on_notify. + + BLE descriptors cannot send notifications; only characteristics can, and + ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify. + """ + if CONF_DESCRIPTOR_UUID in config and ( + config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config + ): + raise cv.Invalid( + f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or " + f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove " + f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or " + f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor" + ) + return config + + +def notify_from_on_notify(config: ConfigType) -> ConfigType: + """Enable notifications when an on_notify automation is configured. + + The triggers have no registration path of their own; without notify the + automation would validate but never fire. + """ + if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]: + config = config.copy() + config[CONF_NOTIFY] = True + return config + + ble_client_ns = cg.esphome_ns.namespace("ble_client") BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) BLEClientNode = ble_client_ns.class_("BLEClientNode") diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index d41fb17961..25001c8f74 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -51,7 +51,9 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es for (auto *node : this->nodes_) node->gattc_event_handler(event, esp_gattc_if, param); - if (!this->services_.empty() && this->all_nodes_established_()) { + // The release frees the GATT cache that BLEClientBase's CCCD lookup still needs. + // The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here. + if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) { this->release_services(); ESP_LOGD(TAG, "All clients established, services released"); } diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index f27bef332b..f20df31816 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -34,6 +34,11 @@ class BLEClientNode { // This should be transitioned to Established once the node no longer needs // the services/descriptors/characteristics of the parent client. This will // allow some memory to be freed. + // The parent frees the peer's GATT cache once every node reports Established. + // Never report Established while an operation that reads that cache is outstanding. + // - esp_ble_gattc_register_for_notify() completes asynchronously. + // - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT. + // - BLEClientBase::register_for_notify() holds the release until the registration completes. espbt::ClientState node_state; BLEClient *parent() { return this->parent_; } diff --git a/esphome/components/ble_client/sensor/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 0975640ece..7764955d89 100644 --- a/esphome/components/ble_client/sensor/__init__.py +++ b/esphome/components/ble_client/sensor/__init__.py @@ -14,13 +14,16 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" TYPE_CHARACTERISTIC = "characteristic" TYPE_RSSI = "rssi" @@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All( }, lower=True, ), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 4bd871dc81..5dbb7e42ed 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); @@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } diff --git a/esphome/components/ble_client/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 0f53cccdad..820f60845d 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -9,13 +9,16 @@ from esphome.const import ( CONF_TRIGGER_ID, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" adv_data_t = cg.std_vector.template(cg.uint8) adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") @@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(ble_client.BLE_CLIENT_SCHEMA), + validate_descriptor_not_notify, + notify_from_on_notify, ) diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 7eaa6af076..ed2b0a63a0 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); @@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py new file mode 100644 index 0000000000..4da7d48882 --- /dev/null +++ b/esphome/components/ble_device_base/__init__.py @@ -0,0 +1,347 @@ +""" +ble_device_base — the platform-neutral BLE layer. + +Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / +ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract +(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h) +on every platform. + +BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the +configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared +subclass, so there is no Python platform table here and no dependency in +either direction (C++-side, the compile-time alias header ble_hub_impl.h and the +defines.h mirror are the deliberate exceptions). A sensor extends +BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a +declared key even on strict schemas) and calls register_ble_device() in +to_code; a tracker component declares BLEHub as its codegen-class parent and +MUST call register_hub_provider() at import time — without it _require_hub +rejects configs that bind through the generated id (an explicit ble_hub_id: +bypasses the registry). Adding a new BLE chip requires a new in-tree tracker +component plus its alias arm and define (see above); out-of-tree BLE hubs +are not supported. + +AES-CCM decryption for encrypted advertisements is provided portably in +ble_aes_ccm.h. +""" + +from collections.abc import Callable +import re + +import esphome.codegen as cg +from esphome.components.const import CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_INTERVAL, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE, ID, KEY_CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@Bl00d-B0b"] + +CONF_BLE_HUB_ID = "ble_hub_id" + +# Number of parsed-advertisement listeners registered in this build; read via +# cg.get_slot_count() by esp32_ble_tracker's feature coupling. +LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" + + +ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") + +# The neutral tracker contract. Every tracker's codegen class declares this as +# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python +# only: C++-side the name is a per-platform alias (ble_hub_impl.h). +BLEHub = ble_device_base_ns.class_("BLEHub") + +# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). +ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener") + + +# Config keys that provide a BLEHub, registered by each tracker component at +# import time (a tracker's module is imported iff it can end up in the build). +# Used only to phrase an actionable error when a BLE consumer is configured +# without any tracker — the binding itself resolves any BLEHub subclass and +# needs no platform table. Out-of-tree BLE hubs are not supported; the +# registry and the messages below deal in in-tree trackers only. +_HUB_PROVIDERS: set[str] = set() + +# The in-tree trackers per target platform, so the missing-tracker error names +# them even in a fresh process where no tracker module has been imported yet (a +# consumer imports only ble_device_base, so the registry is empty exactly in +# the most common failure: the tracker was simply forgotten). Filtered by the +# current platform so an esp32 config is not told to add a Beken tracker; an +# unknown/absent platform falls back to every in-tree name. +_IN_TREE_HUB_PROVIDERS: dict[str, str] = { + "esp32": "esp32_ble_tracker", + "bk72xx": "bk72xx_ble_tracker", + "rp2": "rp2_ble_tracker", + "ln882x": "ln882h_ble_tracker", +} + + +def register_hub_provider(component: str) -> None: + """Called at import time by every component whose config key declares a BLEHub.""" + _HUB_PROVIDERS.add(component) + + +def _require_hub(value: ID) -> ID: + # Without this check a missing tracker surfaces at ID resolution as + # "Couldn't find any component that can be used for 'ble_device_base::BLEHub'" + # — a C++ class name the user never types. Component final validation cannot + # phrase it better: the ID pass runs first and its error skips all later + # steps. All explicitly configured components are loaded before any schema + # validates, so a registered provider in loaded_integrations is exact here. + if value.id is not None: + # Explicit ble_hub_id: — the user is pointing at a specific hub (the + # multi-hub disambiguation case). Let the ID pass judge it; its error + # names the missing id, which is accurate. + return value + if not _HUB_PROVIDERS & CORE.loaded_integrations: + # Defensive lookup rather than CORE.target_platform: the property + # raises when no platform is registered, and this message must never + # be the thing that crashes. In a real run the platform is always set + # (LoadTargetPlatformValidationStep runs before any other domain), so + # the unfiltered all-platforms fallback is reachable only from tests. + platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) + if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS: + # Known platform with no in-tree hub (esp8266, host, rtl87xx, …): + # listing the other platforms' trackers would misdirect, and + # out-of-tree BLE hubs are not supported. + raise cv.Invalid( + f"No BLE tracker exists for {platform}; BLE components are " + "not supported on this platform" + ) + in_tree = ( + {tracker} + if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform)) + else set(_IN_TREE_HUB_PROVIDERS.values()) + ) + # in_tree only: _HUB_PROVIDERS is import-time state that outlives + # CORE.reset() in a long-lived process (dashboard), so a tracker from + # an earlier build of another platform must not leak into the message. + # The gate above is immune — loaded_integrations resets per run. + names = ", ".join(sorted(in_tree)) + raise cv.Invalid(f"No BLE tracker configured — add one of: {names}") + return value + + +# Schema fragment binding a consumer to the configured BLE tracker: extend a +# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a +# trailing validator after a PREVENT_EXTRA schema would reject the explicit +# form before ever running. An omitted id resolves to the single declared +# tracker on any platform; multiple trackers are disambiguated with an +# explicit ble_hub_id. +BLE_DEVICE_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)} +) + + +def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]: + """Transitional alias for the pre-migration binding key: esp32_ble_id -> + ble_hub_id. Warns and auto-migrates until removal; every migrated platform + prepends this to its CONFIG_SCHEMA so existing configs keep validating.""" + return cv.rename_key( + "esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component + ) + + +def request_irk_support() -> None: + """Compile in resolve_irk()'s software-AES path. Called by sensors with an + irk: option so builds without IRK do not carry the resolution code.""" + cg.add_define("USE_BLE_DEVICE_IRK") + + +# Number of GATT client connection slots in this build; sizes the platform +# backend's connection storage. +GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT" + +_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) + + +def request_gatt_client() -> None: + """Compile in the neutral GATT client contract (ble_gatt_client.h) and + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" + cg.add_define("USE_BLE_GATT_CLIENT") + _request_gatt_connection_slot() + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: + """Register `var` as a parsed-advertisement listener on the configured hub.""" + hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) + cg.add(hub.register_listener(var)) + _request_listener_slot() + return var + + +# ---- shared validation / codegen helpers (platform-neutral) ---- + + +def to_ble_units(value: cv.TimePeriod) -> int: + """Convert a scan time to the controller's 0.625 ms units. + + Used by both validation and codegen so what is validated is exactly what is + programmed — the truncation here is what makes the duty-cycle check below + meaningful. + """ + return value.total_microseconds // 625 + + +def validate_scan_parameters(config: ConfigType) -> ConfigType: + """Reject impossible window/interval/duration combinations at config time. + + The controller cannot scan for longer than the interval, and a too-short + duration would end the scan period almost immediately. Catching it here + gives a clear error instead of a runtime controller failure and a retry + loop. + """ + duration = config[CONF_DURATION] + interval = config[CONF_INTERVAL] + window = config[CONF_WINDOW] + + if window > interval: + raise cv.Invalid( + f"Scan window ({window}) needs to be smaller than scan interval ({interval})" + ) + + # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the + # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range + # values here instead of letting the unit conversion silently overflow. + for name, value in (("interval", interval), ("window", window)): + if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: + raise cv.Invalid( + f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms" + ) + + # Validate what actually reaches the controller: both values are truncated to + # whole 0.625 ms units, so a window/interval pair that differs by less than one + # unit collapses to the same value — silently programming a 100 % duty cycle + # (radio permanently on) from a config that asked for less. + interval_units = to_ble_units(interval) + window_units = to_ble_units(window) + if window_units == interval_units and window < interval: + raise cv.Invalid( + f"Scan window ({window}) and interval ({interval}) both truncate to " + f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " + f"cycle. Separate them by at least 0.625 ms." + ) + + if interval.total_microseconds * 3 > duration.total_microseconds: + raise cv.Invalid( + f"Scan duration ({duration}) must cover at least three scan intervals " + f"({interval}): the scanner listens on one of the three BLE advertising " + f"channels per interval, so a shorter duration can miss devices entirely." + ) + + return config + + +def scan_parameters_schema( + interval_default: str, + *, + window_default: str = "30ms", +) -> cv.All: + """Build the scan_parameters value schema shared by all BLE trackers. + + interval_default and window_default are per chip (e.g. esp32 320/30 ms, + bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; + LN882H's SDK recommends 100/50 ms). The `active` option (default on) is + unconditional: active scanning is part of the tracker contract — every + current proxy client assumes it, so a passive-only tracker must not share + this schema. + """ + schema = { + cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, + cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, + cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, + cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + } + return cv.All(cv.Schema(schema), validate_scan_parameters) + + +BT_UUID16_FORMAT = "XXXX" +BT_UUID32_FORMAT = "XXXXXXXX" +BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + +_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$") +_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$") +_BT_UUID128_RE = re.compile( + "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" +) + + +# Validator table keyed by input length: (compiled pattern, label used in errors). +_BT_UUID_FORMATS = { + len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"), + len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"), + len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"), +} + + +def bt_uuid(value: str) -> str: + in_value = cv.string_strict(value) + value = in_value.upper() + + fmt = _BT_UUID_FORMATS.get(len(value)) + if fmt is None: + raise cv.Invalid( + f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format" + ) + pattern, label = fmt + if not pattern.match(value): + raise cv.Invalid( + f"Invalid hexadecimal value for {label} UUID format: '{in_value}'" + ) + return value + + +def as_hex(value: str) -> cg.RawExpression: + return cg.RawExpression(f"0x{value}ULL") + + +def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression: + value = value.replace("-", "") + cpp_array = [ + f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] + ] + if reverse: + cpp_array.reverse() + return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") + + +def as_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=False) + + +def as_reversed_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=True) + + +def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None: + """Emit the width-matched service-UUID setter for a consumer. + + 16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte + array (BLE wire order). Shared here so every sensor platform dispatches the + same way instead of carrying its own if/elif copy. + """ + if len(service_uuid) == len(BT_UUID16_FORMAT): + cg.add(var.set_service_uuid16(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID32_FORMAT): + cg.add(var.set_service_uuid32(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID128_FORMAT): + cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid))) + else: + # bt_uuid restricts lengths to exactly these three formats; if that + # ever loosens, fail the build instead of emitting no setter (a + # sensor whose match_by_ is unset silently never matches). ValueError, + # not cv.Invalid: this runs from to_code, after validation, where + # voluptuous errors surface as raw tracebacks. + raise ValueError(f"Unsupported UUID format: {service_uuid}") diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h new file mode 100644 index 0000000000..ba3128c0ee --- /dev/null +++ b/esphome/components/ble_device_base/automation.h @@ -0,0 +1,118 @@ +// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses +// registered on a BLEHub, exposed by each tracker under its own automation +// names. parse_device()'s return feeds the "Found device" suppression. +// Constructors are templated on the hub type so this header also builds with +// no tracker present (host unit tests). + +#pragma once + +#include "ble_device.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::ble_device_base { + +// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + + bool parse_device(const ESPBTDevice &device) override { + if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), + device.address_uint64()) == this->addresses_.end()) { + return false; + } + this->trigger(device); + return true; + } + + protected: + FixedVector addresses_; +}; + +// on_ble_service_data_advertise: fires when an advertisement contains service +// data for the given UUID. Optional single-MAC filter. +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &sd : device.get_service_datas()) { + if (sd.uuid == this->uuid_) { + this->trigger(sd.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_ble_manufacturer_data_advertise: fires when an advertisement contains +// manufacturer data for the given ID. Optional single-MAC filter. +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &md : device.get_manufacturer_datas()) { + if (md.uuid == this->uuid_) { + this->trigger(md.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_scan_end: fires whenever a scan period ends (duration elapsed or stop +// requested). A listener whose on_scan_end() hook fires the trigger — never +// claims devices (parse_device always returns false). +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { + public: + template explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); } + + bool parse_device(const ESPBTDevice &device) override { return false; } + void on_scan_end() override { this->trigger(); } +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py new file mode 100644 index 0000000000..eb63061a8d --- /dev/null +++ b/esphome/components/ble_device_base/automation.py @@ -0,0 +1,128 @@ +"""Shared codegen for the neutral BLE advertisement triggers (automation.h).""" + +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +from . import ( + BT_UUID16_FORMAT, + BT_UUID32_FORMAT, + BT_UUID128_FORMAT, + LISTENER_COUNT_DEFINE, + as_hex, + as_reversed_hex_array, + ble_device_base_ns, +) + +adv_data_t = cg.std_vector.template(cg.uint8) +adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") +ESPBTDeviceConstRef = ( + ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const") +) + +ESPBTAdvertiseTrigger = ble_device_base_ns.class_( + "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) +) +BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) +) +BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEManufacturerDataAdvertiseTrigger", + automation.Trigger.template(adv_data_t_const_ref), +) +BLEEndOfScanTrigger = ble_device_base_ns.class_( + "BLEEndOfScanTrigger", automation.Trigger.template() +) + +# UUID string length -> setter width. 16/32-bit go out as plain hex literals, +# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an +# impossible length fails as a KeyError instead of silently picking a width +# (bt_uuid validation upstream only ever produces these three). +_UUID_WIDTHS = { + len(BT_UUID16_FORMAT): "16", + len(BT_UUID32_FORMAT): "32", + len(BT_UUID128_FORMAT): "128", +} + + +def uuid_trigger_schema( + trigger_class: MockObjClass, extra: dict[Any, Any] | None = None +): + """Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code(). + + `extra` carries the required UUID key (a cv marker, so a dict rather than + **kwargs); the optional single-mac filter is what uuid_trigger_to_code() + reads back. + """ + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + **(extra or {}), + } + ) + + +def advertise_trigger_schema(trigger_class: MockObjClass): + """on_ble_advertise schema: multi-mac list filter, unlike the single-mac + uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + } + ) + + +def scan_end_trigger_schema(trigger_class: MockObjClass): + """on_scan_end schema: id only — pairs with scan_end_trigger_to_code().""" + return automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)} + ) + + +# Triggers register as ble_device_base listeners in their constructors; count +# them where they are created so no backend can undercount the StaticVector +# (push_back past capacity drops silently). Shares the define with +# register_ble_device() via the core slot-counter factory. +_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_ble_advertise trigger (optional multi-mac filter).""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_addresses([it.as_hex for it in macs])) + await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) + _count_listener() + + +async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_scan_end trigger.""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + _count_listener() + + +async def uuid_trigger_to_code( + conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str +) -> None: + """Build a UUID-filtered advertise trigger. + + The UUID width picks the setter: 16-/32-bit go out as a plain hex literal, + 128-bit as a reversed byte array (BLE wire order). + """ + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + uuid = conf[key] + width = _UUID_WIDTHS[len(uuid)] + value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid) + cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) + if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_address(mac.as_hex)) + await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) + _count_listener() diff --git a/esphome/components/ble_device_base/ble_aes_ccm.cpp b/esphome/components/ble_device_base/ble_aes_ccm.cpp new file mode 100644 index 0000000000..3ff34acc34 --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.cpp @@ -0,0 +1,202 @@ +#include "ble_aes_ccm.h" + +#include +#include + +namespace esphome::ble_device_base { + +namespace { + +// AES-128 forward cipher only — CCM uses the block cipher in the encrypt +// direction for both the CTR keystream and the CBC-MAC. +const uint8_t SBOX[256] = { + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, // + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, // + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, // + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, // + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, // + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, // + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, // + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, // + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, // + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, // + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, // + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, // + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, // + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, // + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, // + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, // +}; + +const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36}; + +inline uint8_t xtime(uint8_t x) { return static_cast((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); } + +// AES-128 forward cipher with on-the-fly key schedule. +class Aes128 { + public: + explicit Aes128(const uint8_t key[16]) { + memcpy(this->rk_, key, 16); + for (size_t i = 16; i < 176; i += 4) { + uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]}; + if (i % 16 == 0) { + const uint8_t tmp = t[0]; + t[0] = static_cast(SBOX[t[1]] ^ RCON[i / 16]); + t[1] = SBOX[t[2]]; + t[2] = SBOX[t[3]]; + t[3] = SBOX[tmp]; + } + for (size_t j = 0; j < 4; j++) + this->rk_[i + j] = static_cast(this->rk_[i - 16 + j] ^ t[j]); + } + } + + void encrypt(const uint8_t in[16], uint8_t out[16]) const { + uint8_t s[16]; + memcpy(s, in, 16); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[i]; + + for (size_t round = 1; round < 10; round++) { + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t c = 0; c < 4; c++) { + uint8_t *col = s + c * 4; + const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3]; + const uint8_t h = static_cast(a0 ^ a1 ^ a2 ^ a3); + col[0] ^= static_cast(h ^ xtime(static_cast(a0 ^ a1))); + col[1] ^= static_cast(h ^ xtime(static_cast(a1 ^ a2))); + col[2] ^= static_cast(h ^ xtime(static_cast(a2 ^ a3))); + col[3] ^= static_cast(h ^ xtime(static_cast(a3 ^ a0))); + } + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[round * 16 + i]; + } + + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[160 + i]; + memcpy(out, s, 16); + } + + protected: + static void shift_rows(uint8_t s[16]) { + uint8_t t = s[1]; + s[1] = s[5]; + s[5] = s[9]; + s[9] = s[13]; + s[13] = t; + t = s[2]; + s[2] = s[10]; + s[10] = t; + t = s[6]; + s[6] = s[14]; + s[14] = t; + t = s[3]; + s[3] = s[15]; + s[15] = s[11]; + s[11] = s[7]; + s[7] = t; + } + + uint8_t rk_[176]; +}; + +} // namespace + +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) { + Aes128 aes(key); + aes.encrypt(in, out); +} + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len) { + // CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte + // nonce L = 2; BTHome uses M = 4. + if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16) + return false; + const size_t l = 15 - nonce_len; + const size_t m = tag_len; + + const Aes128 aes(key); + + // Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian). + uint8_t a[16]; + auto build_ctr = [&](uint32_t counter) { + a[0] = static_cast(l - 1); + memcpy(a + 1, nonce, nonce_len); + memset(a + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + a[15 - i] = static_cast((counter >> (8 * i)) & 0xff); + }; + + // S_0 = E(A_0); its first m bytes mask the transmitted tag. + uint8_t s0[16]; + build_ctr(0); + aes.encrypt(a, s0); + + // CTR-decrypt ciphertext into plaintext using S_1, S_2, ... + uint8_t ks[16]; + for (size_t off = 0; off < ct_len; off += 16) { + build_ctr(static_cast(off / 16) + 1); + aes.encrypt(a, ks); + const size_t n = std::min(static_cast(16), ct_len - off); + for (size_t i = 0; i < n; i++) + plaintext[off + i] = static_cast(ciphertext[off + i] ^ ks[i]); + } + + // CBC-MAC over B_0 | (formatted AAD) | plaintext. + uint8_t x[16]; + uint8_t b0[16]; + const uint8_t flags = static_cast((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1)); + b0[0] = flags; + memcpy(b0 + 1, nonce, nonce_len); + memset(b0 + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + b0[15 - i] = static_cast((ct_len >> (8 * i)) & 0xff); + aes.encrypt(b0, x); // X_1 = E(B_0) + + if (aad_len > 0) { + // Only the < 2^16-2^8 encoding is needed for BLE-sized AAD. + uint8_t blk[16] = {0}; + blk[0] = static_cast((aad_len >> 8) & 0xff); + blk[1] = static_cast(aad_len & 0xff); + size_t ai = 0; + size_t pos = 2; + while (pos < 16 && ai < aad_len) + blk[pos++] = aad[ai++]; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + while (ai < aad_len) { + memset(blk, 0, 16); + const size_t n = std::min(static_cast(16), aad_len - ai); + memcpy(blk, aad + ai, n); + ai += n; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + } + + for (size_t off = 0; off < ct_len; off += 16) { + uint8_t blk[16] = {0}; + const size_t n = std::min(static_cast(16), ct_len - off); + memcpy(blk, plaintext + off, n); + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + + // Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag. + uint8_t diff = 0; + for (size_t i = 0; i < m; i++) + diff |= static_cast((x[i] ^ s0[i]) ^ tag[i]); + return diff == 0; +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_aes_ccm.h b/esphome/components/ble_device_base/ble_aes_ccm.h new file mode 100644 index 0000000000..d337ad8ecd --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace esphome::ble_device_base { + +// Self-contained AES-128-CCM authenticated decryption (RFC 3610). +// +// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use +// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE +// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls +// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not +// put it on the application include path), so a sensor cannot rely on +// being available. This software implementation makes +// encrypted-advertisement decryption work on every BLE platform without a +// per-chip crypto dependency. Decryption volume is tiny (one short block per +// matching advertisement), so software AES is not a meaningful cost. +// +// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted +// bytes to `plaintext` and returns true. Returns false when authentication fails +// (the caller must then discard `plaintext`). The CCM parameters follow the +// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be +// null when `aad_len` is 0. +/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by +/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching +/// works identically on every platform with no chip crypto dependency. +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]); + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len); + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.cpp b/esphome/components/ble_device_base/ble_client_state.cpp new file mode 100644 index 0000000000..55817024b5 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.cpp @@ -0,0 +1,26 @@ +#include "ble_client_state.h" + +namespace esphome::ble_device_base { + +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h new file mode 100644 index 0000000000..92754b70b4 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -0,0 +1,81 @@ +// ble_client_state.h +// +// Platform-neutral GATT client connection state types, shared by every +// platform's GATT client implementation (esp32_ble_client, bluetooth_connection +// backends). Moved here from esp32_ble_tracker, which re-exports them under its +// own namespace for backward compatibility. + +#pragma once + +#include + +namespace esphome::ble_device_base { + +/// ESPHome-private errors for the API's plain-int error fields, outside the +/// ATT code range so they cannot be mistaken for spec errors. -1 is +/// understood by API clients as "not connected". Shared by every GATT +/// client backend. +static constexpr int GATT_ERR_NOT_CONNECTED = -1; +static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// Safety net shared by every GATT backend: force IDLE when the stack never +/// delivers its disconnect completion. +static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; + +// Preferred connection parameters shared by every platform's GATT client so +// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency +// 0). FAST covers connection setup and service discovery; MEDIUM is the +// steady state once established. Stack defaults (12.5-15 ms) are too slow for +// stable connections through WiFi-based BLE proxies, causing disconnections; +// MEDIUM balances responsiveness with bandwidth usage. +static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms +// The timeout value was increased from 6s to 8s to address stability issues observed +// in certain BLE devices when operating through WiFi-based BLE proxies. The longer +// timeout reduces the likelihood of disconnections during periods of high latency. +static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s + +// Fastest connection parameters for devices with short discovery timeouts +static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s + +enum class ClientState : uint8_t { + // Connection is allocated + INIT, + // Client is disconnecting + DISCONNECTING, + // Connection is idle, no device detected. + IDLE, + // Device advertisement found. + DISCOVERED, + // Connection in progress. + CONNECTING, + // Initial connection established. + CONNECTED, + // The client and sub-clients have completed setup. + ESTABLISHED, +}; + +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + +enum class ConnectionType : uint8_t { + // The default connection type, we hold all the services in ram + // for the duration of the connection. + V1, + // The client has a cache of the services and mtu so we should not + // fetch them again + V3_WITH_CACHE, + // The client does not need the services and mtu once we send them + // so we should wipe them from memory as soon as we send them + V3_WITHOUT_CACHE +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp new file mode 100644 index 0000000000..23ca6b1dbd --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -0,0 +1,598 @@ +// ble_device.cpp +// +// Platform-neutral implementation of the shared BLE advertisement types. +// Parses raw BLE advertisement data into ESPBTDevice. + +#include "ble_device.h" + +#include "ble_aes_ccm.h" + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ble_device_base { + +static const char *const TAG = "ble_device_base"; + +// Longest advertisement payload worth hex-dumping at VERY_VERBOSE +// (legacy advertising: 31-byte adv + 31-byte scan response). +static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; + +// --------------------------------------------------------------------------- +// ESPBTUUID +// --------------------------------------------------------------------------- + +ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, data, 16); + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + for (int i = 0; i < 16; i++) + ret.uuid_.uuid128[i] = data[15 - i]; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { + // Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw. + ESPBTUUID ret; + if (length == 4) { + // 16-bit UUID as 4-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = parsed.value(); + } + } else if (length == 8) { + // 32-bit UUID as 8-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = parsed.value(); + } + } else if (length == 16) { + // 16 raw bytes (little-endian 128-bit UUID) + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, reinterpret_cast(data), 16); + } else if (length == 36) { + // Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + ret.type_ = Type::UUID128; + int n = 0; + for (size_t i = 0; i < length; i += 2) { + if (data[i] == '-') + i++; + uint8_t msb = data[i]; + uint8_t lsb = data[i + 1]; + if (msb > '9') + msb -= 7; + if (lsb > '9') + lsb -= 7; + ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); + } + } else { + ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data); + } + return ret; +} + +#ifdef USE_ESP32 +ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { + if (uuid.len == 0) // the unset sentinel get_uuid() emits + return {}; + if (uuid.len == ESP_UUID_LEN_16) + return ESPBTUUID::from_uint16(uuid.uuid.uuid16); + if (uuid.len == ESP_UUID_LEN_32) + return ESPBTUUID::from_uint32(uuid.uuid.uuid32); + return ESPBTUUID::from_raw(uuid.uuid.uuid128); +} + +esp_bt_uuid_t ESPBTUUID::get_uuid() const { + esp_bt_uuid_t ret; + switch (this->type_) { + case Type::UNSET: + ret.len = 0; + memset(&ret.uuid, 0, sizeof(ret.uuid)); + break; + case Type::UUID16: + ret.len = ESP_UUID_LEN_16; + ret.uuid.uuid16 = this->uuid_.uuid16; + break; + case Type::UUID32: + ret.len = ESP_UUID_LEN_32; + ret.uuid.uuid32 = this->uuid_.uuid32; + break; + default: + case Type::UUID128: + ret.len = ESP_UUID_LEN_128; + memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128); + break; + } + return ret; +} + +void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { + this->scan_result_ = &scan_result; + // BLEScanResult's bda is most-significant octet first; the neutral ingest + // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ + // address_str_to() then produce exactly the historical esp32 values. + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; + for (uint8_t i = 0; i < 6; i++) + mac_lsb_first[i] = scan_result.bda[5 - i]; + this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, + scan_result.adv_data_len + scan_result.scan_rsp_len); +} +#endif // USE_ESP32 + +ESPBTUUID ESPBTUUID::as_128bit() const { + // Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID. + if (this->type_ == Type::UNSET || this->type_ == Type::UUID128) + return *this; + uint8_t data[16]; + this->to_128bit_(data); + return ESPBTUUID::from_raw(data); +} + +bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { + // Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains. + switch (this->type_) { + case Type::UNSET: + return false; + case Type::UUID16: + return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1; + case Type::UUID32: + for (uint8_t i = 0; i < 3; i++) { + bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1; + bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2; + if (a && b) + return true; + } + return false; + case Type::UUID128: + for (uint8_t i = 0; i < 15; i++) { + if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2) + return true; + } + return false; + } + return false; +} + +const char *ESPBTUUID::to_str(char *buf) const { + // Identical output format to esp32_ble::ESPBTUUID::to_str. + char *pos = buf; + switch (this->type_) { + case Type::UNSET: + memcpy(buf, "None", 5); + return buf; + case Type::UUID16: + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F); + *pos = 0; // NUL-terminate + return buf; + case Type::UUID32: + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) + *pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F); + *pos = 0; // NUL-terminate + return buf; + default: + case Type::UUID128: + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + for (int8_t i = 15; i >= 0; i--) { + uint8_t byte = this->uuid_.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) + *pos++ = '-'; + } + *pos = 0; // NUL-terminate + return buf; + } +} + +void ESPBTUUID::to_128bit_(uint8_t out[16]) const { + // Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit + // value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit(). + // Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000. + static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + if (this->type_ == Type::UUID128) { + memcpy(out, this->uuid_.uuid128, 16); + return; + } + memcpy(out, BASE, 16); + const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16; + const size_t len = (this->type_ == Type::UUID32) ? 4 : 2; + for (size_t i = 0; i < len; i++) + out[12 + i] = (value >> (i * 8)) & 0xFF; +} + +bool ESPBTUUID::operator==(const ESPBTUUID &other) const { + if (this->type_ == other.type_) { + switch (this->type_) { + case Type::UNSET: + return true; + case Type::UUID16: + return this->uuid_.uuid16 == other.uuid_.uuid16; + case Type::UUID32: + return this->uuid_.uuid32 == other.uuid_.uuid32; + case Type::UUID128: + return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0; + } + return false; + } + // Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured". + if (this->type_ == Type::UNSET || other.type_ == Type::UNSET) + return false; + // Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a + // configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity). + uint8_t a[16]; + uint8_t b[16]; + this->to_128bit_(a); + other.to_128bit_(b); + return memcmp(a, b, 16) == 0; +} + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); } + +optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) { + // iBeacon manufacturer specific data (after company-ID bytes have been stripped): + // [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes + if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C + return {}; + if (data.data.size() != 23) + return {}; + // Require the iBeacon sub-type/length prefix — stricter than the legacy + // esp32 parser, which accepted any 23-byte Apple payload and surfaced + // non-iBeacon frames as garbage beacons. + if (data.data[0] != 0x02 || data.data[1] != 0x15) { + if (prefix_rejected != nullptr) + *prefix_rejected = true; + return {}; + } + return ESPBLEiBeacon(data.data.data()); +} + +// --------------------------------------------------------------------------- +// ESPBTDevice +// --------------------------------------------------------------------------- + +optional ESPBTDevice::get_ibeacon() const { + bool prefix_rejected = false; + uint8_t rejected_sub_type = 0; + uint8_t rejected_len = 0; + for (const auto &it : this->manufacturer_datas_) { + bool rejected = false; + auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected); + if (res.has_value()) + return res; + if (rejected && !prefix_rejected) { + prefix_rejected = true; + rejected_sub_type = it.data[0]; + rejected_len = it.data[1]; + } + } + if (prefix_rejected) { + // Only when no beacon was found at all: these frames were accepted before + // the prefix check, so their disappearance must be observable at the + // default log level. Throttled so a chatty non-iBeacon Apple advertiser + // cannot flood the log; a different address may bypass the shared window + // so that advertiser cannot mask the device that actually regressed — but + // with a 1 s floor, or two alternating advertisers log every frame. + static uint32_t last_log = 0; + static uint64_t last_addr = 0; + const uint32_t now = millis(); + const uint64_t addr = this->address_uint64(); + const uint32_t since = now - last_log; + if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) { + last_log = now; + last_addr = addr; + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)", + this->address_str_to(addr_buf), rejected_sub_type, rejected_len); + } + } + return {}; +} + +const char *ESPBTDevice::address_type_str() const { + switch (this->address_type_) { + case BLE_ADDR_TYPE_PUBLIC: + return "PUBLIC"; + case BLE_ADDR_TYPE_RANDOM: + return "RANDOM"; + case BLE_ADDR_TYPE_RPA_PUBLIC: + return "RPA_PUBLIC"; + case BLE_ADDR_TYPE_RPA_RANDOM: + return "RPA_RANDOM"; + default: + return "UNKNOWN"; + } +} + +void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len) { + // Ingest is BLE controller order (LSB-first); store in printable (MSB-first) + // order so the raw address() accessor matches the historical esp32 layout. + for (uint8_t i = 0; i < 6; i++) + this->address_[i] = mac[5 - i]; + this->address_type_ = addr_type; + this->rssi_ = rssi; + this->name_len_ = 0; + this->name_[0] = '\0'; + this->service_uuids_.clear(); + this->manufacturer_datas_.clear(); + this->service_datas_.clear(); + this->tx_powers_.clear(); + this->appearance_.reset(); + this->ad_flag_.reset(); + this->parse_adv_(data, data_len); + +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGVV(TAG, + "Parse Result:\n" + " Address: %s (%s)\n" + " RSSI: %d\n" + " Name: '%s'", + this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_); + for (auto &it : this->tx_powers_) { + ESP_LOGVV(TAG, " TX Power: %d", it); + } + if (this->appearance_.has_value()) { + ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); + } + if (this->ad_flag_.has_value()) { + ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); + } + char uuid_buf[UUID_STR_LEN]; + for (auto &uuid : this->service_uuids_) { + ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf)); + } + char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; + for (auto &mfg_data : this->manufacturer_datas_) { + auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data); + if (ibeacon.has_value()) { + ESP_LOGVV(TAG, + " Manufacturer iBeacon:\n" + " UUID: %s\n" + " Major: %u\n" + " Minor: %u\n" + " TXPower: %d", + ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(), + ibeacon.value().get_signal_power()); + } else { + ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size())); + } + } + for (auto &svc_data : this->service_datas_) { + ESP_LOGVV(TAG, + " Service data:\n" + " UUID: %s\n" + " Data: %s", + svc_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); + } + ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len)); +#endif // ESPHOME_LOG_HAS_VERY_VERBOSE +} + +// Remove before 2027.2.0 +std::string ESPBTDevice::address_str() const { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->address_str_to(buf)); +} + +const char *ESPBTDevice::address_str_to(char *buf) const { + // address_ is stored in printable (MSB-first) order. + format_mac_addr_upper(this->address_, buf); + return buf; +} + +uint64_t ESPBTDevice::address_uint64() const { + // address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics). + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(this->address_[i]) << ((5 - i) * 8); + return addr; +} + +bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { +#ifdef USE_BLE_DEVICE_IRK + // Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits]. + // The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes). + // Uses the portable software AES-128 shared with the CCM decryptor, so IRK + // matching behaves identically on every platform (volume is one block per + // advertisement from a matching RPA device — software AES is not a cost). + uint8_t ecb_plaintext[16] = {0}; + uint8_t ecb_ciphertext[16]; + const uint64_t addr64 = this->address_uint64(); + ecb_plaintext[13] = (addr64 >> 40) & 0xff; + ecb_plaintext[14] = (addr64 >> 32) & 0xff; + ecb_plaintext[15] = (addr64 >> 24) & 0xff; + aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext); + return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && + ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); +#else + // No sensor configured an irk: in this build; the AES core is compiled out. + (void) irk; + return false; +#endif +} + +void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { + // BLE AD structure TLV: [length][type][value...] + // length includes the type byte. + uint16_t offset = 0; + while (offset < len) { + uint8_t ad_len = payload[offset++]; + if (ad_len == 0) + continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too) + if (offset + ad_len > len) + break; + uint8_t ad_type = payload[offset]; + const uint8_t *ad_data = &payload[offset + 1]; + uint8_t ad_data_len = ad_len - 1; + offset += ad_len; + + switch (ad_type) { + case 0x01: // Flags + if (ad_data_len >= 1) + this->ad_flag_ = ad_data[0]; + break; + + case 0x08: // Shortened Local Name + case 0x09: // Complete Local Name + // Keep the longest name seen — a merged adv + scan-response frame may carry both the + // shortened and the complete name, and the shortened form must never replace the + // complete one (same rule as esp32_ble_tracker's parse_adv_). + if (ad_data_len > this->name_len_) { + uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast(ad_data_len); + memcpy(this->name_, ad_data, name_len); + this->name_[name_len] = '\0'; + this->name_len_ = name_len; + } + break; + + case 0x0A: // TX Power Level + if (ad_data_len >= 1) + this->tx_powers_.push_back(static_cast(ad_data[0])); + break; + + case 0x19: // Appearance + if (ad_data_len >= 2) + this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + break; + + case 0x02: // Incomplete List of 16-bit Service UUIDs + case 0x03: // Complete List of 16-bit Service UUIDs + for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { + uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); + } + break; + + case 0x04: // Incomplete List of 32-bit Service UUIDs + case 0x05: // Complete List of 32-bit Service UUIDs + for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { + uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | + (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | + ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); + } + break; + + case 0x06: // Incomplete List of 128-bit Service UUIDs + case 0x07: // Complete List of 128-bit Service UUIDs + for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16) + this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i])); + break; + + case 0xFF: // Manufacturer Specific Data + if (ad_data_len >= 2) { + uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(company_id); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->manufacturer_datas_.push_back(std::move(sd)); + } + break; + + case 0x16: // Service Data — 16-bit UUID + if (ad_data_len >= 2) { + uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(uuid); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x20: // Service Data — 32-bit UUID + if (ad_data_len >= 4) { + uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | + (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint32(uuid); + sd.data.assign(ad_data + 4, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x21: // Service Data — 128-bit UUID + if (ad_data_len >= 16) { + ServiceData sd; + sd.uuid = ESPBTUUID::from_raw(ad_data); + sd.data.assign(ad_data + 16, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + default: + break; + } + } +} + +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog +// --------------------------------------------------------------------------- + +void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) { +#ifdef ESPHOME_LOG_HAS_DEBUG + // Everything here feeds ESP_LOGD: below DEBUG the whole body (including the + // dedup vector growth) would be pure overhead, so compile it out entirely. + const uint64_t address = device.address_uint64(); + for (auto &disc : this->already_discovered_) { + if (disc == address) + return; + } + this->already_discovered_.push_back(address); + + char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(tag, + "Found device %s RSSI=%d\n" + " Address Type: %s", + device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str()); + if (!device.get_name().empty()) { + ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str()); + } + for (auto &tx_power : device.get_tx_powers()) { + ESP_LOGD(tag, " TX Power: %d", tx_power); + } +#endif // ESPHOME_LOG_HAS_DEBUG +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h new file mode 100644 index 0000000000..668f7e09f8 --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.h @@ -0,0 +1,292 @@ +// ble_device.h +// +// Platform-neutral BLE advertisement types — the generic base every BLE consumer +// (sensor components, bluetooth_proxy, automation triggers) builds against: +// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener +// +// These types are owned here on EVERY platform, with no chip-SDK types in their +// public surface. Platform trackers produce them: +// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and +// re-exports these names (esp32 only) for backward compatibility; +// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly. + +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +#include +#include +#include +#include +#include + +#if defined(__cpp_lib_span) +#include +#endif + +#ifdef USE_ESP32 +// Historical esp32_ble API surface (below, under the same define) uses the +// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32. +#include "esphome/components/esp32_ble/ble_scan_result.h" +#include +#endif + +namespace esphome::ble_device_base { + +using adv_data_t = std::vector; + +// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t). +static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0; +static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3; + +/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" +static constexpr size_t UUID_STR_LEN = 37; + +// --------------------------------------------------------------------------- +// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type. +// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t +// conversions live in esp32_ble (esp32-only adapters), not here. +// --------------------------------------------------------------------------- + +class ESPBTUUID { + public: + ESPBTUUID() = default; + + static ESPBTUUID from_uint16(uint16_t uuid); + static ESPBTUUID from_uint32(uint32_t uuid); + /// Construct from raw 16-byte little-endian UUID. + static ESPBTUUID from_raw(const uint8_t *data); + /// Construct from raw 16-byte big-endian UUID (reversed on store). + static ESPBTUUID from_raw_reversed(const uint8_t *data); + /// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes, + /// or the 36-char dashed UUID form. Same semantics as esp32_ble historically. + static ESPBTUUID from_raw(const char *data, size_t length); + static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } + static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } + static ESPBTUUID from_raw(std::initializer_list data) { + return from_raw(reinterpret_cast(data.begin()), data.size()); + } + +#ifdef USE_ESP32 + /// Source compatibility with the historical esp32_ble API (esp32 builds only). + static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); + esp_bt_uuid_t get_uuid() const; +#endif + + /// Expand to the 128-bit Bluetooth Base UUID form. + ESPBTUUID as_128bit() const; + + /// True if the UUID value contains the adjacent byte pair (data1, data2). + bool contains(uint8_t data1, uint8_t data2) const; + + bool operator==(const ESPBTUUID &other) const; + bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } + + /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an + /// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf. + const char *to_str(char *buf) const; +#if defined(__cpp_lib_span) + const char *to_str(std::span output) const { return this->to_str(output.data()); } +#endif + // UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel). + enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 }; + Type type() const { return this->type_; } + /// True if a UUID has been configured (not default-constructed). + bool is_set() const { return this->type_ != Type::UNSET; } + uint16_t uuid16() const { return this->uuid_.uuid16; } + uint32_t uuid32() const { return this->uuid_.uuid32; } + const uint8_t *uuid128() const { return this->uuid_.uuid128; } + + protected: + // Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian). + void to_128bit_(uint8_t out[16]) const; + + Type type_{Type::UNSET}; + union { + uint16_t uuid16; + uint32_t uuid32; + uint8_t uuid128[16]; + } uuid_{}; +}; + +// --------------------------------------------------------------------------- +// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types) +// --------------------------------------------------------------------------- + +struct ServiceData { + ESPBTUUID uuid; + adv_data_t data; +}; + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +class ESPBLEiBeacon { + public: + ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } + explicit ESPBLEiBeacon(const uint8_t *data); + /// prefix_rejected: caller must initialise to false; set to true ONLY when a + /// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix — + /// the case the legacy esp32 parser accepted. Never written on accept or on + /// the non-Apple/wrong-size rejects. The caller with the device address does + /// the logging (see ESPBTDevice::get_ibeacon()). + static optional from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr); + + uint16_t get_major() const { return byteswap(this->beacon_data_.major); } + uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); } + int8_t get_signal_power() const { return this->beacon_data_.signal_power; } + ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } + + protected: + struct PACKED BeaconData { + uint8_t sub_type; + uint8_t length; + uint8_t proximity_uuid[16]; + uint16_t major; + uint16_t minor; + int8_t signal_power; + } beacon_data_; +}; + +/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. +/// Trackers with LSB-native SDKs call this at the emit site before filling +/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent +/// for an already parsed device, whose address is stored MSB-first. +inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(mac[i]) << (i * 8); + return addr; +} + +/// Unpack a uint64 BLE address into printable (MSB-first) byte order — +/// the order bd_addr_t / esp_bd_addr_t style APIs expect. +inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) { + for (int i = 0; i < 6; i++) + out[i] = (address >> ((5 - i) * 8)) & 0xFF; +} + +// --------------------------------------------------------------------------- +// ESPBTDevice — parsed BLE advertisement +// --------------------------------------------------------------------------- + +class ESPBTDevice { + public: + /// Populate from a raw scan result delivered by a BLE tracker backend. + /// mac is least-significant octet first (BLE controller convention). + void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + + // Alias the core constant so the two cannot drift apart. + static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE; + + /// Return MAC as "XX:XX:XX:XX:XX:XX" string. + ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0") + std::string address_str() const; + /// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf. + const char *address_str_to(char *buf) const; +#if defined(__cpp_lib_span) + const char *address_str_to(std::span buf) const { + return this->address_str_to(buf.data()); + } +#endif + /// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64). + uint64_t address_uint64() const; + /// Raw MAC bytes in printable (MSB-first) order — matches the historical + /// esp32 layout (ESP-IDF bda order). + const uint8_t *address() const { return address_; } +#ifdef USE_ESP32 + // Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t. + esp_ble_addr_type_t get_address_type() const { return static_cast(this->address_type_); } + /// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result. + /// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending + /// consumer feedback on the raw scan-result fields. + void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result); + // Exposed through a function for use in lambdas + const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; } +#else + uint8_t get_address_type() const { return this->address_type_; } +#endif + /// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or + /// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above. + const char *address_type_str() const; + + int get_rssi() const { return rssi_; } + /// Advertised name as a view into the fixed buffer (always NUL-terminated, + /// so c_str() is safe); converts implicitly to std::string where needed. + StringRef get_name() const { return StringRef(this->name_, this->name_len_); } + + const std::vector &get_service_uuids() const { return service_uuids_; } + const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } + const std::vector &get_service_datas() const { return service_datas_; } + const std::vector &get_tx_powers() const { return tx_powers_; } + const optional &get_appearance() const { return appearance_; } + const optional &get_ad_flag() const { return ad_flag_; } + + /// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah" + /// function, AES-128). Uses the portable software AES shared with the CCM + /// decryptor; compiled only when a sensor configures irk: (request_irk_support). + bool resolve_irk(const uint8_t *irk) const; + + optional get_ibeacon() const; + + protected: + void parse_adv_(const uint8_t *payload, uint16_t len); + + // Max name bytes in a legacy advertisement AD element (31-byte PDU minus + // the 2-byte element header); every in-tree tracker scans legacy PDUs only. + static constexpr uint8_t MAX_ADV_NAME_LEN = 29; + + uint8_t address_[MAC_ADDRESS_SIZE]{0}; + uint8_t address_type_{0}; + int rssi_{0}; + // Fixed buffer instead of std::string: no per-advertisement heap churn on + // the scan path, and no libstdc++ string/exception machinery in the image. + char name_[MAX_ADV_NAME_LEN + 1]{}; + uint8_t name_len_{0}; + std::vector service_uuids_{}; + std::vector manufacturer_datas_{}; + std::vector service_datas_{}; +#ifdef USE_ESP32 + const esp32_ble::BLEScanResult *scan_result_{nullptr}; +#endif + std::vector tx_powers_{}; + optional appearance_{}; + optional ad_flag_{}; +}; + +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger +// --------------------------------------------------------------------------- + +/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address. +/// Shared by all tracker backends so the output format and dedup behaviour stay +/// identical by construction (single implementation instead of per-chip copies). +class DiscoveredDeviceLog { + public: + /// Log the device at DEBUG the first time its MAC is seen this scan period. + void log_device(const char *tag, const ESPBTDevice &device); + /// Reset the per-period dedup list (call when a scan period ends). + void clear() { this->already_discovered_.clear(); } + + protected: + std::vector already_discovered_; +}; + +// --------------------------------------------------------------------------- +// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers) +// --------------------------------------------------------------------------- + +class ESPBTDeviceListener { + public: + virtual ~ESPBTDeviceListener() = default; + /// Called at the end of each scan duration period. + virtual void on_scan_end() {} + virtual bool parse_device(const ESPBTDevice &device) = 0; +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h new file mode 100644 index 0000000000..b95fb6878a --- /dev/null +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -0,0 +1,148 @@ +// ble_gatt_client.h +// +// Platform-neutral GATT client connection contract. +// +// Exactly one GATT backend exists per build, so BLEGattConnection is a +// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract +// interface. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls are delivered on the ESPHome main loop; +// borrowed data pointers are valid only for the duration of the call. +// +// Error domain (plain int, forwarded to the API without translation): +// 0 success +// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree) +// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw +// ESP_FAIL from the stack shares this value; both read as a +// failed, unusable connection on the client side) +// GATT_ERR_NO_MEMORY (-2) backend storage exhausted +// anything else: platform stack error/status code, surfaced opaquely. +// Connection events carry HCI status/disconnect reason codes (same code +// space on every controller). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "ble_client_state.h" +#include "ble_device.h" + +#include +#include + +namespace esphome::ble_device_base { + +// Materialized GATT database of a connected peer, discovered by the backend +// and streamed to the API by the consumer. Flat arrays with index ranges +// (not pointers): a service owns characteristics +// [first_characteristic, first_characteristic + characteristic_count) and a +// characteristic owns descriptors [first_descriptor, ...) — discovery is +// depth-first, so the ranges are naturally contiguous. +struct GattDescriptor { + ESPBTUUID uuid; + uint16_t handle; +}; + +struct GattCharacteristic { + ESPBTUUID uuid; + uint16_t value_handle; + // Needed to rebuild the stack's characteristic object for CCCD operations. + uint16_t end_handle; + uint8_t properties; // Bluetooth spec property bitfield + uint16_t first_descriptor; + uint16_t descriptor_count; +}; + +struct GattService { + ESPBTUUID uuid; + uint16_t start_handle; + uint16_t end_handle; + uint16_t first_characteristic; + uint16_t characteristic_count; +}; + +/// Borrowed view of the backend-owned service table. Valid from a successful +/// on_service_discovery_done() until release_services(). Characteristics and +/// descriptors are reached through the per-service/per-characteristic index +/// ranges; the array totals let a consumer bounds-check those ranges instead +/// of trusting the backend's discovery bookkeeping blindly. +struct GattServiceTable { + const GattService *services{nullptr}; + const GattCharacteristic *characteristics{nullptr}; + const GattDescriptor *descriptors{nullptr}; + uint16_t service_count{0}; + uint16_t characteristic_count{0}; + uint16_t descriptor_count{0}; +}; + +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + public: + virtual void on_connection_state(bool connected, uint16_t mtu, int error) {} + virtual void on_service_discovery_done(int error) {} + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + virtual void on_write_result(uint16_t handle, int error) {} + virtual void on_notify_state(uint16_t handle, bool enabled, int error) {} + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + virtual void on_pairing_result(int status) {} +}; + +// The BLEGattConnection op surface, asserted where the alias binds +// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives +// through the listener) or a synchronous error (busy, not connected, stack +// rejection); one operation may be outstanding at a time. Semantics beyond +// the signatures: +// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). +// - notify_characteristic: local registration only; the CCCD write is the +// API client's responsibility (a plain write_descriptor). +// - get_service_table/release_services: backend-owned transient storage, +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. +// - completions: connect and gatt_disconnect land in on_connection_state, +// discover_services in on_service_discovery_done, pair in +// on_pairing_result, reads in on_read_result, notify_characteristic in +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h new file mode 100644 index 0000000000..9da6371012 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub.h @@ -0,0 +1,122 @@ +// ble_hub.h +// +// The platform-neutral BLE tracker contract: shared types plus the method +// surface every tracker provides (documented below). Exactly one tracker +// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not +// an abstract interface — no vtable, every hub call inlinable. Consumers +// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub). +// +// Chip differences are expressed as data (HubCapabilities), never as +// platform conditionals in consumers. + +#pragma once + +#include "ble_device.h" +#include "esphome/core/defines.h" + +#include +#include + +namespace esphome::ble_device_base { + +/// One raw advertisement as delivered by the controller — a borrowed view, +/// valid only for the duration of the invoke() callback. +struct RawAdvertisement { + /// Producers convert their native byte order at the emit site, so no + /// byte-order convention crosses this contract. + uint64_t address; + const uint8_t *data; + uint16_t data_len; + int8_t rssi; // signed dBm + uint8_t addr_type; +}; + +/// Subscriber slot for the raw-advertisement stream (the bluetooth_proxy +/// path). The hub delivers on the ESPHome main loop. Same shape as +/// logger.h's LogCallback: an instance pointer plus a plain function +/// pointer — no virtuals, no std::function. +/// +/// Usage: +/// hub->set_raw_advertisement_callback({this, [](void *self, const RawAdvertisement &adv) { +/// static_cast(self)->on_raw_advertisement(adv); +/// }}); +struct RawAdvertisementCallback { + void *instance{nullptr}; + void (*fn)(void *instance, const RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no subscriber"; hubs must guard on this. + bool is_set() const { return this->fn != nullptr; } + void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } +}; + +/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast +/// directly (pinned by static_asserts at the cast sites). +enum class ScannerState : uint8_t { + IDLE = 0, + STARTING = 1, + RUNNING = 2, + FAILED = 3, + STOPPING = 4, + STOPPED = 5, +}; + +/// Subscriber slot for scanner-state transitions; same shape as +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs +/// that push provide the setter; consumers of the rest poll scan_running(). +struct ScannerStateCallback { + void *instance{nullptr}; + void (*fn)(void *instance, ScannerState state){nullptr}; + bool is_set() const { return this->fn != nullptr; } + void invoke(ScannerState state) const { this->fn(this->instance, state); } +}; + +/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. +struct HubCapabilities { + /// Controller can send scan requests (active scanning). + bool active_scan; + /// Controller (or tracker) delivers advertisement + scan response as one merged + /// frame. When false, consumers relying on scan-response fields (e.g. names) + /// may only see them where the receiver merges per address (Home Assistant does). + bool merges_scan_response; + /// GATT client connections are available: the platform has a + /// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in + /// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client). + /// Today: esp32 and rp2. + bool gatt; + /// request_scan_mode() is honored at runtime. Distinct from active_scan: + /// a passive-only controller can never switch, and a hub may support + /// active scanning yet still refuse the runtime switch (esp32_ble_tracker + /// drives its mode through its own tracker API). + bool scan_mode_switch; +}; + +// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias. +// Semantics beyond the signatures: +// - register_listener: parsed-advertisement consumers (sensors, triggers). +// - set_raw_advertisement_callback: raw stream, one consumer at a time. +// - get_adapter_mac: printable order, out[0] = MSB. +// - scan_active: the current/configured mode sends scan requests. +// - request_scan_mode: false = cannot honor, state untouched (the caller +// reports the real state back); true = applied immediately, restarting a +// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch. +// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback) +// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept +// requires both exactly when that define is set. A push hub must emit a +// transition for every accepted or refused mode request - consumers skip +// their own mode report on push builds. +template +concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback, + uint8_t *mac) { + hub.register_listener(listener); + hub.set_raw_advertisement_callback(raw_callback); + { T::get_capabilities() } -> std::same_as; + hub.get_adapter_mac(mac); + { hub.scan_running() } -> std::same_as; + { hub.scan_active() } -> std::same_as; + { hub.request_scan_mode(true) } -> std::same_as; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + hub.set_scanner_state_callback(ScannerStateCallback{}); + { hub.get_scanner_state() } -> std::same_as; +#endif +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub_impl.h b/esphome/components/ble_device_base/ble_hub_impl.h new file mode 100644 index 0000000000..87214ca7f7 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub_impl.h @@ -0,0 +1,35 @@ +// ble_hub_impl.h +// +// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's +// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header, +// trackers include ble_hub.h (the contract). + +#pragma once + +#include "ble_hub.h" +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE_TRACKER) +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker +#elif defined(USE_RP2_BLE_TRACKER) +#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker +#elif defined(USE_BK72XX_BLE_TRACKER) +#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker +#elif defined(USE_LN882H_BLE_TRACKER) +#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker +#endif +// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub. + +namespace esphome::ble_device_base { + +#ifdef ESPHOME_BLE_HUB_TYPE +using BLEHub = ESPHOME_BLE_HUB_TYPE; +static_assert(BLEHubContract, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)"); +#undef ESPHOME_BLE_HUB_TYPE +#endif + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..2c0d766683 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,153 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + // A partial bind is treated as unbound; never dereference half a binding. + if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..f28790f207 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[MAC_ADDRESS_SIZE]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade98..a7713d9a4b 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -13,14 +13,14 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_presence_ns = cg.esphome_ns.namespace("ble_presence") BLEPresenceDevice = ble_presence_ns.class_( "BLEPresenceDevice", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) @@ -33,23 +33,24 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_presence"), binary_sensor.binary_sensor_schema(BLEPresenceDevice) .extend( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period, cv.Optional(CONF_MIN_RSSI): cv.All( cv.decibel, cv.int_range(min=-100, max=-30) ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds)) if min_rssi := config.get(CONF_MIN_RSSI): @@ -70,20 +71,15 @@ async def to_code(config): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index 4a70648ac5..bc169623ce 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -1,8 +1,6 @@ #include "ble_presence_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_presence { static const char *const TAG = "ble_presence"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_presence"; void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index e17e26ff1c..4e49cc32a3 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -1,15 +1,17 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_presence { class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, + public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { @@ -22,19 +24,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -49,7 +51,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, this->minimum_rssi_ = rssi; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) { return false; } @@ -119,9 +121,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_{0}; uint16_t ibeacon_minor_{0}; @@ -137,5 +139,3 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, }; } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index f678865f47..7c7c7b2148 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -1,8 +1,6 @@ #include "ble_rssi_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_rssi { static const char *const TAG = "ble_rssi"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_rssi"; void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index 8e804ab8e7..a30b94b8b7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_rssi { -class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; @@ -20,19 +22,19 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -47,7 +49,7 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP this->publish_state(NAN); this->found_ = false; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { switch (this->match_by_) { case MATCH_BY_MAC_ADDRESS: if (device.address_uint64() == this->address_) { @@ -109,9 +111,9 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_; uint16_t ibeacon_minor_; @@ -120,5 +122,3 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP }; } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21..43e5813ea2 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -14,11 +14,11 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi") BLERSSISensor = ble_rssi_ns.class_( - "BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener + "BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener ) @@ -31,6 +31,7 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_rssi"), sensor.sensor_schema( BLERSSISensor, unit_of_measurement=UNIT_DECIBEL_MILLIWATT, @@ -42,14 +43,14 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,26 +61,21 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) if mac_address := config.get(CONF_MAC_ADDRESS): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index d85894edc8..3d7a301793 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -1,8 +1,6 @@ #include "ble_scanner.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_scanner { static const char *const TAG = "ble_scanner"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_scanner"; void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c70ee637ef..0efc42682b 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -5,35 +5,25 @@ #include #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/text_sensor/text_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_scanner { -class BLEScanner final : public text_sensor::TextSensor, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - // Escape special characters in the device name for valid JSON - const char *name = device.get_name().c_str(); + // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this + // sensor has always published. char escaped_name[128]; - size_t pos = 0; - for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) { - uint8_t c = static_cast(*name); - if (c == '"' || c == '\\') { - escaped_name[pos++] = '\\'; - escaped_name[pos++] = c; - } else if (c < 0x20) { - pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c); - } else { - escaped_name[pos++] = c; - } - } - escaped_name[pos] = '\0'; + json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false); char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", @@ -45,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor, }; } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/text_sensor.py b/esphome/components/ble_scanner/text_sensor.py index 96d71a0399..0c08e1f734 100644 --- a/esphome/components/ble_scanner/text_sensor.py +++ b/esphome/components/ble_scanner/text_sensor.py @@ -1,25 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, text_sensor +from esphome.components import ble_device_base, text_sensor import esphome.config_validation as cv -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner") BLEScanner = ble_scanner_ns.class_( "BLEScanner", text_sensor.TextSensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_scanner"), text_sensor.text_sensor_schema(BLEScanner) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..fa1a86be3a --- /dev/null +++ b/esphome/components/bluetooth_connection/__init__.py @@ -0,0 +1,203 @@ +"""Per-platform GATT connection backends and the helpers to embed one. + +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import rp2040_ble +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + + +def AUTO_LOAD() -> list[str]: + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.is_rp2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] + return ["ble_device_base"] + + +CODEOWNERS = ["@bdraco", "@jesserockz"] + +bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") + +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and +# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's +# btstack_memory.cpp replaces those pools via linker --wrap (requested by +# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself +# belongs to the platform stack that owns the pools. +RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS + +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. +HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} + +# The hub-platform wrapper and the backend codegen classes. +HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") +RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) +BluedroidGattClient = bluetooth_connection_ns.class_( + "BluedroidGattClient", cg.Component +) + +CONF_BACKEND_ID = "backend_id" + +DOMAIN = "bluetooth_connection" + + +@dataclass +class _ConnectionData: + rp2_backend_count: int = 0 + + +def _get_data() -> _ConnectionData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _ConnectionData() + return CORE.data[DOMAIN] + + +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker + + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} + ) + + +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import ota + + # The backend drops its link when an OTA starts (esp32 tracker parity). + ota.request_ota_state_listeners() + # More than one backend outgrows the prebuilt BTstack pools: swap them for + # the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's + # btstack_memory.cpp. Keyed to backend registrations (the same event that + # grows the count that sizes the pools), so single-backend builds emit no + # flags and stay byte-identical to previous releases. + data = _get_data() + data.rp2_backend_count += 1 + if data.rp2_backend_count == 2: + rp2040_ble.add_btstack_pool_overrides() + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys, and stack + registration. The esp32 fragments import their stack lazily because those + imports register esp32-only automations as a side effect; rp2040_ble is + side-effect-free, so it is imported at module scope (the cap constant + needs it there anyway).""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + + +def _backend_entry(platform: str | None = None) -> _PlatformBackend: + key = platform if platform is not None else CORE.target_platform + if (entry := _PLATFORM_BACKENDS.get(key)) is None: + raise cv.Invalid(f"no GATT client backend is registered for {key}") + return entry + + +def gatt_client_schema(platform: str | None = None) -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. + + Defaults to the platform being validated; pass `platform` explicitly when + building a schema outside validation (the language-schema dumper calls + per-platform builders under arbitrary CORE platforms). + """ + entry = _backend_entry(platform) + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema(platform: str | None = None) -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp new file mode 100644 index 0000000000..a001729083 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -0,0 +1,68 @@ +#include "bluetooth_connection.h" + +#ifdef USE_ESP32 +#include +#include +#endif + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str) { + // Calculate the actual size of just this service (+1 for the field tag) + size_t service_size = resp.services.back().calculate_size() + 1; + + if (current_size + service_size > MAX_PACKET_SIZE) { + if (resp.services.size() > 1) { + // We would go over -- pop the last service and retry it in the next batch + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch", + connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size, + (unsigned) MAX_PACKET_SIZE); + // Don't advance send_service -- the popped service goes into the next batch + } else { + // This single service is too large, but we have to send it anyway; + // advance so we don't get stuck + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str, + send_service, (unsigned) service_size); + send_service++; + } + return BatchClose::SEND; + } + + current_size += service_size; + send_service++; + return BatchClose::CONTINUE; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +namespace esphome::bluetooth_connection { + +// Address-scoped Bluedroid maintenance. Gated with the connection surface: +// the advertisement-only arm no longer dispatches these requests at all. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} + +} // namespace esphome::bluetooth_connection +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h new file mode 100644 index 0000000000..b21d997b4f --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -0,0 +1,169 @@ +// Shared types and helpers for the per-platform GATT connection backends and +// the Bluetooth proxy that drives them. + +#pragma once + +#include "esphome/core/defines.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include +#include + +#ifdef USE_ESP32 +#include +#endif + +// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has +// proxy connection slots": codegen emits it per configured slot, and each +// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not +// the converse: a backend can exist without proxy slots). The hub +// wrapper, the proxy's connection surface and the API's connection messages +// all gate on it. The address-scoped maintenance functions below are only +// reached from that gated surface; the #else stubs just keep this header +// parsing on arms without a backend. + +namespace esphome::api { +class BluetoothGATTGetServicesResponse; +} // namespace esphome::api + +namespace esphome::bluetooth_connection { + +// Connection-owned error type for the API error fields, which are plain +// integers on the wire. Aliases esp_err_t on esp32 (where the values come from +// IDF calls); a bare int elsewhere. Owning the name instead of probing for +// esp_err_t keeps the header independent of how a platform's SDK spells its +// error type. +#ifdef USE_ESP32 +using conn_err_t = esp_err_t; +static constexpr conn_err_t CONN_OK = ESP_OK; +#else +using conn_err_t = int; +static constexpr conn_err_t CONN_OK = 0; +#endif + +// The ESPHome-private "not connected" wire value, shared with the neutral +// GATT contract so backend and wrapper cannot drift. +static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; + +// What the platform's connection backend supports beyond GATT operations; +// the proxy derives its feature flags and legacy version from these. +#if defined(USE_ESP32) +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#else +static constexpr bool SUPPORTS_PAIRING = false; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#endif + +// Address-scoped (not connection-scoped) maintenance requests. +#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT) +conn_err_t unpair_device(uint64_t address); +#else +inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +conn_err_t clear_gatt_cache(uint64_t address); +#else +inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } +#endif + +// send_service_ cursor states; >= 0 is the next service index to stream. +static constexpr int DONE_SENDING_SERVICES = -2; +static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; +// Owed-ack retries stop after ~25 s of subscribed drain time from the first +// refusal, keeping most of the client's 30 s GATT window for congestion to +// clear while still bounding how stale a delivered reply can be. +static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250; + +// ---- Service-streaming size budget, shared by every platform's streamer ---- + +// Conservative MTU limit for API messages (accounts for WPA3 overhead) +static constexpr size_t MAX_PACKET_SIZE = 1360; + +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +/// Estimate the wire size of a service (service overhead + its characteristics, +/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be +/// safe) before fetching/packing the full data. +inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count; +} + +// ---- UUID wire packing, shared by every platform's streamer ---- + +// This function is allocation-free and directly packs UUIDs into the output +// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID +// stores its 128-bit form little-endian (same as Bluedroid). +inline void fill_128bit_uuid_array(std::array &out, const ble_device_base::ESPBTUUID &uuid) { + using ble_device_base::ESPBTUUID; + if (uuid.type() == ESPBTUUID::Type::UUID128) { + const uint8_t *u = uuid.uuid128(); + // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) + out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | + ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); + out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | + ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + return; + } + // 16/32-bit UUID inserted into the Bluetooth base UUID: + // 00000000-0000-1000-8000-00805F9B34FB + uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32(); + out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11 + out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7 +} + +/// Fill the UUID in the appropriate wire format based on client support and +/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form +/// otherwise). +inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, + const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) { + using ble_device_base::ESPBTUUID; + if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) { + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.type() == ESPBTUUID::Type::UUID16) { + short_uuid = uuid.uuid16(); + } else { + short_uuid = uuid.uuid32(); + } +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +/// Result of close_service_batch: keep filling the batch or send it now. +/// An oversized service is packed alone; a failed (backpressured) send is +/// retried from the batch start, so no service is silently skipped. +enum class BatchClose : uint8_t { CONTINUE, SEND }; + +/// Close out the service just packed into resp (account its actual wire size, +/// advance the cursor) and decide whether the batch must be sent now. Shared +/// tail of both platform streamers so the budget logic and its log lines +/// cannot drift. +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str); +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +} // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..076c77b18e --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +#include "bluetooth_connection.h" +#include "bluetooth_connection_hub.h" + +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.bluedroid"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_disconnect(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears down. + if (this->state() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } + ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); + this->remote_addr_type_ = addr_type; + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = false; + this->enable_loop(); + this->set_state(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, + MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT)); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, + static_cast(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_disconnect() { + auto st = this->state(); + if (st == ClientState::DISCONNECTING) { + return 0; + } + // Nothing was opened, so no completion event will follow: report + // not-connected and the hub frees the slot at once (rp2 convention). + if (st == ClientState::IDLE) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::DISCOVERED) { + // Parked for the tracker promote loop, never opened. + this->set_idle_(); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); +} + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +void BluedroidGattClient::release_services() { + this->service_total_ = 0; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params)); +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- service streaming ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + uint16_t primary = 0; + uint16_t secondary = 0; + auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, + 0x0001, 0xFFFF, 0, &primary); + auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, + 0x0001, 0xFFFF, 0, &secondary); + if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) { + // A failed count must not become an authoritative empty database. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +// Bound by the SERVICE STREAMING HAZARD note at the top of +// bluetooth_connection_hub.cpp: never skip a batch, never send done early. +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + this->release_services(); + conn.send_services_done_(); + return; + } + + // The subscriber vanished mid-stream. + auto *api_conn = conn.proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); + conn.park_service_stream_(); + return; + } + + bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids(); + api::BluetoothGATTGetServicesResponse resp; + resp.address = conn.address_; + size_t current_size = resp.calculate_size(); + int16_t batch_start = conn.send_service_; + + while (conn.send_service_ < this->service_total_) { + esp_gattc_service_elem_t service_result; + uint16_t svc_count = 1; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != ESP_GATT_OK || svc_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_, + conn.address_str_, conn.send_service_); + conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); + if (char_count_status != ESP_GATT_OK) { + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + return; + } + + // If this service likely won't fit, send the current batch first. + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + service_resp.handle = service_result.start_handle; + + if (total_char_count > 0) { + service_resp.characteristics.init(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + // Bounded by the count query: a misbehaving peripheral can make the + // enumeration return more entries than it reported. + while (char_offset < total_char_count) { + uint16_t cc = 1; + auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (char_status != ESP_GATT_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + + uint16_t total_desc_count = 0; + auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, + 0, 0, char_result.char_handle, &total_desc_count); + if (desc_count_status != ESP_GATT_OK) { + // Abort rather than stream the characteristic descriptor-less: a + // missing CCCD in a cached database breaks notifications for good. + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status); + conn.abort_service_stream(desc_count_status); + return; + } + if (total_desc_count > 0) { + characteristic_resp.descriptors.init(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (desc_offset < total_desc_count) { + uint16_t dc = 1; + auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, + &desc_result, &dc, desc_offset); + if (desc_status != ESP_GATT_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } + char_offset++; + } + } + + if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // On a failed send, rewind the cursor so the batch is retried instead of + // silently skipped. + if (!api_conn->send_message(resp)) { + conn.note_batch_stalled_(); + conn.send_service_ = batch_start; + return; + } + conn.batch_stalled_ = false; +} +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays set + // so CLOSE_EVT still matches). + this->unconditional_disconnect_(); + return; + } + this->set_state(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state(ClientState::ESTABLISHED); + // No discovery phase: report immediately with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state() == ClientState::IDLE) { + // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. + return; + } + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. + this->release_services(); + this->set_disconnecting_(); +} + +bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, + esp_ble_gattc_cb_param_t *param) { + if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + return false; + this->release_services(); + this->set_idle_(); + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. + this->listener_->on_connection_state(false, 0, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + break; + } + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: { + if (this->conn_id_ != param->read.conn_id) + return false; + bool ok = param->read.status == ESP_GATT_OK; + this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, + ok ? param->read.value_len : 0, ok ? 0 : param->read.status); + break; + } + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: { + if (this->conn_id_ != param->write.conn_id) + return false; + this->listener_->on_write_result(param->write.handle, + param->write.status == ESP_GATT_OK ? 0 : param->write.status); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); + break; + } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state( + param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (this->conn_id_ != param->notify.conn_id) + return false; + ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); + this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept; a refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + break; + } + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + this->listener_->on_pairing_result( + param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..0d0b4fed5b --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + // Wired by codegen before setup and invariant for the device lifetime. + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- esp32_ble_tracker::ESPBTClient ---- + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. + void stream_service_batch(BluetoothConnection &conn); +#endif + + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + + protected: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + int check_and_log_error_(const char *operation, esp_err_t err); + void log_gattc_warning_(const char *operation, int code); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. + uint8_t remote_addr_type_{0}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..3c982d81ae --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -0,0 +1,67 @@ +// bluetooth_connection_gatt_backend.h +// +// Binds ble_device_base::BLEGattConnection to the build's one GATT backend. +// Backend and consumer both live in this component, so the ladder does too; +// backends implement ble_gatt_client.h (the neutral contract). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#if defined(USE_RP2040_BLE) +#include "bluetooth_connection_rp2.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32_BLE) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient +#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) +// Emitted only by the host unit-test manifest: the tests compile the hub +// wrapper standalone, so bind a do-nothing backend. Every other backend-less +// build hits the #error below. +namespace esphome::bluetooth_connection { + +class StubGattBackend { + public: + void set_listener(ble_device_base::GattClientListener *listener) {} + int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } + int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + ble_device_base::GattServiceTable get_service_table() { return {}; } + void set_connection_type(ble_device_base::ConnectionType ct) {} + void release_services() {} +}; + +} // namespace esphome::bluetooth_connection +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend +#else +#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here" +#endif + +namespace esphome::ble_device_base { + +using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; +static_assert(BLEGattConnectionContract, + "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); +#undef ESPHOME_BLE_GATT_CONNECTION_TYPE + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp new file mode 100644 index 0000000000..c8f97f207e --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -0,0 +1,567 @@ +// The proxy's per-slot connection wrapper, shared by every platform. +// +// SERVICE STREAMING HAZARD - read before touching the streaming code here or +// in the platform streamers (bluetooth_connection_bluedroid.cpp). +// +// A V3 client caches the service list it receives as the device's complete, +// permanent database. Nothing on the wire marks a list as partial, so a +// stream that is truncated, has a skipped batch, or is terminated early +// would be cached whole and poison every later session with the device. +// +// The rule: it is always better to send nothing and let the client time out +// than to let services-done follow an incomplete stream. Concretely: +// - a refused batch rewinds the cursor and is retried, never skipped; +// - services-done is sent only after every batch was accepted; +// - every interruption (subscriber lost or swapped, backend abort, +// bounds-check failure) parks or aborts WITHOUT services-done and drops +// any owed done; +// - a new GetServices supersedes an owed done, so a stale done can never +// land on a fresh request's empty accumulator and cache it as empty. +// The client only caches a list terminated by services-done within the same +// request; timeouts, disconnects and errors raise instead of caching. +#include "bluetooth_connection_hub.h" + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +void BluetoothConnection::set_address(uint64_t address) { + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); + // Slot changing hands: anything owed belonged to the old address. The + // choke point for every reassignment, not just reset_connection_()'s path. + this->clear_owed_flags_(); + this->address_ = address; + if (address == 0) { + this->address_str_[0] = '\0'; + return; + } + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + format_mac_addr_upper(mac, this->address_str_); +} + +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // No connect timeout here: the API client's own timeout or the api-gone + // sweep drives disconnect(). + this->state_ = ClientState::CONNECTING; + int err = this->backend_->connect(this->address_, address_type); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + } +} + +void BluetoothConnection::disconnect() { + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. + if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { + return; + } + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + return; + } + this->state_ = ClientState::DISCONNECTING; +} + +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + +void BluetoothConnection::reset_connection_(conn_err_t reason) { + if (this->pending_error_ != 0) { + reason = this->pending_error_; + this->pending_error_ = 0; + } + this->state_ = ClientState::IDLE; + this->services_discovered_ = false; + this->paired_ = false; + // Link gone: the slot may hold a different device before the drain runs. + this->clear_owed_flags_(); + this->backend_->release_services(); + this->proxy_->reset_connection_slot_(this, reason); +} + +// ---- backend event listener ---- + +void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { + if (connected && this->address_ == 0) { + // Late completion for a slot that was already freed: nothing to report, + // and the api-gone sweep or a new reservation owns the slot now. + // Return ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); + return; + } + if (connected && this->state_ == ClientState::DISCONNECTING) { + // The link came up after a disconnect request won the race; finish the + // teardown instead of reporting a connection the client no longer wants. + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nothing left to tear down after all. + this->reset_connection_(err); + } + return; + } + if (connected) { + this->mtu_ = mtu; + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + // The API client has the services cached; never discover them. No + // discovery phase needs the fast interval, so settle straight into the + // shared steady-state parameters. Both backends already open cached + // connections with these values (esp32 prefer-params, rp2 initiating + // params), so this request is normally redundant - kept as a backstop + // in case the initial parameters were negotiated away. + this->state_ = ClientState::ESTABLISHED; + // The one D-level line for a cached connect; the uncached path narrates + // through "Discovery finished" instead. + ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_, + this->address_str_, mtu); + int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, + ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, + ble_device_base::MEDIUM_CONN_TIMEOUT); + if (param_err != 0) { + // Survivable: the link just stays on the fast interval. + ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, + param_err); + } + this->send_connected_reply_(); + this->proxy_->send_connections_free(); + return; + } + // V3_WITHOUT_CACHE: discover services first — the connected response is + // sent when discovery completes (MTU + services before the response). + this->state_ = ClientState::CONNECTED; + int err = this->backend_->discover_services(); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); + // Latch the real cause for the disconnect report. + this->latch_pending_error_(err); + this->disconnect(); + } + return; + } + // Disconnected, connect failed, or teardown complete + if (this->address_ == 0) { + return; // Slot already freed + } + ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, + error); + this->reset_connection_(error); +} + +void BluetoothConnection::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); + // Carry the GATT error into the disconnection report so the client sees + // the real cause instead of a generic HCI reason. + this->latch_pending_error_(error); + this->disconnect(); + return; + } + ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_, + this->mtu_); + this->state_ = ClientState::ESTABLISHED; + this->services_discovered_ = true; + this->send_connected_reply_(); + this->proxy_->send_connections_free(); +} + +void BluetoothConnection::flush_owed_replies_() { + // Connected first: the client should never see services-done or an ack for + // a link it has not been told is up. Structural, not size-dependent: a + // still-owed connected reply defers the smaller sends to the next tick. + if (this->connected_reply_owed_) { + this->send_connected_reply_(); + if (this->connected_reply_owed_) { + // The retry limits are wall-clock windows: age the deferred budgets so + // a reply cannot outlive the window it was sized for. + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->age_services_done_(); + } + if (this->has_pending_ack_()) { + this->age_pending_ack_(); + } + return; + } + } + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_services_done_(); + } + if (this->has_pending_ack_()) { + this->flush_pending_ack_(); + } +} + +void BluetoothConnection::send_connected_reply_() { + if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) { + this->connected_reply_owed_ = false; + return; + } + // Warn on the leading edge only, as elsewhere: the drop must be visible but + // must not add traffic to the connection that just refused a frame. + if (!this->connected_reply_owed_) { + ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_); + this->connected_reply_owed_ = true; + } +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, + operation, handle, status); +} + +void BluetoothConnection::note_batch_stalled_() { + if (this->batch_stalled_) + return; + this->batch_stalled_ = true; + ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_, + this->address_str_); +} + +/// Both payload-free acks are just (address, handle); only the type differs. +template +static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) { + Response resp; + resp.address = address; + resp.handle = handle; + return api_connection->send_message(resp); +} + +/// Sole construction site, so a re-offer cannot drift from the original. +bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (kind == PendingAck::PENDING_ACK_ERROR) { + // Proxy owns the error reply and reports a refusal the same way. + return this->proxy_->send_gatt_error(this->address_, handle, error); + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return true; // Nobody subscribed: nothing is owed + switch (kind) { + case PendingAck::PENDING_ACK_WRITE: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NOTIFY: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NONE: + case PendingAck::PENDING_ACK_ERROR: // returned above + return true; + } + // No default label above, so a new enumerator is a -Wswitch warning rather + // than a silent notify reply. This return only satisfies -Wreturn-type. + return true; +} + +void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (this->try_send_ack_(kind, handle, error)) + return; + // Report a newly owed reply and a displaced one; displacing is the case + // that loses a reply. Re-refusing the same one stays quiet. + if (!this->has_pending_ack_()) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_, + this->address_str_, handle); + } else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_, + this->address_str_, this->pending_ack_handle_, handle); + } + this->latch_pending_ack_(kind, handle, error); +} + +void BluetoothConnection::flush_pending_ack_() { + if (!this->has_pending_ack_()) + return; + if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { + this->clear_pending_ack_(); + return; + } + this->age_pending_ack_(); +} + +void BluetoothConnection::age_pending_ack_() { + if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { + // Undeliverable: past here the client has given up and may have re-asked, + // and a late reply would answer the new request instead of this one. + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_, + this->address_str_, this->pending_ack_handle_); + this->clear_pending_ack_(); + } +} + +void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { + // Late completion for a freed slot; nothing to report. + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("reading char/descriptor", handle, error); + this->send_gatt_error_(handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTReadResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + // Not latched: would mean holding the payload through the congestion + // that refused it. The client's read timeout arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_write_result(uint16_t handle, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("writing char/descriptor", handle, error); + this->send_gatt_error_(handle, error); + return; + } + this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle); +} + +void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, + error); + this->send_gatt_error_(handle, error); + return; + } + this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle); +} + +void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->address_ == 0) + return; + ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle); + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyDataResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + // Not latched, same reason as the read reply. Notify data is lossy: the + // peripheral will not resend it. + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); + } +} + +// ---- GATT operations ---- + +conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const { + if (this->connected()) { + return CONN_OK; + } + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action, + type); + return GATT_NOT_CONNECTED; +} + +conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); + if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_characteristic(handle); +} + +conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); + if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_characteristic(handle, data, static_cast(length), response); +} + +conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); + if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_descriptor(handle); +} + +// The neutral backend contract performs descriptor writes acknowledged, so +// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). +conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, + bool /*response*/) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); + if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_descriptor(handle, data, static_cast(length)); +} + +conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY); + if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, + enable ? "Registering for" : "Unregistering for", handle); + return this->backend_->notify_characteristic(handle, enable); +} + +conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK) + return err; + return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout); +} + +// ---- Service streaming ---- + +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else { + this->age_services_done_(); + } +} + +void BluetoothConnection::age_services_done_() { + if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + +void BluetoothConnection::send_service_for_discovery_() { + auto table = this->backend_->get_service_table(); + if (this->send_service_ >= table.service_count) { + this->backend_->release_services(); + this->send_services_done_(); + return; + } + + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. + auto *api_conn = this->proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, + this->address_str_); + this->park_service_stream_(); + return; + } + + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); + + // Prepare response + api::BluetoothGATTGetServicesResponse resp; + resp.address = this->address_; + + // Dynamic batching based on actual size, same contract as the esp32 streamer + size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; + + while (this->send_service_ < table.service_count) { + const auto &service = table.services[this->send_service_]; + + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids); + service_resp.handle = service.start_handle; + + // Bounds-check the backend's index ranges against the table totals rather + // than trusting its discovery bookkeeping blindly. A miscounted non-empty + // range must not stream a truncated database as authoritative (V3 clients + // cache it permanently): abort and tear the connection down; the client + // times out and retries. Empty ranges are tolerated regardless of index. + uint16_t char_count = service.characteristic_count; + if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { + ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); + return; + } + if (char_count > 0) { + service_resp.characteristics.init(char_count); + for (uint16_t ci = 0; ci < char_count; ci++) { + const auto &chr = table.characteristics[service.first_characteristic + ci]; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids); + characteristic_resp.handle = chr.value_handle; + characteristic_resp.properties = chr.properties; + uint16_t desc_count = chr.descriptor_count; + if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { + ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); + return; + } + if (desc_count == 0) { + continue; + } + characteristic_resp.descriptors.init(desc_count); + for (uint16_t di = 0; di < desc_count; di++) { + const auto &desc = table.descriptors[chr.first_descriptor + di]; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids); + descriptor_resp.handle = desc.handle; + } + } + } + + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped + // (bounded: a subscriber that stays gone ends streaming via the api-lost + // rewind above). + if (!api_conn->send_message(resp)) { + this->note_batch_stalled_(); + this->send_service_ = batch_start; + return; + } + this->batch_stalled_ = false; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h new file mode 100644 index 0000000000..f87d545f7d --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -0,0 +1,274 @@ +// BluetoothConnection: drives the build's GATT backend (the +// ble_device_base::BLEGattConnection alias) and translates its events into +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. + +#pragma once + +#include "bluetooth_connection.h" + +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "bluetooth_connection_gatt_backend.h" +#include "esphome/core/helpers.h" + +namespace esphome::bluetooth_proxy { +class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { + +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; + +/// A refused GATT reply owed to the current subscriber. Payload-free only: +/// these rebuild from address + handle + error, so a retry costs no buffered +/// data. Read and notify-data carry payloads and are deliberately absent. +enum class PendingAck : uint8_t { + PENDING_ACK_NONE = 0, + PENDING_ACK_WRITE, + PENDING_ACK_NOTIFY, + PENDING_ACK_ERROR, +}; + +class BluetoothConnection final : public ble_device_base::GattClientListener { + public: + /// Wire the platform backend. Called from codegen before setup. + void set_backend(ble_device_base::BLEGattConnection *backend) { + this->backend_ = backend; + backend->set_listener(this); + } + + // ---- proxy dispatch surface ---- + conn_err_t read_characteristic(uint16_t handle); + conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t read_descriptor(uint16_t handle); + conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t notify_characteristic(uint16_t handle, bool enable); + conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); + void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } + + void set_address(uint64_t address); + uint64_t get_address() const { return this->address_; } + const char *address_str() const { return this->address_str_; } + uint8_t get_connection_index() const { return this->connection_index_; } + + ClientState state() const { return this->state_; } + void set_state(ClientState st) { this->state_ = st; } + bool connected() const { return this->state_ == ClientState::ESTABLISHED; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // Both backends branch on the type before connecting (bluedroid picks + // prefer-params and the with-cache report at OPEN_EVT; rp2 picks the + // initiating parameters), so this must be set before the connect starts. + this->backend_->set_connection_type(ct); + } + // Latched at discovery completion rather than read from the backend table: + // streaming frees the table, and this must stay true for the connection's + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). + bool has_gatt_services() const { return this->services_discovered_; } + + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). + void process_pending_services() { + if (this->send_service_ >= 0) { + this->stream_pending_(this->backend_); + } + } + + // ---- backend event listener (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; + + protected: + friend class bluetooth_proxy::BluetoothProxy; + // The Bluedroid backend streams services in place from its stack cache. + friend class BluedroidGattClient; + + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + + /// Latch a refused reply for the proxy drain. One slot per connection, + /// newest wins: a GATT client works one request at a time, and a discarded + /// reply falls back to the timeout it would have hit anyway. + void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) { + this->pending_ack_retries_ = 0; + this->pending_ack_ = kind; + this->pending_ack_handle_ = handle; + this->pending_ack_error_ = error; + } + void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; } + /// Drop an owed reply this re-ask makes stale. Clients match futures on + /// response type as well as handle, so an owed error (which resolves any op + /// on the handle) is cleared by any re-ask, other kinds only by their own. + void supersede_pending_ack_(uint16_t handle, PendingAck kind) { + if (this->has_pending_ack_() && this->pending_ack_handle_ == handle && + (this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) { + this->clear_pending_ack_(); + } + } + bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; } + /// Warn on the stall's leading edge only. The batch is never lost (the + /// caller rewinds the cursor), and a warning per attempt would add traffic + /// to the connection already refusing frames. Both streamers route here. + void note_batch_stalled_(); + /// Send the connected=true reply, latching it if the API refuses. Rebuilt + /// from address_ and mtu_, so the latch is one bit; a dropped confirmation + /// leaves the client timing out while this slot holds a live link. No retry + /// bound: the slot's lifetime is the bound (teardown clears the flag). + void send_connected_reply_(); + /// Re-offer everything this slot owes. One entry point so the proxy drain + /// does not have to know which latches exist. + void flush_owed_replies_(); + /// Drop everything this slot owes, in one write to the shared tail byte. + void clear_owed_flags_() { + this->pending_ack_ = PendingAck::PENDING_ACK_NONE; + this->batch_stalled_ = false; + this->connected_reply_owed_ = false; + } + /// Sole construction site for these replies, shared by send and retry. + bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); + /// First attempt: send, and latch it for the drain if the API refuses. + void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0); + /// Report a rejected request. Latched like a completion reply, so a + /// refused frame does not strand the client for its whole timeout. + void send_gatt_error_(uint16_t handle, conn_err_t error) { + this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error); + } + /// Re-offer the owed reply; clears on success, stays owed on a refusal. + void flush_pending_ack_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_pending_ack_(); + // A backend providing its own streamer (see the contract doc) builds the + // response in place from its stack cache; the rest use the table streamer. + // Template so the discarded branch is not odr-checked against backends + // that lack the method. + template void stream_pending_(Backend *backend) { + if constexpr (requires { backend->stream_service_batch(*this); }) { + backend->stream_service_batch(*this); + } else { + this->send_service_for_discovery_(); + } + } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + this->batch_stalled_ = false; + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } + void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_services_done_(); + void reset_connection_(conn_err_t reason); + conn_err_t check_connected_op_(const char *action, const char *type) const; + void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); + + // Memory optimized layout for 32-bit systems + // Group 1: Pointers (4 bytes each, naturally aligned) + bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; + ble_device_base::BLEGattConnection *backend_{nullptr}; + + // Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays + // 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8). + int16_t send_service_{INIT_SENDING_SERVICES}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; + + // Group 3: 8-byte and 4-byte types + uint64_t address_{0}; + conn_err_t pending_error_{0}; + // Full width: the GATT error domain is open-ended (ble_gatt_client.h) and + // forwarded untranslated, so narrowing would corrupt platform codes. + conn_err_t pending_ack_error_{0}; + + // Group 4: Arrays + char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + // Parked here rather than in Group 2: address_str_ ends 2-aligned, so this + // uses tail slack instead of pushing address_ out by 6 bytes of padding. + uint16_t pending_ack_handle_{0}; + + // Group 5: bit-packed tail. The first two bytes were already full, so the + // first added bit forced a third and took the 8-aligned object 48 -> 56; + // the handle, error and retry counter ride in that padding. Four bitfield + // bits left; another byte-sized member costs 8 per slot. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the first two tail bytes exactly. + ClientState state_ : 3 {ClientState::IDLE}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; + uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; + bool services_discovered_ : 1 {false}; + static_assert(static_cast(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow"); + PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; + /// Set while a refused batch is retrying, so only the first one warns. + bool batch_stalled_ : 1 {false}; + /// An owed connected=true reply; the proxy's paced drain re-offers it. + bool connected_reply_owed_ : 1 {false}; + // Plain byte after the bitfields: takes the padding byte instead of + // straddling pending_ack_'s storage unit and growing the object. + static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); + uint8_t pending_ack_retries_{0}; +}; + +// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad +// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit. +static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, + "BluetoothConnection layout regressed on a 32-bit target"); + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp new file mode 100644 index 0000000000..855c895196 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -0,0 +1,1287 @@ +#include "bluetooth_connection_rp2.h" + +#include "bluetooth_connection.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.rp2"; + +using ble_device_base::ESPBTUUID; +using ble_device_base::GATT_ERR_NOT_CONNECTED; +using ble_device_base::GATT_ERR_NO_MEMORY; + +// Engine-owned timeouts: BTstack has a 30 s ATT transaction timeout but no +// connect timeout — a stuck LE_CONNECTING both blocks future gap_connect calls +// and keeps the scan inhibited, so the engine cancels after 20 s. The +// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +// Budget after a cancel is in flight: its completion normally lands within +// tens of ms, and while the engine waits it pins the stack-wide connect slot, +// so a lost completion must cost seconds, not another full connect budget. +static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000; +// Pending engines re-attempt gap_connect on this cadence instead of every +// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock. +static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50; +// Can-send windows normally open within a connection interval (tens of ms). +static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; + +// HCI "connection timeout" reason, reported when a teardown had to be forced. +static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; + +// Initiating-scan parameters and connection-event lengths for outgoing +// connections (BTstack-specific knobs; the connection intervals themselves are +// the shared FAST/MEDIUM parameters from ble_device_base/ble_client_state.h, +// used in the same lifecycle places as esp32: FAST for connect and service +// discovery, MEDIUM once established). +static constexpr uint16_t CONN_SCAN_INTERVAL = 96; // 60 ms in 0.625 ms units +static constexpr uint16_t CONN_SCAN_WINDOW = 48; // 30 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MIN = 16; // 10 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MAX = 48; // 30 ms in 0.625 ms units + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; +uint8_t RP2GattClient::instance_count = 0; +btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; +RP2GattClient *RP2GattClient::connect_owner = nullptr; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { + if (uuid16 != 0) { + return ESPBTUUID::from_uint16(uuid16); + } + // BTstack structs carry the 128-bit form big-endian (printable order). + return ESPBTUUID::from_raw_reversed(uuid128); +} + +void RP2GattClient::setup() { + // Pre-create every pool entry so the packet handlers' allocate() calls are + // always a free-list pop -- the IRQ path must never reach malloc(). + if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { + ESP_LOGE(TAG, "GATT event pool warm-up failed"); + this->mark_failed(); + return; + } + + // Register this engine for IRQ-context event routing. + if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { + // Cannot happen with codegen-sized storage; refuse loudly if it ever does. + ESP_LOGE(TAG, "GATT client registry full"); + this->mark_failed(); + return; + } + { + // One locked section: the slot store lands before the count bump, and a + // live HCI handler (N > 1 builds) cannot read a half-written registry. + BluetoothLock lock; + this->engine_index_ = instance_count; + instances[instance_count] = this; + instance_count++; + // One HCI event handler for all engine instances (BTstack supports + // multiple registrations, so rp2040_ble's own handler is unaffected). + if (hci_event_registration.callback == nullptr) { + hci_event_registration.callback = &RP2GattClient::hci_packet_handler; + hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); + } + } + +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + + this->disable_loop(); +} + +#ifdef USE_OTA_STATE_LISTENER +void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + // esp32 parity (its tracker disconnects every client at OTA start): free + // the shared radio for the transfer. No restore needed; the client + // reconnects, and on success the device reboots anyway. + if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) { + this->gatt_disconnect(); + } +} +#endif + +float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } + +void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } + +// ---- IRQ-context handlers: copy-and-enqueue only ---- + +RP2GattClient *RP2GattClient::instance_for_con_handle(hci_con_handle_t con_handle) { + for (uint8_t i = 0; i < instance_count; i++) { + if (instances[i]->con_handle_ == con_handle) { + return instances[i]; + } + } + return nullptr; +} + +void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + switch (event_type) { + case HCI_EVENT_META_GAP: { + if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { + break; + } + uint8_t status = gap_subevent_le_connection_complete_get_status(packet); + hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + // Route by ownership, not address: gap_connect refuses a new + // create-connection until the previous completion is processed, so the + // event belongs to the owner by construction. Cancel completions carry + // a zeroed peer address on this controller, so an address match would + // drop them and pin the owner until its backstop. + RP2GattClient *inst = connect_owner; + static constexpr bd_addr_t ZERO_ADDR = {}; + if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 && + memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) { + // Addressed completion for a peer the owner is not connecting to: a + // success delayed past a cancel and an ownership handoff (the cancel + // idles the stack's request immediately) must not stamp the old + // procedure's link onto the new owner. Zero-address (cancel) + // completions need no such guard: BTstack only emits them while its + // request state is idle, and a new owner re-arms that state when it + // claims the token, so a stale cancel completion is swallowed by the + // stack, never re-attributed. A successful stale link still needs + // disposal (same hazard as the unowned branch below). + if (status == 0) { + gap_disconnect(con_handle); + } + break; + } + connect_owner = nullptr; + if (inst == nullptr) { + if (status == 0) { + // Nobody owns this late link (the owner escalated first): tear it + // down here or the hci_connection_t leaks and the peer answers + // DISALLOWED until reboot. + gap_disconnect(con_handle); + } + break; + } + if (status == 0) { + // Stamp the handle here in the BTstack context: a disconnection + // racing the queued CONNECTED event arrives in this same context + // and must route by handle (it carries no address). + inst->con_handle_ = con_handle; + } + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); + break; + } + case HCI_EVENT_DISCONNECTION_COMPLETE: { + // Routable even against a still-queued CONNECTED event: the handle is + // stamped in this context at connection-complete time. + RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + // Every GATT event carries the connection handle in the same position via + // its accessor; route on it. + hci_con_handle_t con_handle; + switch (event_type) { + case GATT_EVENT_MTU: + con_handle = gatt_event_mtu_get_handle(packet); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: + con_handle = gatt_event_service_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: + con_handle = gatt_event_characteristic_query_result_get_handle(packet); + break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: + con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_value_query_result_get_handle(packet); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_descriptor_query_result_get_handle(packet); + break; + case GATT_EVENT_NOTIFICATION: + con_handle = gatt_event_notification_get_handle(packet); + break; + case GATT_EVENT_INDICATION: + con_handle = gatt_event_indication_get_handle(packet); + break; + case GATT_EVENT_QUERY_COMPLETE: + con_handle = gatt_event_query_complete_get_handle(packet); + break; + default: + return; + } + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst != nullptr) { + inst->handle_gatt_event_irq_(event_type, packet); + } +} + +void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet) { + switch (event_type) { + case GATT_EVENT_MTU: + this->enqueue_event_irq_(RP2GattEvent::MTU_EXCHANGED, 0, gatt_event_mtu_get_MTU(packet)); + break; + case GATT_EVENT_QUERY_COMPLETE: + this->enqueue_event_irq_(RP2GattEvent::QUERY_COMPLETE, gatt_event_query_complete_get_att_status(packet), 0); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->service_count_ >= RP2_GATT_MAX_SERVICES) { + this->truncated_ = true; + break; + } + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + auto &dst = this->arena_->services[this->service_count_]; + dst.uuid = uuid_from_btstack(service.uuid16, service.uuid128); + dst.start_handle = service.start_group_handle; + dst.end_handle = service.end_group_handle; + dst.first_characteristic = 0; + dst.characteristic_count = 0; + this->service_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->char_count_ >= RP2_GATT_MAX_CHARACTERISTICS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + auto &dst = this->arena_->characteristics[this->char_count_]; + dst.uuid = uuid_from_btstack(characteristic.uuid16, characteristic.uuid128); + dst.value_handle = characteristic.value_handle; + dst.end_handle = characteristic.end_handle; + dst.properties = static_cast(characteristic.properties); + dst.first_descriptor = 0; + dst.descriptor_count = 0; + this->char_count_++; + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->desc_count_ >= RP2_GATT_MAX_DESCRIPTORS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + auto &dst = this->arena_->descriptors[this->desc_count_]; + dst.uuid = uuid_from_btstack(descriptor.uuid16, descriptor.uuid128); + dst.handle = descriptor.handle; + this->desc_count_++; + break; + } + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + // One blob per event at the reported offset; assemble into the op buffer. + this->assemble_blob_irq_(gatt_event_long_characteristic_value_query_result_get_value_offset(packet), + gatt_event_long_characteristic_value_query_result_get_value(packet), + gatt_event_long_characteristic_value_query_result_get_value_length(packet)); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + this->assemble_blob_irq_(gatt_event_long_characteristic_descriptor_query_result_get_descriptor_offset(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor_length(packet)); + break; + case GATT_EVENT_NOTIFICATION: + this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), + gatt_event_notification_get_value(packet), + gatt_event_notification_get_value_length(packet)); + break; + case GATT_EVENT_INDICATION: + // BTstack auto-confirms indications; deliver like a notification. + this->enqueue_notify_irq_(gatt_event_indication_get_value_handle(packet), gatt_event_indication_get_value(packet), + gatt_event_indication_get_value_length(packet)); + break; + default: + break; + } +} + +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len) { + if (offset >= RP2_GATT_MAX_ATTR_LEN) { + return; + } + if (len > RP2_GATT_MAX_ATTR_LEN - offset) { + len = RP2_GATT_MAX_ATTR_LEN - offset; + } + memcpy(this->op_buffer_ + offset, data, len); + if (offset + len > this->op_len_) { + this->op_len_ = offset + len; + } +} + +void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { + RP2GattEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + this->event_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); + return; + } + event->type = type; + event->status = status; + event->value = value; + this->event_queue_.push(event); + this->enable_loop_soon_any_context(); +} + +void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { + RP2GattNotifyEvent *event = this->notify_pool_.allocate(); + if (event == nullptr) { + this->notify_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); + return; + } + event->handle = handle; + event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; + memcpy(event->data, data, event->len); + this->notify_queue_.push(event); + this->enable_loop_soon_any_context(); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +// ---- Main-loop state machine ---- + +void RP2GattClient::loop() { + RP2GattEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + RP2GattEvent copy = *event; + this->event_pool_.release(event); + this->handle_event_(copy); + } + + RP2GattNotifyEvent *notify; + while ((notify = this->notify_queue_.pop()) != nullptr) { + if (this->notify_subscribed_(notify->handle)) { + this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + } + this->notify_pool_.release(notify); + } + + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + // Control events must not be lost; the connection state is no longer + // trustworthy — recover with a forced teardown. + ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped); + this->gatt_disconnect(); + } + uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); + if (notify_dropped > 0) { + ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped); + } + + if (this->state_ == EngineState::CONNECT_PENDING) { + uint32_t now = millis(); + if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { + // Never reached the radio; nothing stack-side to cancel. + ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_); + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) { + this->connect_retry_ms_ = now; + if (int err = this->try_gap_connect_(); err != 0) { + this->fail_connection_(static_cast(err)); + } + } + } else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID && + this->connect_cancel_attempted_; + uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS; + if (now - this->connect_started_ > budget) { + ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_); + bool link_up = this->state_ != EngineState::CONNECTING; + bool cancel_sent = false; + if (!link_up) { + BluetoothLock lock; + // Handle check under the lock: a success completion can stamp it in + // the BTstack context right up to this point, and escalating past a + // live link would orphan it (the queued CONNECTED event is dropped + // by the state guard once fail_connection_ runs). + link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID; + if (!link_up && connect_owner == this) { + // gap_connect_cancel is stack-global; only the engine whose + // create-connection is in flight may issue it. First timeout: + // cancel and give the completion a grace period. Second: the + // completion was lost, re-issue the cancel in case the procedure + // still runs (a no-op on an idle stack), then escalate. + gap_connect_cancel(); + cancel_sent = !this->connect_cancel_attempted_; + } + this->connect_cancel_attempted_ = true; + } + if (link_up) { + // The link is up (stamped mid-timeout or MTU exchange stalled): tear + // it down properly so the controller frees its side; the + // DISCONNECTING safety net below reclaims state if the disconnection + // event is lost. Dropping engine state without gap_disconnect would + // leak the live link and this engine's GATT slot for the rest of the + // boot. + this->gatt_disconnect(); + } else if (cancel_sent) { + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer so + // a lost event escalates on the short cancel budget. + this->connect_started_ = now; + } else { + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } + } + } else if (this->state_ == EngineState::DISCONNECTING) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_); + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && + millis() - this->write_no_rsp_started_ > WRITE_NO_RSP_TIMEOUT_MS) { + // The can-send window never opened; report instead of hanging the op slot. + bool timed_out = false; + { + BluetoothLock lock; + // The trampoline may have just sent it; its queued result wins. + if (this->event_queue_.empty()) { + this->op_type_ = OpType::NONE; + timed_out = true; + } + } + if (timed_out) { + ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_); + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); + } + } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && + this->event_queue_.empty() && this->notify_queue_.empty())) { + // Nothing pending: the enqueue path re-arms the loop from any context. + this->disable_loop(); + } +} + +void RP2GattClient::handle_event_(const RP2GattEvent &event) { + switch (event.type) { + case RP2GattEvent::CONNECTED: + this->handle_connected_(event.status, event.value); + break; + case RP2GattEvent::DISCONNECTED: + this->handle_disconnected_(event.status); + break; + case RP2GattEvent::MTU_EXCHANGED: + if (this->state_ == EngineState::MTU_EXCHANGE) { + this->mtu_ = event.value; + ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); + this->state_ = EngineState::READY; + // Scanning resumes and runs alongside the established connection. + this->release_scan_inhibit_(); + this->listener_->on_connection_state(true, this->mtu_, 0); + } + break; + case RP2GattEvent::QUERY_COMPLETE: + this->handle_query_complete_(event.status); + break; + case RP2GattEvent::WRITE_NO_RSP_DONE: + this->finish_write_no_rsp_(event.status); + break; + case RP2GattEvent::PAIRING_RESULT: + this->listener_->on_pairing_result(event.status); + break; + } +} + +void RP2GattClient::can_write_no_rsp_trampoline(void *context) { + // BTstack context: this callback IS the can-send window, so the deferred + // write happens here; only the result is enqueued for the main loop. + auto *self = static_cast(context); + if (self->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + uint8_t status = gatt_client_write_value_of_characteristic_without_response(self->con_handle_, self->op_handle_, + self->op_len_, self->op_buffer_); + if ((status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) && + gatt_client_request_to_write_without_response(&self->can_write_registration_, self->con_handle_) == 0) { + return; // next window retries; a failed re-arm falls through as an error + } + self->enqueue_event_irq_(RP2GattEvent::WRITE_NO_RSP_DONE, status, 0); +} + +void RP2GattClient::finish_write_no_rsp_(uint8_t status) { + if (this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + this->op_type_ = OpType::NONE; + this->listener_->on_write_result(this->op_handle_, status); +} + +void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { + if (this->state_ != EngineState::CONNECTING) { + return; + } + if (status != 0) { + ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status); + this->fail_connection_(status); + return; + } + if (this->cancel_requested_) { + // A disconnect request raced the connection complete and lost; finish + // the teardown instead of reporting a connection nobody wants. + this->con_handle_ = con_handle; + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + uint8_t disc_status; + { + BluetoothLock lock; + disc_status = gap_disconnect(this->con_handle_); + } + if (disc_status != 0) { + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + return; + } + this->con_handle_ = con_handle; + this->state_ = EngineState::MTU_EXCHANGE; + ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); + BluetoothLock lock; + // One wildcard listener covers notifications/indications for every + // characteristic on this connection; the CCCD writes come from the API + // client as plain descriptor writes. + gatt_client_listen_for_characteristic_value_updates(&this->notification_registration_, + &RP2GattClient::gatt_packet_handler, this->con_handle_, nullptr); + // Auto MTU negotiation is disabled (see rp2040_ble enable hooks), so the + // exchange is kicked explicitly; GATT_EVENT_MTU completes it. Without the + // explicit kick the MTU would only be exchanged on the first GATT query, + // which never happens on a V3_WITH_CACHE connection. + // Both registration calls above return void (BTstack 075a078, arduino-pico + // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // the connect timeout in loop(). + gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); +} + +void RP2GattClient::release_scan_inhibit_() { + if (this->holds_scan_inhibit_) { + this->holds_scan_inhibit_ = false; + this->parent_->release_scan_inhibit(); + } +} + +void RP2GattClient::fail_connection_(uint8_t reason) { + { + // Timeout escalation can fire with the completion event lost; release the + // stack-wide connect slot so pending engines can proceed. Until the old + // completion is processed, gap_connect answers any peer with DISALLOWED + // (the request-level guard in hci.c); a cancel idles that request + // immediately, and a late addressed completion from the old procedure is + // then dropped by the owner-peer cross-check in the handler. + BluetoothLock lock; + if (connect_owner == this) { + connect_owner = nullptr; + } + if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // A success completion stamped the handle between the escalation + // decision and this lock: tear the link down before cleanup wipes the + // handle, or it leaks its pool block for the rest of the boot. + gap_disconnect(this->con_handle_); + } + } + this->cleanup_link_state_(); + this->release_scan_inhibit_(); + this->state_ = EngineState::IDLE; + this->listener_->on_connection_state(false, 0, reason); +} + +void RP2GattClient::cleanup_link_state_() { + // Drop notifications queued behind the disconnect so they cannot emit + // against a freed slot (address 0) on the next loop. + RP2GattNotifyEvent *stale; + while ((stale = this->notify_queue_.pop()) != nullptr) { + this->notify_pool_.release(stale); + } + // con_handle_ may be stamped in the BTstack context before the main loop + // registers the listener, so a valid handle does not imply a registration; + // stop_listening on an unregistered entry is a benign no-op. One lock + // scope around check and reset so an IRQ stamp cannot land in between + // (unreachable today — ownership is released before cleanup — but the + // invariant lives three functions away). + { + BluetoothLock lock; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; + } + this->notify_subscription_count_ = 0; + this->cancel_requested_ = false; + this->op_type_ = OpType::NONE; + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); +} + +void RP2GattClient::handle_disconnected_(uint8_t reason) { + if (this->state_ == EngineState::IDLE) { + return; + } + ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); + this->fail_connection_(reason); +} + +void RP2GattClient::handle_query_complete_(uint8_t att_status) { + // Stale completions cannot cross connections: the loop drains the whole + // event queue every iteration, teardown resets op/discovery state, and a + // new discovery is only issued after the new link's MTU event — which in + // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate + // from the query state machine). Completions with nothing in flight are + // dropped below. + if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + OpType op = this->op_type_; + this->op_type_ = OpType::NONE; + switch (op) { + case OpType::READ_CHAR: + case OpType::READ_DESC: + // A value that is an exact multiple of MTU - 1 ends with a trailing + // blob request some peers refuse with INVALID_OFFSET; the read is + // complete, not failed. + if ((att_status == ATT_ERROR_INVALID_OFFSET || att_status == ATT_ERROR_ATTRIBUTE_NOT_LONG) && + this->op_len_ > 0) { + att_status = 0; + } + this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, + att_status); + break; + case OpType::WRITE_CHAR: + case OpType::WRITE_DESC: + this->listener_->on_write_result(this->op_handle_, att_status); + break; + default: + break; + } + return; + } + if (this->discovery_phase_ != DiscoveryPhase::NONE) { + this->advance_discovery_(att_status); + } +} + +// ---- Service discovery ---- + +int RP2GattClient::discover_services() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (this->arena_ == nullptr) { + // Transient: freed in release_services() right after the table streams + // to the API client (mirrors Bluedroid's own per-connection GATT DB + // lifetime on esp32). Checked: a fragmented heap must surface as a + // stack error the proxy can report, not a device reset. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_ = allocator.allocate(1); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_); + return ble_device_base::GATT_ERR_NO_MEMORY; + } + new (this->arena_) ServiceArena(); + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; + this->discovery_phase_ = DiscoveryPhase::SERVICES; + BluetoothLock lock; + uint8_t status = gatt_client_discover_primary_services(&RP2GattClient::gatt_packet_handler, this->con_handle_); + if (status != 0) { + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); + return status; + } + return 0; +} + +int RP2GattClient::issue_characteristic_query_(uint16_t service_index) { + auto &service = this->arena_->services[service_index]; + gatt_client_service_t btstack_service = {}; + btstack_service.start_group_handle = service.start_handle; + btstack_service.end_group_handle = service.end_handle; + service.first_characteristic = this->char_count_; + BluetoothLock lock; + return gatt_client_discover_characteristics_for_service(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_service); +} + +int RP2GattClient::issue_descriptor_query_(uint16_t char_index) { + auto &chr = this->arena_->characteristics[char_index]; + gatt_client_characteristic_t btstack_characteristic = {}; + btstack_characteristic.value_handle = chr.value_handle; + btstack_characteristic.end_handle = chr.end_handle; + chr.first_descriptor = this->desc_count_; + BluetoothLock lock; + return gatt_client_discover_characteristic_descriptors(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_characteristic); +} + +void RP2GattClient::advance_discovery_(uint8_t att_status) { + if (this->arena_ == nullptr) { + // release_services() is publicly callable; a table freed mid-discovery + // must end the discovery instead of dereferencing a null arena. + this->finish_discovery_(GATT_ERR_NOT_CONNECTED); + return; + } + if (att_status != 0) { + this->finish_discovery_(att_status); + return; + } + switch (this->discovery_phase_) { + case DiscoveryPhase::SERVICES: + if (this->service_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::CHARACTERISTICS; + this->disc_service_cursor_ = 0; + if (int err = this->issue_characteristic_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + case DiscoveryPhase::CHARACTERISTICS: { + auto &service = this->arena_->services[this->disc_service_cursor_]; + service.characteristic_count = this->char_count_ - service.first_characteristic; + this->disc_service_cursor_++; + if (this->disc_service_cursor_ < this->service_count_) { + if (int err = this->issue_characteristic_query_(this->disc_service_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + if (this->char_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::DESCRIPTORS; + this->disc_char_cursor_ = 0; + if (int err = this->issue_descriptor_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + } + case DiscoveryPhase::DESCRIPTORS: { + auto &chr = this->arena_->characteristics[this->disc_char_cursor_]; + chr.descriptor_count = this->desc_count_ - chr.first_descriptor; + this->disc_char_cursor_++; + if (this->disc_char_cursor_ < this->char_count_) { + if (int err = this->issue_descriptor_query_(this->disc_char_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + this->finish_discovery_(0); + break; + } + default: + break; + } +} + +void RP2GattClient::finish_discovery_(int error) { + this->discovery_phase_ = DiscoveryPhase::NONE; + ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + error, this->service_count_, this->char_count_, this->desc_count_); + if (error == 0 && this->truncated_) { + // A partial table must not stream: V3 clients cache the database + // permanently, so an incomplete one would be wrong forever. + error = ATT_ERROR_INSUFFICIENT_RESOURCES; + } + if (error == 0) { + // Discovery no longer needs the fast interval; settle into the shared + // steady-state parameters (same lifecycle place as esp32). Status + // discarded: BTstack fails this only for an already-gone handle. + BluetoothLock lock; + gap_update_connection_parameters(this->con_handle_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + if (this->truncated_) { + ESP_LOGE(TAG, "Service table truncated (device exceeds %u services / %u characteristics / %u descriptors)", + RP2_GATT_MAX_SERVICES, RP2_GATT_MAX_CHARACTERISTICS, RP2_GATT_MAX_DESCRIPTORS); + } + if (error != 0) { + this->release_services(); + } + this->listener_->on_service_discovery_done(error); +} + +ble_device_base::GattServiceTable RP2GattClient::get_service_table() { + ble_device_base::GattServiceTable table; + if (this->arena_ != nullptr) { + table.services = this->arena_->services; + table.characteristics = this->arena_->characteristics; + table.descriptors = this->arena_->descriptors; + table.service_count = this->service_count_; + table.characteristic_count = this->char_count_; + table.descriptor_count = this->desc_count_; + } + return table; +} + +void RP2GattClient::release_services() { + if (this->arena_ != nullptr) { + // Under BluetoothLock so a discovery result landing in the BTstack + // context cannot write into the arena mid-free. + BluetoothLock lock; + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_->~ServiceArena(); + allocator.deallocate(this->arena_, 1); + this->arena_ = nullptr; + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; +} + +// ---- Connection control ---- + +int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->is_failed()) { + // setup() failed: nothing is registered for event routing and loop() + // never runs, so a connect could not complete or time out. + return GATT_ERR_NOT_CONNECTED; + } + if (this->state_ != EngineState::IDLE) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (!this->parent_->is_active()) { + return GATT_ERR_NOT_CONNECTED; + } + ble_device_base::uint64_to_mac_msb_first(address, this->peer_addr_); + // BLE_ADDR_TYPE_* code space: bit 0 distinguishes public from random + // (resolved RPA types 2/3 connect with the underlying kind). + this->peer_addr_type_ = (addr_type & 1) != 0 ? BD_ADDR_TYPE_LE_RANDOM : BD_ADDR_TYPE_LE_PUBLIC; + + // Stop the shared radio's scan for the duration of the connect attempt + // (esp32 parity: initiating and scanning contend for the radio). + this->holds_scan_inhibit_ = true; + this->parent_->inhibit_scan(); + this->connect_cancel_attempted_ = false; + this->cancel_requested_ = false; + // Bounds the queued wait; restarted when gap_connect is accepted so the + // radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the + // sum via a disconnect request). + this->connect_started_ = millis(); + if (int err = this->try_gap_connect_(); err != 0) { + this->release_scan_inhibit_(); + return err; + } + this->enable_loop(); + return 0; +} + +// One outgoing LE create-connection exists stack-wide: issue it if no other +// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry. +// Returns nonzero only for hard failures (state untouched; caller cleans up). +int RP2GattClient::try_gap_connect_() { + // Unlocked peek: single core, aligned pointer; a stale value costs one loop + // pass and the locked re-check below is authoritative. Keeps the per-loop + // pending retry from taking BluetoothLock just to find the radio busy. + if (connect_owner != nullptr) { + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + uint8_t status; + { + BluetoothLock lock; + if (connect_owner != nullptr) { + status = ERROR_CODE_COMMAND_DISALLOWED; + } else { + // esp32 parity: cached connections come up at MEDIUM already (nothing + // consumes the fast interval without a discovery phase), so there is no + // post-connect update procedure to race or silently lose; sustained + // FAST intervals also starve WiFi on the shared CYW43 radio. + // Without-cache runs FAST for discovery and steps down in + // finish_discovery_. + bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, + cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL, + cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0, + cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (status == 0) { + connect_owner = this; + // Still under the lock: a synthesized failure completion can fire in + // the BTstack context the instant it releases, and completion routing + // requires CONNECTING — set after the fact, the event is discarded + // and the engine burns its whole budget waiting for it. + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + } + } + } + if (status == 0) { + return 0; + } + if (status == ERROR_CODE_COMMAND_DISALLOWED) { + // Radio busy with another engine's connect; resolved from loop(). + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status); + return status; +} + +int RP2GattClient::gatt_disconnect() { + switch (this->state_) { + case EngineState::IDLE: + return GATT_ERR_NOT_CONNECTED; + case EngineState::DISCONNECTING: + return 0; // already on its way down + case EngineState::CONNECT_PENDING: + // Nothing issued stack-side; the invalid handle takes the refused + // path below without touching the stack. + break; + case EngineState::CONNECTING: { + if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { + // The cancel can lose the race against a successful connection + // complete; handle_connected_ checks this flag and finishes the + // teardown instead of proceeding. It also counts as the one cancel + // attempt, so a lost completion escalates on the next timeout tick. + this->cancel_requested_ = true; + this->connect_cancel_attempted_ = true; + // Grace period for the cancel completion: the client's disconnect + // often lands right at the engine's own deadline, and without the + // restart the loop timeout fires first and reports before the + // completion can finish the teardown cleanly. + this->connect_started_ = millis(); + BluetoothLock lock; + // Owner: the cancel completes as a failed connection-complete. Not + // the owner (completion already resolved in the BTstack context): the + // queued event drives the same teardown, nothing to cancel. + if (connect_owner == this) { + gap_connect_cancel(); + } + return 0; + } + break; + } + default: + break; + } + uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + { + BluetoothLock lock; + status = gap_disconnect(this->con_handle_); + } + if (status != 0) { + ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status); + } + } + if (status != 0) { + // Refused (handle already gone) or never issued (CONNECT_PENDING): + // complete via the event queue so the listener cannot re-enter + // disconnect mid-call. BluetoothLock stops the IRQ producer, so this + // main-loop push is SPSC-safe. + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + } + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + this->enable_loop(); + return 0; +} + +// ---- GATT operations (single outstanding op) ---- + +int RP2GattClient::read_characteristic(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_CHAR; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + // Long variant: plain read first, blob continuations only past MTU - 1. + uint8_t status = gatt_client_read_long_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + if (!response) { + // Synchronous in BTstack: the data is copied into the L2CAP buffer before + // the call returns, and no completion event exists — synthesize one so + // the wire behavior matches esp32 (which reports write-no-response too). + uint8_t status; + { + BluetoothLock lock; + if (this->op_type_ == OpType::WRITE_CHAR_NO_RSP) { + // A deferred write is parked; sending now would overtake it. + return GATT_CLIENT_BUSY; + } + status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, + const_cast(data)); + // BTSTACK_ACL_BUFFERS_FULL is the same transient flow control one layer + // down (L2CAP), so it defers identically. + if (status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) { + if (this->op_in_flight_()) { + // The op buffer is owned; bounce the busy to the caller as before. + return status; + } + // Stash the payload and send from the can-send callback. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR_NO_RSP; + this->op_handle_ = handle; + this->op_len_ = len; + this->write_no_rsp_started_ = millis(); + this->can_write_registration_.callback = &RP2GattClient::can_write_no_rsp_trampoline; + this->can_write_registration_.context = this; + uint8_t req = gatt_client_request_to_write_without_response(&this->can_write_registration_, this->con_handle_); + if (req != 0 && req != ERROR_CODE_COMMAND_DISALLOWED) { + this->op_type_ = OpType::NONE; + return req; + } + // COMMAND_DISALLOWED = still armed from a timed-out deferral; that + // registration sends the newly parked payload. Keep the loop running + // so the deadline below can fire on a stalled link. + this->enable_loop(); + return 0; + } + } + if (status == 0) { + this->listener_->on_write_result(handle, 0); + } + return status; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + // BTstack keeps the caller's pointer until the request is sent; the payload + // must live in engine-owned storage across the async operation. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status; + if (len <= this->mtu_ - 3) { + status = gatt_client_write_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, handle, + len, this->op_buffer_); + } else { + status = gatt_client_write_long_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, + handle, len, this->op_buffer_); + } + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::read_descriptor(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_DESC; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_long_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_DESC; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status = gatt_client_write_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle, len, this->op_buffer_); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + +int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + // The CCCD write arrives separately as a descriptor write (V3 semantics); + // this call only gates local delivery via the subscription list. + if (enable) { + if (!this->notify_subscribed_(handle)) { + if (this->notify_subscription_count_ >= RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS) { + return GATT_ERR_NO_MEMORY; + } + this->notify_subscriptions_[this->notify_subscription_count_++] = handle; + } + } else { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + this->notify_subscriptions_[i] = this->notify_subscriptions_[--this->notify_subscription_count_]; + break; + } + } + } + this->listener_->on_notify_state(handle, enable, 0); + return 0; +} + +bool RP2GattClient::notify_subscribed_(uint16_t handle) const { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + return true; + } + } + return false; +} + +int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); +} + +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h new file mode 100644 index 0000000000..4d407269b6 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -0,0 +1,251 @@ +// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. +// +// The build's ble_device_base::BLEGattConnection backend (bound by alias in +// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the +// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy +// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call +// issued from the main loop is wrapped in BluetoothLock. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +// Caps for the transient service table. Sized generously for real devices +// (typical peripherals expose < 8 services / < 30 characteristics); a peer +// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than +// streaming an incomplete database a V3 client would cache permanently. +static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16; +static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96; +static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96; + +// Concurrent notify subscriptions per connection (enable fails with +// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful). +static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16; + +// ATT spec maximum attribute value length; bounds the op buffer and +// notification payloads. +static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; + +// Control events from the BTstack handlers to loop(). +struct RP2GattEvent { + enum Type : uint8_t { + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) + }; + Type type; + uint8_t status; + uint16_t value; + void release() {} +}; + +// One notification/indication from the peer. +struct RP2GattNotifyEvent { + uint16_t handle; + uint16_t len; + uint8_t data[RP2_GATT_MAX_ATTR_LEN]; + void release() {} +}; + +static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; +// Depth 4: the queue is drained every main-loop iteration and each slot is a +// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. +static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; + +class RP2GattClient final : public Component, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + ble_device_base::GattServiceTable get_service_table(); + // Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is + // reserved for the discovery phase of uncached connects. + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + void release_services(); + +#ifdef USE_OTA_STATE_LISTENER + // Drop the connection while an OTA runs (esp32 parity): an active link + // competes with the transfer for the shared radio. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + protected: + // Link/engine state. Discovery and GATT ops have their own cursors below — + // the link stays READY while they run. + enum class EngineState : uint8_t { + IDLE, + CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered + DISCONNECTING, + }; + + enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; + + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC }; + + // The whole table in one transient allocation (RAMAllocator, checked), + // freed after streaming. + struct ServiceArena { + ble_device_base::GattService services[RP2_GATT_MAX_SERVICES]; + ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS]; + ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS]; + }; + + // BTstack packet handlers (IRQ context: copy-and-enqueue only). + static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); + + void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); + void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); + void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len); + + // Main-loop state machine. + void handle_event_(const RP2GattEvent &event); + void handle_connected_(uint8_t status, uint16_t con_handle); + void handle_disconnected_(uint8_t reason); + void handle_query_complete_(uint8_t att_status); + void advance_discovery_(uint8_t att_status); + int issue_characteristic_query_(uint16_t service_index); + int issue_descriptor_query_(uint16_t char_index); + void finish_discovery_(int error); + void fail_connection_(uint8_t reason); + int try_gap_connect_(); + void cleanup_link_state_(); + bool notify_subscribed_(uint16_t handle) const; + static void can_write_no_rsp_trampoline(void *context); + void finish_write_no_rsp_(uint8_t status); + void release_scan_inhibit_(); + bool op_in_flight_() const { + return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; + } + + // Group 1: containers / large storage + ble_device_base::GattClientListener *listener_{nullptr}; + ServiceArena *arena_{nullptr}; + esphome::LockFreeQueue event_queue_; + esphome::EventPool event_pool_; + esphome::LockFreeQueue notify_queue_; + esphome::EventPool notify_pool_; + + // Shared buffer for the single outstanding GATT op: write payloads (BTstack + // keeps the caller's pointer until the request is sent) and read results + // (written from the handler, read after QUERY_COMPLETE is drained). + uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN]; + + // BTstack registrations + gatt_client_notification_t notification_registration_{}; + btstack_context_callback_registration_t can_write_registration_{}; + + // Group 3: 4-byte types + uint32_t connect_started_{0}; + uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt + uint32_t disconnecting_started_{0}; + uint32_t write_no_rsp_started_{0}; + // Unscoped C enum, so int-sized: lives with the 4-byte members to keep the + // padding at the tail. + bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + + // Group 4: 2-byte types (table counters written from the handler during + // discovery, read from the main loop after the phase's QUERY_COMPLETE) + hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; + uint16_t op_handle_{0}; + uint16_t op_len_{0}; + uint16_t service_count_{0}; + uint16_t char_count_{0}; + uint16_t desc_count_{0}; + uint16_t disc_service_cursor_{0}; + uint16_t disc_char_cursor_{0}; + + // Group 5: arrays / 1-byte types + // Subscribed notify handles; the loop() drain filters the wildcard + // listener's deliveries on this list (esp32 parity for enable=false). + std::array notify_subscriptions_{}; + uint8_t notify_subscription_count_{0}; + uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE}; + EngineState state_{EngineState::IDLE}; + DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; + OpType op_type_{OpType::NONE}; + bool truncated_{false}; + // One cancel attempt per connect: the second timeout escalates to failure. + bool connect_cancel_attempted_{false}; + // A disconnect request raced an in-flight connect; finish teardown on link-up. + bool cancel_requested_{false}; + // This engine's own hold on the shared scan inhibit, so the pairing stays + // one-to-one per connection even with multiple slots. + bool holds_scan_inhibit_{false}; + + // Instance registry for routing BTstack events (IRQ context) to engines. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT]; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static uint8_t instance_count; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; + // The engine whose gap_connect is in flight: BTstack allows one outgoing LE + // create-connection stack-wide, and gap_connect_cancel is global, so only + // the owner may cancel. Written under BluetoothLock from the main loop, + // cleared in the BTstack context when the procedure resolves. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *connect_owner; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ad7528c156..95f71fc8ea 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,62 @@ +import functools import logging import esphome.codegen as cg -from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker -from esphome.components.esp32 import add_idf_sdkconfig_option -from esphome.components.esp32_ble import BTLoggers +from esphome.components import ble_device_base, bluetooth_connection import esphome.config_validation as cv -from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.const import ( + CONF_ACTIVE, + CONF_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_LN882X, + PLATFORM_RP2, +) +from esphome.core import CORE +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] -DEPENDENCIES = ["api", "esp32"] +# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily +# inside _esp32_config_schema()/_to_code_esp32(): importing those modules +# registers esp32-only automations (ble.enable, ble.disable, ...) as a side +# effect, and a module-scope import would leak them into every platform's +# registry the moment a config declares `bluetooth_proxy:` — degrading +# "Unable to find action" config errors into C++ compile failures. + + +def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: + """Components to auto-load for the platform being compiled. + + Callable with no argument so tooling that resolves AUTO_LOAD without a + target platform (the device-builder catalog sync does exactly this) gets + the union of every arm instead of an empty list — which is what lets it + keep cross-referencing the esp32 BLE stack. A real build always has a + target platform set, so it takes one of the concrete branches. + """ + if CORE.is_esp32: + return ["bluetooth_connection", "esp32_ble_tracker"] + if CORE.target_platform in _HUB_PLATFORMS: + return ["ble_device_base", "bluetooth_connection"] + # No target platform, or one this component does not support: tooling + # resolving the manifest (including the host-pinned dependency resolver) — + # expose every arm so the closure keeps the esp32 BLE stack. + return [ + "ble_device_base", + "bluetooth_connection", + "esp32_ble_tracker", + ] + + +# Platforms with an in-tree ble_device_base BLE tracker hub whose controller +# supports active scanning — every current client (aioesphomeapi, bleak-esphome, +# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# hub must not be admitted (it would be misdriven). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and +# FILTER_SOURCE_FILES hub entry. +_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) + +DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) @@ -20,65 +68,302 @@ DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") -BluetoothProxy = bluetooth_proxy_ns.class_( - "BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component -) -BluetoothConnection = bluetooth_proxy_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase -) +BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component) -CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } -).extend(cv.COMPONENT_SCHEMA) +# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32 +# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/ +# pins them together, and the outer walkable schema uses it as the +# connection_slots bound (per-platform schemas tighten it). +_IDF_MAX_CONNECTIONS = 9 -def validate_connections(config): - if CONF_CONNECTIONS in config: - if not config[CONF_ACTIVE]: - raise cv.Invalid( - "Connections can only be used if the proxy is set to active" +@functools.cache +def _esp32_config_schema() -> cv.All: + """Build the esp32 schema, importing the esp32 BLE stack only when used.""" + from esphome.components import esp32_ble, esp32_ble_tracker + + if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS: + raise cv.Invalid( + f"bluetooth_proxy's connection-slot limit mirror " + f"({_IDF_MAX_CONNECTIONS}) is out of sync with " + f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); " + f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" + ) + + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) + + def validate_connections(config): + if CONF_CONNECTIONS in config: + if not config[CONF_ACTIVE]: + raise cv.Invalid( + "Connections can only be used if the proxy is set to active" + ) + elif config[CONF_ACTIVE]: + connection_slots: int = config[CONF_CONNECTION_SLOTS] + esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")( + config ) - elif config[CONF_ACTIVE]: - connection_slots: int = config[CONF_CONNECTION_SLOTS] - esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) - return { - **config, - CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], - } + return { + **config, + CONF_CONNECTIONS: [ + CONNECTION_SCHEMA({}) for _ in range(connection_slots) + ], + } + return config + + return cv.All( + ( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, + cv.Optional( + CONF_CONNECTION_SLOTS, + default=DEFAULT_CONNECTION_SLOTS, + ): cv.All( + cv.positive_int, + cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + cv.Optional(CONF_CONNECTIONS): cv.All( + cv.ensure_list(CONNECTION_SCHEMA), + cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ), + validate_connections, + ) + + +def _validate_no_active(config: ConfigType) -> ConfigType: + if config[CONF_ACTIVE]: + raise cv.Invalid( + "Active connections are not supported on this platform; the proxy " + "forwards advertisements only (set active: false)" + ) return config -CONFIG_SCHEMA = cv.All( - ( +@functools.cache +def _rp2_config_schema() -> cv.All: + """Full proxy on the rp2 BLE hub: active connections through the BTstack + GATT client backend in bluetooth_connection. Multi-slot builds replace the + prebuilt library's one-client BTstack pools via linker --wrap, owned by + rp2040_ble and requested when a second backend registers.""" + connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) + + def populate_connections(config: ConfigType) -> ConfigType: + from esphome.components import rp2040_ble + + # One wrapper + backend pair per slot, declared during validation so + # their ids exist for codegen (the esp32 arm's `connections` pattern). + if not config[CONF_ACTIVE]: + return config + connection_slots: int = config[CONF_CONNECTION_SLOTS] + rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) + return { + **config, + CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)], + } + + max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] + schema = ( cv.Schema( { - cv.GenerateID(): cv.declare_id(BluetoothProxy), + **_COMMON_SCHEMA_KEYS, cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, - default=DEFAULT_CONNECTION_SLOTS, + default=min(DEFAULT_CONNECTION_SLOTS, max_conn), ): cv.All( cv.positive_int, - cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), - ), - cv.Optional(CONF_CONNECTIONS): cv.All( - cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + cv.Range( + min=1, + max=max_conn, + msg=f"rp2 supports at most {max_conn} connection slot(s); " + "the BTstack pool overrides in rp2040_ble are sized " + f"for {max_conn}", + ), ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) .extend(cv.COMPONENT_SCHEMA) - ), - validate_connections, + ) + return cv.All(schema, populate_connections) + + +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + if connections: + # Gates the connection and GATT half of the API surface. A proxy + # without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never + # sends those requests and their handlers and encoders are dead. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) + connection = cg.new_Pvariable(connection_conf[CONF_ID]) + cg.add(connection.set_backend(backend)) + cg.add(var.register_connection(connection)) + + +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. +_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} + + +# Keys every platform arm declares identically; each arm spreads this dict so +# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default +# differs (esp32 True, rp2 True, advertisement-only False). +_COMMON_SCHEMA_KEYS = { + cv.GenerateID(): cv.declare_id(BluetoothProxy), +} + +# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement +# callback feeds the same API batching, no connection stack compiled. +_BLE_HUB_CONFIG_SCHEMA = cv.All( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + cv.Optional(CONF_ACTIVE, default=False): cv.boolean, + } + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA), + _validate_no_active, ) -async def to_code(config): +@schema_extractor("schema") +def _validate_platform(config: ConfigType) -> ConfigType: + """Apply the schema for the platform actually being compiled. + + Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS + platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get + the advertisement-only shape; unsupported keys were already rejected by + name in _reject_unsupported_connection_keys. + """ + if config is SCHEMA_EXTRACT: + # The language-schema dumper runs without a platform. Expose the esp32 + # shape so `connections`, the ids and every default stay in the + # generated schema the editor and dashboard consume. + return _esp32_config_schema() + if CORE.is_esp32: + return _esp32_config_schema()(config) + if CORE.target_platform not in _HUB_PLATFORMS: + # Fail here with the actual reason. Without this gate the error surfaces + # later as an unresolvable hub ID ("Are you missing a hub declaration?") + # on platforms where no hub component can be declared. + full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)]) + adv_only = ", ".join( + sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS)) + ) + raise cv.Invalid( + f"bluetooth_proxy is not supported on {CORE.target_platform}: no " + "active-scan-capable BLE tracker hub is available for this " + f"platform. It runs on {full} (full proxy) and {adv_only} " + "(advertisement-only)." + ) + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) + return _BLE_HUB_CONFIG_SCHEMA(config) + + +def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType: + """Reject connection options a platform does not support, by name. + + GATT hub platforms keep connection_slots but reject the esp32-only keys; + advertisement-only hubs reject all three. Runs before the walkable schema + below so the user gets "this option does not exist here" instead of a + value-range error implying the option works. + """ + if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None: + return config + if CORE.target_platform not in _HUB_PLATFORMS: + # No proxy of any kind exists here: fall through so _validate_platform + # reports "not supported on {platform}" instead of a key-level message + # implying an advertisement-only proxy is available. + return config + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + # Full proxy: connection_slots is real here; the per-connection list + # exists internally but carries no user options, and the Bluedroid + # NVS service cache is esp32-only. + rejected = { + CONF_CONNECTIONS: ( + "has no per-connection options on this platform; use " + "'connection_slots' to set the count" + ), + CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)", + } + else: + reason = ( + "requires active connection support; this platform runs the " + "advertisement-only proxy and has no such option" + ) + rejected = dict.fromkeys( + (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason + ) + for key, reason in rejected.items(): + if key in config: + raise cv.Invalid(f"'{key}' {reason}", path=[key]) + return config + + +# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's +# field-range extractor among others) introspects it to discover options and +# their bounds, which a bare dispatch function would hide. It carries the scalar +# keys with no defaults; _validate_platform then runs the real per-platform +# schema, which applies the defaults and rejects options the platform does not +# support. +# +# It deliberately does NOT declare `connections`: this outer schema runs before +# the per-platform one, so any key it transforms is transformed twice. Running +# CONNECTION_SCHEMA twice re-validates an already-generated ID through +# declare_id(), which (unlike use_id) has no guard for an ID instance and +# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched +# for _ESP32_CONFIG_SCHEMA to validate exactly once. +CONFIG_SCHEMA = cv.All( + _reject_unsupported_connection_keys, + cv.Schema( + { + cv.Optional(CONF_ACTIVE): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES): cv.boolean, + # Bounded by the loosest platform cap so range walkers (the + # device-builder field-range sync) see a real Range; the + # per-platform schemas tighten it (1 on rp2) with their own error. + cv.Optional(CONF_CONNECTION_SLOTS): cv.All( + cv.positive_int, + cv.Range(min=1, max=_IDF_MAX_CONNECTIONS), + ), + }, + extra=cv.ALLOW_EXTRA, + ), + _validate_platform, +) + + +async def _to_code_esp32(config: ConfigType) -> None: + from esphome.components import esp32_ble, esp32_ble_tracker + from esphome.components.esp32 import add_idf_sdkconfig_option + from esphome.components.esp32_ble import BTLoggers + # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP) @@ -86,11 +371,35 @@ async def to_code(config): await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - await esp32_ble_tracker.register_raw_ble_device(var, config) + tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) + cg.add(var.set_ble_hub(tracker)) - # Define max connections for protobuf fixed array - connection_count = len(config.get(CONF_CONNECTIONS, [])) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + # Compiles the scanner-state push slot into the tracker and the matching + # registration into the proxy; the other hubs are polled instead. + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") + + await _connections_to_code(var, config) + + if config.get(CONF_CACHE_SERVICES): + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) + + +async def _to_code_ble_hub(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_active(config[CONF_ACTIVE])) + hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) + cg.add(var.set_ble_hub(hub)) + + await _connections_to_code(var, config) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + await _to_code_esp32(config) + else: + await _to_code_ble_hub(config) # Define batch size for BLE advertisements # Each advertisement is up to 80 bytes when packaged (including protocol overhead) @@ -98,13 +407,4 @@ async def to_code(config): # This achieves ~97% WiFi MTU utilization while staying under the limit cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) - for connection_conf in config.get(CONF_CONNECTIONS, []): - connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) - await cg.register_component(connection_var, connection_conf) - cg.add(var.register_connection(connection_var)) - await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) - - if config.get(CONF_CACHE_SERVICES): - add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) - cg.add_define("USE_BLUETOOTH_PROXY") diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp deleted file mode 100644 index 7ba9e61e19..0000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ /dev/null @@ -1,595 +0,0 @@ -#include "bluetooth_connection.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "bluetooth_proxy.h" - -namespace esphome::bluetooth_proxy { - -static const char *const TAG = "bluetooth_proxy.connection"; - -// This function is allocation-free and directly packs UUIDs into the output array -// using precalculated constants for the Bluetooth base UUID -static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // out[0] = bytes 8-15 (big-endian) - // - For 128-bit UUIDs: use bytes 8-15 as-is - // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 - out[0] = uuid_source.len == ESP_UUID_LEN_128 - ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) - : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) - << 32) | - 0x00001000ULL); // Base UUID bytes 8-11 - // out[1] = bytes 0-7 (big-endian) - // - For 128-bit UUIDs: use bytes 0-7 as-is - // - For 16/32-bit UUIDs: use precalculated base UUID constant - out[1] = uuid_source.len == ESP_UUID_LEN_128 - ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB -} - -// Helper to fill UUID in the appropriate format based on client support and UUID type -static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, - bool use_efficient_uuids) { - if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(uuid_128, uuid); - } else if (uuid.len == ESP_UUID_LEN_16) { - short_uuid = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - short_uuid = uuid.uuid.uuid32; - } -} - -// Constants for size estimation -static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) -static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) -static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) -static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic - -// Helper to estimate service size before fetching all data -/** - * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. - * - * @param char_count The number of characteristics in the service. - * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. - * @return The estimated size of the service in bytes. - * - * This function calculates the size of a Bluetooth service by considering: - * - A service overhead, which depends on whether efficient UUIDs are used. - * - The size of each characteristic, assuming 128-bit UUIDs for safety. - * - The size of descriptors, assuming one 128-bit descriptor per characteristic. - */ -static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { - size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; - // Always assume 128-bit UUIDs for characteristics to be safe - size_t char_size = CHAR_SIZE_128BIT; - // Assume one 128-bit descriptor per characteristic - size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; - - return service_overhead + (char_size + desc_size) * char_count; -} - -bool BluetoothConnection::supports_efficient_uuids_() const { - auto *api_conn = this->proxy_->get_api_connection(); - return api_conn && api_conn->client_supports_api_version(1, 12); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { - auto &allocated = this->proxy_->connections_free_response_.allocated; - for (auto &slot : allocated) { - if (slot == find_value) { - slot = set_value; - return; - } - } -} - -void BluetoothConnection::set_address(uint64_t address) { - // If we're clearing an address (disconnecting), update the pre-allocated message - if (address == 0 && this->address_ != 0) { - this->proxy_->connections_free_response_.free++; - this->update_allocated_slot_(this->address_, 0); - } - // If we're setting a new address (connecting), update the pre-allocated message - else if (address != 0 && this->address_ == 0) { - this->proxy_->connections_free_response_.free--; - this->update_allocated_slot_(0, address); - } - - // Call parent implementation to actually set the address - BLEClientBase::set_address(address); -} - -void BluetoothConnection::loop() { - BLEClientBase::loop(); - - // Early return if no active connection - if (this->address_ == 0) { - return; - } - - // Handle service discovery if in valid range - if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { - this->send_service_for_discovery_(); - } - - // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete - // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the - // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. - if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { - // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the - // base class. Free the proxy slot, notify the API client, and reset send_service_. - // address_ may already be 0 if reset_connection_ ran earlier on this teardown. - if (this->address_ == 0) { - return; - } - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); - this->reset_connection_(reason); -} - -void BluetoothConnection::reset_connection_(esp_err_t reason) { - // Send disconnection notification - this->proxy_->send_device_connection(this->address_, false, 0, reason); - - // Important: If we were in the middle of sending services, we do NOT send - // send_gatt_services_done() here. This ensures the client knows that - // the service discovery was interrupted and can retry. The client - // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) - // to detect incomplete service discovery rather than relying on us to - // tell them about a partial list. - this->set_address(0); - this->send_service_ = INIT_SENDING_SERVICES; - this->proxy_->send_connections_free(); -} - -void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ >= this->service_count_) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); - this->release_services(); - return; - } - - // Early return if no API connection - auto *api_conn = this->proxy_->get_api_connection(); - if (api_conn == nullptr) { - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->supports_efficient_uuids_(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Conservative MTU limit for API messages (accounts for WPA3 overhead) - static constexpr size_t MAX_PACKET_SIZE = 1360; - - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - - while (this->send_service_ < this->service_count_) { - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Get the number of characteristics BEFORE adding to response - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); - - if (char_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // If this service likely won't fit, send current batch (unless it's the first) - size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); - if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { - // This service likely won't fit, send current batch - break; - } - - // Now add the service since we know it will likely fit - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - if (total_char_count > 0) { - // Initialize FixedVector with exact count and process characteristics - service_resp.characteristics.init(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - continue; - } - - // Initialize FixedVector with exact count and process descriptors - characteristic_resp.descriptors.init(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; - } - if (desc_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (desc_count == 0) { - break; // No more descriptors - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - // Calculate the actual size of just this service - size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag - - // Check if adding this service would exceed the limit - if (current_size + service_size > MAX_PACKET_SIZE) { - // We would go over - pop the last service if we have more than one - if (resp.services.size() > 1) { - resp.services.pop_back(); - ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, - MAX_PACKET_SIZE); - // Don't increment send_service_ - we'll retry this service in next batch - } else { - // This single service is too large, but we have to send it anyway - ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str(), this->send_service_, service_size); - // Increment so we don't get stuck - this->send_service_++; - } - // Send what we have - break; - } - - // Now we know we're keeping this service, add its size - current_size += service_size; - // Successfully added this service, increment counter - this->send_service_++; - } - - // Send the message with dynamically batched services - api_conn->send_message(resp); -} - -void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); -} - -void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); -} - -void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, - type); -} - -void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), - operation, handle, status); -} - -esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { - if (err != ESP_OK) { - this->log_connection_warning_(operation, err); - return err; - } - return ESP_OK; -} - -bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) - return false; - - switch (event) { - case ESP_GATTC_DISCONNECT_EVT: { - // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources - // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, - param->disconnect.reason); - // Send disconnection notification but don't free the slot yet - this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); - break; - } - case ESP_GATTC_OPEN_EVT: { - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - this->reset_connection_(param->open.status); - } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - } - this->seen_mtu_or_services_ = false; - break; - } - case ESP_GATTC_CFG_MTU_EVT: - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (!this->seen_mtu_or_services_) { - // We don't know if we will get the MTU or the services first, so - // only send the device connection true if we have already received - // the services. - this->seen_mtu_or_services_ = true; - break; - } - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - break; - } - case ESP_GATTC_READ_DESCR_EVT: - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); - this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTReadResponse resp; - resp.address = this->address_; - resp.handle = param->read.handle; - resp.set_data(param->read.value, param->read.value_len); - api_connection->send_message(resp); - break; - } - case ESP_GATTC_WRITE_CHAR_EVT: - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); - this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = param->write.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (param->unreg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, - param->unreg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->unreg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (param->reg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, - param->reg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->reg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, - param->notify.handle); - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyDataResponse resp; - resp.address = this->address_; - resp.handle = param->notify.handle; - resp.set_data(param->notify.value, param->notify.value_len); - api_connection->send_message(resp); - break; - } - default: - break; - } - return true; -} - -void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - BLEClientBase::gap_event_handler(event, param); - - switch (event) { - case ESP_GAP_BLE_AUTH_CMPL_EVT: - if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0) - break; - if (param->ble_security.auth_cmpl.success) { - this->proxy_->send_device_pairing(this->address_, true); - } else { - this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason); - } - break; - default: - break; - } -} - -esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char", err); -} - -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char", err); -} - -esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "descriptor"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); -} - -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "descriptor"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); -} - -esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { - if (!this->connected()) { - this->log_gatt_not_connected_("notify", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - - if (enable) { - ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); - } - - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); -} - -esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - return this->proxy_->get_advertisement_parser_type(); -} - -} // namespace esphome::bluetooth_proxy - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h deleted file mode 100644 index e5600f6af4..0000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -namespace esphome::bluetooth_proxy { - -class BluetoothProxy; - -class BluetoothConnection final : public esp32_ble_client::BLEClientBase { - public: - void dump_config() override; - void loop() override; - bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - - esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); - esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); - - esp_err_t notify_characteristic(uint16_t handle, bool enable); - - esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { - return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); - } - - void set_address(uint64_t address) override; - - protected: - friend class BluetoothProxy; - - void on_disconnect_complete(esp_err_t reason) override; - - bool supports_efficient_uuids_() const; - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - void update_allocated_slot_(uint64_t find_value, uint64_t set_value); - void log_connection_error_(const char *operation, esp_gatt_status_t status); - void log_connection_warning_(const char *operation, esp_err_t err); - void log_gatt_not_connected_(const char *action, const char *type); - void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); - esp_err_t check_and_log_error_(const char *operation, esp_err_t err); - - // Memory optimized layout for 32-bit systems - // Group 1: Pointers (4 bytes each, naturally aligned) - BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_proxy - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 37ebcad8b4..878d3cd44e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,15 +1,17 @@ #include "bluetooth_proxy.h" +#ifdef USE_BLUETOOTH_PROXY + #include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" #include +#include #include #include -#ifdef USE_ESP32 - namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy"; @@ -23,41 +25,116 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } -void BluetoothProxy::setup() { - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +// The neutral enum's values are the wire values. +static_assert(static_cast(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE); +static_assert(static_cast(ble_device_base::ScannerState::STARTING) == + api::enums::BLUETOOTH_SCANNER_STATE_STARTING); +static_assert(static_cast(ble_device_base::ScannerState::RUNNING) == + api::enums::BLUETOOTH_SCANNER_STATE_RUNNING); +static_assert(static_cast(ble_device_base::ScannerState::FAILED) == + api::enums::BLUETOOTH_SCANNER_STATE_FAILED); +static_assert(static_cast(ble_device_base::ScannerState::STOPPING) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPING); +static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->parent_->get_scan_active(); - - this->parent_->add_scanner_state_listener(this); -} - -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } -} - -void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { +bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); - resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + return this->api_connection_->send_message(resp); } -void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else +void BluetoothProxy::send_polled_scanner_state_() { + // One read feeds both the frame and the change detector; the detector only + // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a + // full TX buffer) is retried from loop() instead of leaving a stale state. + const bool running = this->hub_->scan_running(); + if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING + : ble_device_base::ScannerState::IDLE)) { + this->last_scan_running_ = running; + } +} +#endif // USE_BLE_SCANNER_STATE_CALLBACK + +void BluetoothProxy::setup() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +#endif + + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->hub_->scan_active(); + + this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { + static_cast(self)->on_raw_advertisement_(adv); + }}); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Only push hubs compile the slot; elsewhere loop() polls scan_running(). + this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { + static_cast(self)->send_scanner_state_(state); + }}); +#endif +} + +// The hub delivers raw advertisements on the ESPHome main loop. +void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + return; + + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + adv.address = raw.address; + adv.rssi = raw.rssi; + adv.address_type = raw.addr_type; + uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); + adv.data_len = length; + std::memcpy(adv.data, raw.data, length); + + this->response_.advertisements_len++; + + ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements_(); + } +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str(), espbt::client_state_to_string(state)); + connection->address_str(), ble_device_base::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address); +} void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -66,104 +143,176 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); -} - -#ifdef USE_ESP32_BLE_DEVICE -bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { - // This method should never be called since bluetooth_proxy always uses raw advertisements - // but we need to provide an implementation to satisfy the virtual method requirement - return false; + if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) { + // No connection, so nothing to latch against; the client's timeout arbitrates. + this->log_reply_dropped_("Not-connected", address); + } } #endif -bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return false; - - auto &advertisements = this->response_.advertisements; - - for (size_t i = 0; i < count; i++) { - auto &result = scan_results[i]; - uint8_t length = result.adv_data_len + result.scan_rsp_len; - - // Fill in the data directly at current position - auto &adv = advertisements[this->response_.advertisements_len]; - adv.address = esp32_ble::ble_addr_to_uint64(result.bda); - adv.rssi = result.rssi; - adv.address_type = result.ble_addr_type; - adv.data_len = length; - std::memcpy(adv.data, result.ble_adv, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], - result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } +void BluetoothProxy::log_advertisement_flush_(bool sent) { + if (sent) { + // VV: one line per flush drowns a verbose log in any busy environment. + ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + } else { + // The rare congestion signal stays at V. + ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); } - - return true; -} - -void BluetoothProxy::log_advertisement_flush_() { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } void BluetoothProxy::dump_config() { + // Print configured facts. dump_config runs right after setup, before the + // radio is up, so live scan state would always read "stopped" here — the + // loop's BluetoothScannerStateResponse carries the changing value instead. + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + this->get_bluetooth_mac_address_pretty(mac_str); + const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; + const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); + " Connections: %d\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + YESNO(this->active_), this->connection_count_, scan_mode, mac_out); +#else + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Mode: advertisement-only (no GATT connections)\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + scan_mode, mac_out); +#endif } -void BluetoothProxy::loop() { - // Run advertisement flush / connection cleanup every 100ms - uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_advertisement_flush_time_ < 100) - return; - this->last_advertisement_flush_time_ = now; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); +void BluetoothProxy::register_connection(BluetoothConnection *connection) { + if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { + // Cannot happen with codegen-sized registration; a silent drop would + // surface later as a null proxy_ dereference, so refuse loudly. + ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } + // The hub wrapper has no Component lifecycle, so the index is assigned here. + connection->connection_index_ = this->connection_count_; + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; +} + +void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } + +void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) { + for (auto &slot : this->connections_free_response_.allocated) { + if (slot == find_value) { + slot = set_value; + return; + } + } + // The accounting arrays are only mutated here and sized to the slot count, + // so a miss means the bookkeeping already drifted — say so. + ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); +} + +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingReply *free_entry = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + this->log_reply_deferred_("Disconnect", address); + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + return; // latch_pending_disconnection_ keeps at most one entry per address } } } -esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; +void BluetoothProxy::answer_device_disconnected_(uint64_t address) { + if (this->send_device_connection(address, false)) { + // A landed answer satisfies any owed notification for the address; a + // drained duplicate would follow it otherwise. + this->clear_pending_disconnection_(address); + return; + } + // Not latched: the client's own request timeout arbitrates, and pooling + // these would let a request retry loop displace an unsolicited disconnect. + this->log_reply_dropped_("Disconnect", address); +} + +void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) { + if (this->send_device_connection(address, false, 0, error)) { + // A later disconnect landing for an address that still has one owed would + // otherwise have the drain repeat it. + this->clear_pending_disconnection_(address); + return; + } + // A dropped disconnect leaves the client believing the link is live, so + // every GATT operation on it times out until something else corrects it. + // latch_pending_disconnection_() reports the leading edge. + this->latch_pending_disconnection_(address, error); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + // The client has no other way to learn of an unsolicited disconnect. + this->send_device_disconnected_(connection->get_address(), reason); + connection->set_address(0); + connection->send_service_ = INIT_SENDING_SERVICES; + this->send_connections_free(); } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { + // Finish the scan before reserving: a free slot earlier in the array must + // not win over a later slot that already holds the address, or one device + // ends up on two slots with a second connection attempt racing the first. + BluetoothConnection *free_slot = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) - return connection; - - if (reserve && conn_addr == 0) { - connection->send_service_ = INIT_SENDING_SERVICES; - connection->set_address(address); - // All connections must start at INIT - // We only set the state if we allocate the connection - // to avoid a race where multiple connection attempts - // are made. - connection->set_state(espbt::ClientState::INIT); + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; } + + if (free_slot == nullptr && conn_addr == 0) + free_slot = connection; } - return nullptr; + if (!reserve || free_slot == nullptr) + return nullptr; + this->clear_pending_disconnection_(address); + free_slot->send_service_ = INIT_SENDING_SERVICES; + free_slot->set_address(address); + // All connections must start at INIT + // We only set the state if we allocate the connection + // to avoid a race where multiple connection attempts + // are made. + free_slot->set_state(ClientState::INIT); + return free_slot; } void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { @@ -173,99 +322,97 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), connection->address_str()); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } - if (connection->state() == espbt::ClientState::CONNECTED || - connection->state() == espbt::ClientState::ESTABLISHED) { + if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); - this->send_device_connection(msg.address, true); + connection->send_connected_reply_(); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } - this->log_connection_request_ignored_(connection, connection->state()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; - } else if (connection->state() != espbt::ClientState::INIT) { + } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { - connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE - connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); + connection->initiate_connection(static_cast(msg.address_type)); this->send_connections_free(); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); return; } - if (connection->state() != espbt::ClientState::IDLE) { + if (connection->state() != ClientState::IDLE) { connection->disconnect(); } else { connection->set_address(0); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); } break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { + // The connection wrapper exposes the pairing surface; success is + // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { auto err = connection->pair(); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_device_pairing(msg.address, false, err); } } else { this->send_device_pairing(msg.address, true); } + } else { + // Answer instead of leaving the client to time out. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_pairing(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + if (ret == CONN_OK) { + // The bond is gone; a live connection must not short-circuit the + // next PAIR as already paired. + auto *connection = this->get_connection_(msg.address, false); + if (connection != nullptr) { + connection->set_unpaired(); + } + } + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_gattc_cache_clean(address); - api::BluetoothDeviceClearCacheResponse call; - call.address = msg.address; - call.success = ret == ESP_OK; - call.error = ret; - - this->api_connection_->send_message(call); - + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { ESP_LOGE(TAG, "V1 connections removed"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); break; } } @@ -279,8 +426,8 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms } auto err = connection->read_characteristic(msg.handle); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -292,8 +439,8 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & } auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -305,8 +452,8 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead } auto err = connection->read_descriptor(msg.handle); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -318,8 +465,8 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri } auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -329,9 +476,32 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } - if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); - this->send_gatt_services_done(msg.address); + if (!connection->has_gatt_services()) { + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -346,14 +516,16 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest } auto err = connection->notify_characteristic(msg.handle, msg.enable); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Not latched (esp32 parity): the request is idempotent, so a drop resolves + // via the client timeout and a retry gives the same answer. Still reported. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -361,10 +533,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn if (connection == nullptr || !connection->connected()) { ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", - connection ? static_cast(connection->connection_index_) : -1, + connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); - resp.error = ESP_GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); + resp.error = GATT_NOT_CONNECTED; + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } return; } @@ -375,26 +549,207 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn static_cast(std::min(msg.max_interval, max_val)), static_cast(std::min(msg.latency, max_val)), static_cast(std::min(msg.timeout, max_val))); - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } +} + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#ifdef USE_ESP32 + +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + // esp32 only: BLEHub is the concrete tracker here, so these calls reach + // tracker-native methods beyond the neutral contract. + if (this->hub_->get_scan_active() == active) { + return; + } + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + this->hub_->set_scan_active(active); + this->hub_->stop_scan(); + this->hub_->set_scan_continuous( + true); // Set this to true to automatically start scanning again when it has cleaned up. +} + +#else // !USE_ESP32 + +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK + if (this->api_connection_ != nullptr) { + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. + this->send_polled_scanner_state_(); + } +#endif +} + +#endif // USE_ESP32 + +void BluetoothProxy::loop() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Stream pending service-discovery batches every iteration; the streamer + // handles a vanished API connection itself. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->process_pending_services(); + } +#endif + + // Run advertisement flush / scanner-state poll every 100ms + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_advertisement_flush_time_ < 100) + return; + this->last_advertisement_flush_time_ = now; + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive. Every build + // sends this at subscribe time (api_connection.cpp), so the drain + // compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } +#endif + + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // The API subscriber is gone: tear down any connections it left behind + // (disconnect() on an already-disconnecting slot is a no-op). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif + return; + } + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->flush_owed_replies_(); + } + // Address-keyed, not slot-keyed, so it gets its own loop; bounded by + // connection_count_ like the latch and clear helpers. Not pre-cleared: + // the sender clears on success and re-latches on refusal, keeping the + // latch's leading-edge warn honest (same shape as the unpair drain). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.empty()) + continue; + this->send_device_disconnected_(owed.address(), owed.error()); + } + + // An owed unpair reply. Not pre-cleared: the sender clears on success and + // re-latches on refusal, keeping its leading-edge warn guard honest. + if (!this->pending_unpairing_.empty()) { + conn_err_t error = this->pending_unpairing_.error(); + this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error); + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else + // This hub doesn't push scanner-state transitions; poll and report on + // change. A hub gaining push emits the define and drops this poll. + if (this->hub_->scan_running() != this->last_scan_running_) { + this->send_polled_scanner_state_(); + } +#endif + +#ifdef USE_WIFI + // Wi-Fi (or a coexistence build that can fall back to it): every other + // non-empty 100 ms tick (~200 ms) gives partial batches time to fill + // toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer, + // fuller frames. Full batches still ship immediately from the queueing + // path, and the owed-reply drains above keep the 100 ms cadence. + if (this->response_.advertisements_len != 0) { + if (this->adv_flush_toggle_) { + this->flush_pending_advertisements_(); + } + this->adv_flush_toggle_ = !this->adv_flush_toggle_; + } else { + // Nothing pending (idle, or a full batch just shipped inline): arm so + // the next batch ships on the next tick. + this->adv_flush_toggle_ = true; + } +#else + // No Wi-Fi in the build (ethernet): no airtime worth trading latency for, + // so partial batches flush every tick. + this->flush_pending_advertisements_(); +#endif +} + +void BluetoothProxy::reset_owed_replies_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->connections_free_pending_ = false; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() + // re-drives it from the hub, so clearing it there is free. + this->scanner_state_pending_ = false; +#else + // Force a poll-arm mismatch: a frame refused at subscribe time could + // otherwise match the stale detector and never be retried. Inert on + // unsubscribe: loop() returns at the no-subscriber gate before the + // detector runs, and a re-subscribe re-arms this anyway. + this->last_scan_running_ = !this->hub_->scan_running(); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->pending_unpairing_.clear(); + this->pending_disconnections_.fill({}); + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the next + // session; silence (the client's timeout) arbitrates. + auto *connection = this->connections_[i]; + connection->park_service_stream_(); + connection->clear_owed_flags_(); + } +#endif } void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + if (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // A previous subscriber still holds the slot. This is almost always a + // stale connection from a client that dropped without a clean disconnect + // and has not yet hit the keepalive timeout; rejecting the new + // subscriber would silently starve it of advertisements until it + // reconnects, so the newest subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->reset_owed_replies_(); } this->api_connection_ = api_connection; - this->parent_->recalculate_advertisement_parser_types(); - - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // get_scanner_state() is part of the push-hub surface (see BLEHubContract). + this->send_scanner_state_(this->hub_->get_scanner_state()); +#else + this->send_polled_scanner_state_(); +#endif } void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) { @@ -403,19 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; - this->parent_->recalculate_advertisement_parser_types(); + this->reset_owed_replies_(); } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - this->api_connection_->send_message(call); -} +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -423,28 +769,45 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; + call.address = address; + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { +bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTErrorResponse call; call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { +void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDevicePairingResponse call; @@ -452,33 +815,63 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_ call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: a retried PAIR is answered from is_paired(), so the client + // recovers on its own. Still worth saying it happened. + this->log_reply_dropped_("Pairing", address); + } } -void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { +void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; + // An owed success is the authoritative answer: a later attempt for the + // same address fails only because the first already removed the bond. + if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && + this->pending_unpairing_.error() == CONN_OK) { + success = true; + error = CONN_OK; + } api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - this->api_connection_->send_message(call); -} - -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_->get_scan_active() == active) { + if (this->api_connection_->send_message(call)) { + // A later unpair landing for an address that still has one owed would + // otherwise have the drain repeat it. + if (this->pending_unpairing_.matches(address)) { + this->pending_unpairing_.clear(); + } return; } - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_->set_scan_active(active); - this->parent_->stop_scan(); - this->parent_->set_scan_continuous( - true); // Set this to true to automatically start scanning again when it has cleaned up. + if (this->pending_unpairing_.empty()) { + this->log_reply_deferred_("Unpair", address); + } else if (!this->pending_unpairing_.matches(address)) { + this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); } +// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE, +// so its response encoder would be dead weight there. +void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { + if (this->api_connection_ == nullptr) + return; + api::BluetoothDeviceClearCacheResponse call; + call.address = address; + call.success = success; + call.error = error; + + if (!this->api_connection_->send_message(call)) { + // Not latched: clear-cache is idempotent, so a retry gives the same answer. + this->log_reply_dropped_("Clear-cache", address); + } +} +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 2b6d29da43..e233c38b56 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -1,33 +1,38 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" + +#ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" -#include "esphome/components/esp32_ble_client/ble_client_base.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" -#include "bluetooth_connection.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID -#include -#endif -#include +#include "esphome/components/ble_device_base/ble_hub_impl.h" + +#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" namespace esphome::bluetooth_proxy { -static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1; -static constexpr int DONE_SENDING_SERVICES = -2; -static constexpr int INIT_SENDING_SERVICES = -3; +// The connection-domain types live in the bluetooth_connection component; +// re-exported here so the proxy code reads unqualified. +using bluetooth_connection::CONN_OK; +using bluetooth_connection::conn_err_t; +using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; +using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; -using namespace esp32_ble_client; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +using BluetoothConnection = bluetooth_connection::BluetoothConnection; +using ClientState = ble_device_base::ClientState; +#endif // Legacy versions: // Version 1: Initial version without active connections @@ -36,6 +41,8 @@ using namespace esp32_ble_client; // Version 4: Pairing support // Version 5: Cache clear support static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5; +static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4; +static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3; static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1; enum BluetoothProxyFeature : uint32_t { @@ -53,32 +60,67 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, - public esp32_ble_tracker::BLEScannerStateListener, - public Component { - friend class BluetoothConnection; // Allow connection to update connections_free_response_ +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit +/// error on top. Every error that reaches it fits int16_t. +class PendingReply { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: only (address 0, error 0) reads back as nothing owed. + // A zero-address failure still latches, which is correct - that reply is + // owed too. Neither backend can unpair address 0 successfully. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingReply p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingReply{}.empty()); +#endif + +class BluetoothProxy final : public Component { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; +#endif public: BluetoothProxy(); -#ifdef USE_ESP32_BLE_DEVICE - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; -#endif - bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } void dump_config() override; void setup() override; void loop() override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. - void register_connection([[maybe_unused]] BluetoothConnection *connection) { - // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } -#endif - } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + void register_connection(BluetoothConnection *connection); +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS +#ifndef USE_ESP32 + // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below + // snapshots scan_active()/scan_running() and installs the raw callback, and + // the BLEHub contract does not promise those are settled any earlier than + // the hub's own setup(). + float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } +#endif // !USE_ESP32 +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg); @@ -87,63 +129,92 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg); +#endif void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } + /// Whether the subscribed API client understands 16/32-bit UUID fields. + bool client_supports_efficient_uuids() const { + return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); + } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Refusals latch in send_device_disconnected_() and + /// send_connected_reply_(); other callers report via log_reply_dropped_(). + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); - void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); - void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK); - void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); + /// False only when the API refused the frame, so the reply is still owed. + bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); + /// No default error: the drain rebuilds success as (error == CONN_OK), so a + /// caller that omitted it would have a reported failure resent as a success. + void send_device_unpairing(uint64_t address, bool success, conn_err_t error); + void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); +#endif void bluetooth_scanner_set_mode(bool active); - static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { - bd_addr[0] = (address >> 40) & 0xff; - bd_addr[1] = (address >> 32) & 0xff; - bd_addr[2] = (address >> 24) & 0xff; - bd_addr[3] = (address >> 16) & 0xff; - bd_addr[4] = (address >> 8) & 0xff; - bd_addr[5] = (address >> 0) & 0xff; - } - void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } - /// BLEScannerStateListener interface - void on_scanner_state(esp32_ble_tracker::ScannerState state) override; - uint32_t get_legacy_version() const { - if (this->active_) { + if (!this->active_) { + return LEGACY_PASSIVE_ONLY_VERSION; + } + // Legacy clients (which predate the feature flags) map versions to + // capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active + // connections only. + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; } - return LEGACY_PASSIVE_ONLY_VERSION; + return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION + : LEGACY_ACTIVE_NO_PAIRING_VERSION; } uint32_t get_feature_flags() const { uint32_t flags = 0; flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN; flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS; +#ifdef USE_ESP32 flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; +#else + // Advertise mode switching only where the hub honors request_scan_mode(); + // scan_mode_switch is the capability bit for exactly that (#18079) — + // active_scan alone is not enough, a hub may support active scanning yet + // refuse the runtime switch. + if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) { + flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; + } +#endif if (this->active_) { + // REMOTE_CACHING is mandatory for active connections: API clients + // refuse to connect without it (it selects which V3 connect request + // they send, not device-side caching). flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; - flags |= BluetoothProxyFeature::FEATURE_PAIRING; - flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING; + if (bluetooth_connection::SUPPORTS_PAIRING) { + flags |= BluetoothProxyFeature::FEATURE_PAIRING; + } + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { + flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; + } } return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; + this->hub_->get_adapter_mac(mac); + // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn + // the address once the link layer is up, and report all-zero until then. + if (mac_address_is_valid(mac)) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; @@ -151,51 +222,149 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, } protected: - void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); + bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else + void send_polled_scanner_state_(); +#endif + void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); /// Caller must ensure api_connection_ is non-null and API server is connected. void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; - this->api_connection_->send_message(this->response_); + // Perishable and the highest-frequency send here: a drop only reports at + // V, anything louder would be the flood the batch pacing exists to avoid. + [[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); + this->log_advertisement_flush_(sent); #endif this->response_.advertisements_len = 0; } - void log_advertisement_flush_(); + void log_advertisement_flush_(bool sent); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); - void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); + /// Keep the pre-allocated connections-free message in step when a + /// connection slot changes address (0 = free). Called from the connection + /// classes' set_address(). + void update_address_slot_(uint64_t old_address, uint64_t new_address) { + auto &resp = this->connections_free_response_; + if (new_address == 0 && old_address != 0) { + if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + resp.free++; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(old_address, 0); + } else if (new_address != 0 && old_address == 0) { + if (resp.free > 0) { + resp.free--; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(0, new_address); + } + } + void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_slot_accounting_mismatch_(); + /// Free a connection slot after teardown: notify the API client and reset + /// the streaming cursor. Important: does NOT send send_gatt_services_done() + /// when service streaming was interrupted -- the client (aioesphomeapi) has + /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service + /// discovery and retry, rather than being told a partial list is complete. + void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Send connected=false and pool it for the paced drain if refused. A + /// dropped disconnect desynchronises the proxy: the client keeps a link it + /// believes is live and every operation on it times out. Unsolicited and + /// drained notifications only; request answers use the variant below. + void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK); + /// Answer a request with connected=false. Never pools: a refusal falls back + /// to the client's request timeout, keeping the pool for the unsolicited + /// notifications the client cannot recover on its own. + void answer_device_disconnected_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); +#endif + + /// Drop everything the ending session was owed. One list, so a new latch is + /// one edit rather than two call sites where an omission looks deliberate. + /// Drops state only, never sends: api_connection_ is the departing + /// subscriber on subscribe and nullptr on unsubscribe. + void reset_owed_replies_(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// Report a reply we deliberately do not latch, so no drop is silent. + void log_reply_dropped_(const char *what, uint64_t address); + /// A latched reply's leading edge; the drain's re-refusals stay quiet. + void log_reply_deferred_(const char *what, uint64_t address); + /// A latched reply lost to a newer one for a different address. + void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); +#endif + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; + // Owed unpair reply. The bond is already gone when the send is refused, so + // a retry is told the unpair failed when it succeeded. One slot: a second + // refused unpair displaces the first, as happened to both before this. + PendingReply pending_unpairing_{}; +#endif + ble_device_base::BLEHub *hub_{nullptr}; + // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below + // start on an even word, closing two alignment holes. + uint32_t last_advertisement_flush_time_{0}; // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; - // Group 3: 4-byte types - uint32_t last_advertisement_flush_time_{0}; - +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; +#endif // Group 4: 1-byte types grouped together bool active_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; +#endif bool configured_scan_active_{false}; // Configured scan mode from YAML - // 3 bytes used, 1 byte padding +#ifdef USE_WIFI + /// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial + /// batches fill; an idle tick re-arms, so the first batch after a gap + /// still ships on the next tick. See loop(). + bool adv_flush_toggle_{false}; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else + bool last_scan_running_{false}; // Last scanner state reported to the subscriber +#endif }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 8ce216da22..4be7ca8268 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -1,24 +1,24 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt CODEOWNERS = ["@nagyrobi"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] -BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( - "BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) def bthome_mithermometer_base_schema(extra_schema=None): if extra_schema is None: extra_schema = {} - return ( + return cv.All( + ble_device_base.rename_legacy_hub_id("bthome_mithermometer"), cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), @@ -26,15 +26,15 @@ def bthome_mithermometer_base_schema(extra_schema=None): cv.Optional(CONF_BINDKEY): cv.bind_key, } ) - .extend(BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def setup_bthome_mithermometer(var, config): await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if bindkey := config.get(CONF_BINDKEY): bindkey_bytes = [ diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff38ab1740..1ebabea0a3 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -8,13 +8,20 @@ #include #include +// AES-CCM backend for encrypted-advertisement (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code +// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform. #ifdef USE_ESP32 - #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define BTHOME_CRYPTO_PSA +#endif +#endif +#ifndef BTHOME_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::bthome_mithermometer { @@ -25,6 +32,9 @@ static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; static constexpr size_t BTHOME_COUNTER_SIZE = 4; +// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they +// compile away and an ungated helper trips -Wunused-function. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG static const char *format_mac_address(std::span buffer, uint64_t address) { std::array mac{}; for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) { @@ -34,6 +44,7 @@ static const char *format_mac_address(std::span= ESPHOME_LOG_LEVEL_CONFIG static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) { switch (obj_type) { @@ -153,7 +164,7 @@ void BTHomeMiThermometer::dump_config() { LOG_SENSOR(" ", "Signal Strength", this->signal_strength_); } -bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { bool matched = false; for (auto &service_data : device.get_service_datas()) { if (this->handle_service_data_(service_data, device)) { @@ -200,7 +211,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if defined(BTHOME_CRYPTO_PSA) // PSA AEAD expects ciphertext + tag concatenated // BLE advertisement max payload is 31 bytes, so this is always sufficient static constexpr size_t MAX_CT_WITH_TAG = 32; @@ -232,29 +243,18 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return false; } #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8); - if (ret) { - ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext, - payload.data(), mic, BTHOME_MIC_SIZE); - mbedtls_ccm_free(&ctx); - if (ret) { - ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext, + ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) { + ESP_LOGVV(TAG, "BTHome decryption failed."); return false; } #endif return true; } -bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { return false; } @@ -435,5 +435,3 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD } } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 924858e449..4a95311557 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -8,11 +8,12 @@ #include #include -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform; this +// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM) +// decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); @@ -24,11 +25,11 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void dump_config() override; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: - bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device); + bool handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; @@ -45,5 +46,3 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, }; } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 9b50866db0..02551391ad 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -23,9 +23,9 @@ from esphome.const import ( from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer -CODEOWNERS = ["@nagyrobi"] +AUTO_LOAD = ["ble_device_base"] -DEPENDENCIES = ["esp32_ble_tracker"] +CODEOWNERS = ["@nagyrobi"] CONFIG_SCHEMA = bthome_mithermometer_base_schema( { diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index e6a63b8275..704a61d4de 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -2,9 +2,10 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" -#include "json_escape.h" namespace esphome::captive_portal { @@ -13,7 +14,7 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h deleted file mode 100644 index 0b3c71cd74..0000000000 --- a/esphome/components/captive_portal/json_escape.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once -#include -#include -#include - -#include "esphome/core/helpers.h" -#include "esphome/core/string_ref.h" - -namespace esphome::captive_portal { - -/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). -static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; - -/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. -/// -/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and -/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is -/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the -/// call can be used directly as an argument. -/// -/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for -/// the null terminator. -inline const char *json_escape_into_buffer(std::span buf, StringRef value) { - if (buf.empty()) - return ""; - // Reserve one byte for the null terminator. - const size_t limit = buf.size() - 1; - size_t pos = 0; - for (char ch : value) { - auto c = static_cast(ch); - // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping - // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. - char escape = '\0'; - switch (c) { - case '"': - escape = '"'; - break; - case '\\': - escape = '\\'; - break; - case '\n': - escape = 'n'; - break; - case '\r': - escape = 'r'; - break; - case '\t': - escape = 't'; - break; - case '\b': - escape = 'b'; - break; - case '\f': - escape = 'f'; - break; - default: - break; - } - if (escape != '\0') { - if (pos + 2 > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = escape; - } else if (c < 0x20) { - // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so - // the two high hex digits are always zero. - if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) - break; - buf[pos++] = '\\'; - buf[pos++] = 'u'; - buf[pos++] = '0'; - buf[pos++] = '0'; - buf[pos++] = format_hex_char(static_cast(c >> 4)); - buf[pos++] = format_hex_char(static_cast(c & 0x0F)); - } else { - if (pos + 1 > limit) - break; - buf[pos++] = static_cast(c); - } - } - buf[pos] = '\0'; - return buf.data(); -} - -} // namespace esphome::captive_portal diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 0feb384ac2..01e3ed0cd5 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -21,6 +21,7 @@ MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) +CC1101Listener = ns.class_("CC1101Listener") # Config keys CONF_RX_ATTENUATION = "rx_attenuation" @@ -48,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate" +CONF_FOC_LIMIT = "foc_limit" +CONF_FOC_PRE_K = "foc_pre_k" +CONF_FOC_POST_K = "foc_post_k" +CONF_BS_LIMIT = "bs_limit" +CONF_BS_PRE_KI = "bs_pre_ki" +CONF_BS_PRE_KP = "bs_pre_kp" +CONF_BS_POST_KI = "bs_post_ki" +CONF_BS_POST_KP = "bs_post_kp" # Packet mode config keys CONF_PACKET_MODE = "packet_mode" @@ -161,6 +171,64 @@ HYST_LEVEL = { "High": HystLevel.HYST_LEVEL_HIGH, } +FocLimit = ns.enum("FocLimit", True) +FOC_LIMIT = { + "Disabled": FocLimit.FOC_LIMIT_DISABLED, + "BW/8": FocLimit.FOC_LIMIT_BW_8, + "BW/4": FocLimit.FOC_LIMIT_BW_4, + "BW/2": FocLimit.FOC_LIMIT_BW_2, +} + +FocPreK = ns.enum("FocPreK", True) +FOC_PRE_K = { + "K": FocPreK.FOC_PRE_K_K, + "2K": FocPreK.FOC_PRE_K_2K, + "3K": FocPreK.FOC_PRE_K_3K, + "4K": FocPreK.FOC_PRE_K_4K, +} + +FocPostK = ns.enum("FocPostK", True) +FOC_POST_K = { + "Same": FocPostK.FOC_POST_K_SAME, + "K/2": FocPostK.FOC_POST_K_K_2, +} + +BsLimit = ns.enum("BsLimit", True) +BS_LIMIT = { + "Disabled": BsLimit.BS_LIMIT_DISABLED, + "3.125%": BsLimit.BS_LIMIT_3P125_PERCENT, + "6.25%": BsLimit.BS_LIMIT_6P25_PERCENT, + "12.5%": BsLimit.BS_LIMIT_12P5_PERCENT, +} + +BsPreKi = ns.enum("BsPreKi", True) +BS_PRE_KI = { + "KI": BsPreKi.BS_PRE_KI_KI, + "2KI": BsPreKi.BS_PRE_KI_2KI, + "3KI": BsPreKi.BS_PRE_KI_3KI, + "4KI": BsPreKi.BS_PRE_KI_4KI, +} + +BsPreKp = ns.enum("BsPreKp", True) +BS_PRE_KP = { + "KP": BsPreKp.BS_PRE_KP_KP, + "2KP": BsPreKp.BS_PRE_KP_2KP, + "3KP": BsPreKp.BS_PRE_KP_3KP, + "4KP": BsPreKp.BS_PRE_KP_4KP, +} + +BsPostKi = ns.enum("BsPostKi", True) +BS_POST_KI = { + "Same": BsPostKi.BS_POST_KI_SAME, + "KI/2": BsPostKi.BS_POST_KI_KI_2, +} + +BsPostKp = ns.enum("BsPostKp", True) +BS_POST_KP = { + "Same": BsPostKp.BS_POST_KP_SAME, + "KP": BsPostKp.BS_POST_KP_KP, +} + # Optional settings to generate setter calls for CONFIG_MAP = { cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0), @@ -214,6 +282,15 @@ CONFIG_MAP = { cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False), cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False), cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False), + cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean, + cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False), + cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False), + cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False), + cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False), + cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False), + cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False), + cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False), + cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False), cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean, cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t, cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index ea0138e1dd..f7b90b91cf 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_foc_bs_cs_gate(bool value) { + this->state_.FOC_BS_CS_GATE = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_limit(FocLimit value) { + this->state_.FOC_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_pre_k(FocPreK value) { + this->state_.FOC_PRE_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_post_k(FocPostK value) { + this->state_.FOC_POST_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_bs_limit(BsLimit value) { + this->state_.BS_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_ki(BsPreKi value) { + this->state_.BS_PRE_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_kp(BsPreKp value) { + this->state_.BS_PRE_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_ki(BsPostKi value) { + this->state_.BS_POST_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_kp(BsPostKp value) { + this->state_.BS_POST_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + void CC1101Component::set_packet_mode(bool value) { this->state_.PKT_FORMAT = static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 065ffd5250..79bfc9cb33 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -71,6 +71,17 @@ class CC1101Component final : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Frequency offset compensation and bit synchronization settings + void set_foc_bs_cs_gate(bool value); + void set_foc_limit(FocLimit value); + void set_foc_pre_k(FocPreK value); + void set_foc_post_k(FocPostK value); + void set_bs_limit(BsLimit value); + void set_bs_pre_ki(BsPreKi value); + void set_bs_pre_kp(BsPreKp value); + void set_bs_post_ki(BsPostKi value); + void set_bs_post_kp(BsPostKp value); + // Packet mode settings void set_packet_mode(bool value); void set_packet_length(uint8_t value); diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index 59b29f7478..6748f4369a 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -231,6 +231,56 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class FocLimit : uint8_t { + FOC_LIMIT_DISABLED, + FOC_LIMIT_BW_8, + FOC_LIMIT_BW_4, + FOC_LIMIT_BW_2, +}; + +enum class FocPreK : uint8_t { + FOC_PRE_K_K, + FOC_PRE_K_2K, + FOC_PRE_K_3K, + FOC_PRE_K_4K, +}; + +enum class FocPostK : uint8_t { + FOC_POST_K_SAME, + FOC_POST_K_K_2, +}; + +enum class BsLimit : uint8_t { + BS_LIMIT_DISABLED, + BS_LIMIT_3P125_PERCENT, + BS_LIMIT_6P25_PERCENT, + BS_LIMIT_12P5_PERCENT, +}; + +enum class BsPreKi : uint8_t { + BS_PRE_KI_KI, + BS_PRE_KI_2KI, + BS_PRE_KI_3KI, + BS_PRE_KI_4KI, +}; + +enum class BsPreKp : uint8_t { + BS_PRE_KP_KP, + BS_PRE_KP_2KP, + BS_PRE_KP_3KP, + BS_PRE_KP_4KP, +}; + +enum class BsPostKi : uint8_t { + BS_POST_KI_SAME, + BS_POST_KI_KI_2, +}; + +enum class BsPostKp : uint8_t { + BS_POST_KP_SAME, + BS_POST_KP_KP, +}; + enum class PacketFormat : uint8_t { PACKET_FORMAT_FIFO, PACKET_FORMAT_SYNC_SERIAL, diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fc1b0f368e..fe050fca22 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -274,7 +274,7 @@ def climate_schema( @setup_entity("climate") async def setup_climate_core_(var, config): - visual = config[CONF_VISUAL] + visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 599894c8a9..6c776e0228 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -205,6 +205,9 @@ class ClimateTraits { float get_visual_max_humidity() const { return this->visual_max_humidity_; } void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } + TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; } + void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; } + protected: void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { @@ -274,6 +277,7 @@ class ClimateTraits { climate::ClimateFanModeMask supported_fan_modes_; climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; + TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; /** Custom mode storage - pointers to vectors owned by the Climate base class. * diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f4fa9aaa7..44878274d6 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -24,21 +24,27 @@ CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" +CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" +CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" CONF_VOLUME_MAX = "volume_max" CONF_VOLUME_MIN = "volume_min" +CONF_WINDOW = "window" ICON_CURRENT_DC = "mdi:current-dc" ICON_SOLAR_PANEL = "mdi:solar-panel" diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 000db307b9..31559a514c 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -12,6 +12,7 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -109,6 +110,7 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54f..969cd840cf 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); ESP_LOGD(TAG, diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1cc04dcbd8..55b29310a1 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -28,7 +28,7 @@ size_t DebugComponent::get_device_info_(std::span ESP_LOGD(TAG, "LibreTiny debug info:\n" " Version: %s\n" - " Chip: %s (%04x) @ %u MHz\n" + " Chip: %s (%04x) @ %" PRIu32 " MHz\n" " Chip ID: 0x%06" PRIX32 "\n" " Board: %s\n" " Flash: %" PRIu32 " KiB\n" @@ -38,7 +38,7 @@ size_t DebugComponent::get_device_info_(std::span lt_get_board_code(), flash_kib, ram_kib, reset_reason); 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, <_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: "); diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index ba6081963f..4ace4be0a3 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,8 +1,9 @@ #include "debug_component.h" #ifdef USE_RP2 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include #include #if defined(PICO_RP2350) #include @@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } +// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here. +uint32_t DebugComponent::get_free_heap_() { return RAMAllocator().get_free_heap_size(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = ::rp2040.f_cpu(); + uint32_t cpu_freq = clock_get_hz(clk_sys); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 9666c8e507..3b70f947d2 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, + CONF_TRIGGER_ID, CONF_WAKEUP_PIN, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -234,6 +235,15 @@ EXT1_WAKEUP_MODES = { } WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") +WakeupCause = deep_sleep_ns.enum("WakeupCause") +WakeTrigger = deep_sleep_ns.class_( + "WakeTrigger", automation.Trigger.template(WakeupCause), cg.Component +) +Ext1WakeTrigger = deep_sleep_ns.class_( + "Ext1WakeTrigger", automation.Trigger.template(), cg.Component +) + +CONF_ON_WAKE = "on_wake" CONF_WAKEUP_PIN_MODE = "wakeup_pin_mode" CONF_ESP32_EXT1_WAKEUP = "esp32_ext1_wakeup" CONF_TOUCH_WAKEUP = "touch_wakeup" @@ -256,6 +266,22 @@ WAKEUP_PIN_SCHEMA = cv.Schema( } ) +EXT1_WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): cv.All( + pins.internal_gpio_input_pin_schema, validate_pin_number_esp32 + ), + cv.Optional(CONF_ON_WAKE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Ext1WakeTrigger)} + ), + } +) + +# Entries that are not in the {pin: ..., on_wake: ...} form are treated as a +# bare pin config (the original syntax, e.g. a plain "GPIO5" or {number: 5}). +validate_ext1_wakeup_pin = cv.maybe_simple_value(EXT1_WAKEUP_PIN_SCHEMA, key=CONF_PIN) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -282,8 +308,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, - validate_pin_number_esp32, + validate_ext1_wakeup_pin, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -292,6 +317,12 @@ CONFIG_SCHEMA = cv.All( } ), ), + cv.Optional(CONF_ON_WAKE): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger)} + ), + ), cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( @@ -323,7 +354,7 @@ async def to_code(config): if CONF_WAKEUP_PIN in config: pins_as_list = config.get(CONF_WAKEUP_PIN, []) if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins_(len(pins_as_list))) + cg.add(var.init_wakeup_pins(len(pins_as_list))) for item in pins_as_list: cg.add( var.add_wakeup_pin( @@ -362,16 +393,27 @@ async def to_code(config): ) cg.add(var.set_run_duration(wakeup_cause_to_run_duration)) - if CONF_ESP32_EXT1_WAKEUP in config: - conf = config[CONF_ESP32_EXT1_WAKEUP] + if (ext1_conf := config.get(CONF_ESP32_EXT1_WAKEUP)) is not None: mask = 0 - for pin in conf[CONF_PINS]: - mask |= 1 << pin[CONF_NUMBER] + for pin_conf in ext1_conf[CONF_PINS]: + number = pin_conf[CONF_PIN][CONF_NUMBER] + mask |= 1 << number + for wake_conf in pin_conf.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID], number) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") struct = cg.StructInitializer( - Ext1Wakeup, ("mask", mask), ("wakeup_mode", conf[CONF_MODE]) + Ext1Wakeup, ("mask", mask), ("wakeup_mode", ext1_conf[CONF_MODE]) ) cg.add(var.set_ext1_wakeup(struct)) + for wake_conf in config.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID]) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") + 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: diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 8dca32689b..73e0331c76 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -7,6 +7,21 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep.bk72xx"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SLEEP_GPIO: + return WAKEUP_CAUSE_GPIO; + case REBOOT_REASON_SLEEP_RTC: + return WAKEUP_CAUSE_TIMER; + case REBOOT_REASON_SLEEP_USB: + return WAKEUP_CAUSE_UNKNOWN; + default: + return WAKEUP_CAUSE_NONE; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() { @@ -15,15 +30,15 @@ void DeepSleepComponent::dump_config_platform_() { } } -bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const { - return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr && - !this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin))); +bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pin_item) const { + return (pin_item.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pin_item.wakeup_pin != nullptr && + !this->sleep_duration_.has_value() && (pin_item.wakeup_level == get_real_pin_state_(*pin_item.wakeup_pin))); } bool DeepSleepComponent::prepare_to_sleep_() { - if (wakeup_pins_.size() > 0) { + if (!this->wakeup_pins_.empty()) { for (WakeUpPinItem &item : this->wakeup_pins_) { - if (pin_prevents_sleep_(item)) { + if (this->pin_prevents_sleep_(item)) { // Defer deep sleep until inactive if (!this->next_enter_deep_sleep_) { this->status_set_warning(); @@ -44,7 +59,7 @@ void DeepSleepComponent::deep_sleep_() { item.wakeup_level = !item.wakeup_level; } } - ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", + ESP_LOGI(TAG, "Wake-up on P%u %s (%" PRId32 ")", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", static_cast(item.wakeup_pin_mode)); } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 896ed092aa..a620d52a02 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -60,6 +60,65 @@ struct WakeupCauseToRunDuration { #endif // USE_ESP32 +#ifdef USE_DEEP_SLEEP_ON_WAKE + +/// Why the device woke from deep sleep. Passed to on_wake automations. +enum WakeupCause : uint8_t { + /// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart). + WAKEUP_CAUSE_NONE = 0, + /// The device woke from deep sleep, but the source could not be identified. + WAKEUP_CAUSE_UNKNOWN, + /// The device was woken by the sleep timer. + WAKEUP_CAUSE_TIMER, + /// The device was woken by a GPIO pin (wakeup_pin or esp32_ext1_wakeup). + WAKEUP_CAUSE_GPIO, + /// The device was woken by a touch pad. + WAKEUP_CAUSE_TOUCH, +}; + +/// Return why the device woke from deep sleep. Implemented per platform. +WakeupCause get_wakeup_cause(); + +/** Setup priority of on_wake triggers. + * + * Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at + * their default priority (600), so on_wake automations can update state (e.g. globals) that + * on_boot automations then use. + */ +inline constexpr float ON_WAKE_TRIGGER_SETUP_PRIORITY = 700.0f; + +/// Fires once on boot when the device woke from deep sleep, with the wakeup cause. +class WakeTrigger : public Trigger, public Component { + public: + void setup() override { + const WakeupCause cause = get_wakeup_cause(); + if (cause != WAKEUP_CAUSE_NONE) { + this->trigger(cause); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } +}; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) +/// Fires once on boot when the device was woken from deep sleep by the given ext1 pin. +class Ext1WakeTrigger : public Trigger<>, public Component { + public: + explicit Ext1WakeTrigger(uint8_t pin) : pin_(pin) {} + void setup() override { + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 && + (esp_sleep_get_ext1_wakeup_status() & (1ULL << this->pin_))) { + this->trigger(); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } + + protected: + uint8_t pin_; +}; +#endif + +#endif // USE_DEEP_SLEEP_ON_WAKE + template class EnterDeepSleepAction; template class PreventDeepSleepAction; @@ -84,7 +143,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 #if defined(USE_BK72XX) - void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); } + void init_wakeup_pins(size_t capacity) { this->wakeup_pins_.init(capacity); } void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) { this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()}); } @@ -132,7 +191,7 @@ class DeepSleepComponent final : public Component { bool should_teardown_(); #ifdef USE_BK72XX - bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } #endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 7cb8e53efd..f64e1f37e1 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -30,6 +30,25 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (esp_sleep_get_wakeup_cause()) { + case ESP_SLEEP_WAKEUP_EXT0: + case ESP_SLEEP_WAKEUP_EXT1: + case ESP_SLEEP_WAKEUP_GPIO: + return WAKEUP_CAUSE_GPIO; + case ESP_SLEEP_WAKEUP_TIMER: + return WAKEUP_CAUSE_TIMER; + case ESP_SLEEP_WAKEUP_TOUCHPAD: + return WAKEUP_CAUSE_TOUCH; + case ESP_SLEEP_WAKEUP_UNDEFINED: + return WAKEUP_CAUSE_NONE; + default: + return WAKEUP_CAUSE_UNKNOWN; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { if (this->wakeup_cause_to_run_duration_.has_value()) { esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause(); diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 9239a7fb31..2b98f4b855 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,10 +3,27 @@ #include +#ifdef USE_DEEP_SLEEP_ON_WAKE +extern "C" { +#include +} +#endif + namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + // The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST). + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + if (ESP.getResetInfoPtr()->reason == REASON_DEEP_SLEEP_AWAKE) { + return WAKEUP_CAUSE_TIMER; + } + return WAKEUP_CAUSE_NONE; +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py new file mode 100644 index 0000000000..5a26ceab50 --- /dev/null +++ b/esphome/components/ds248x/__init__.py @@ -0,0 +1,112 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE + +CODEOWNERS = ["@tomwellnitz"] +MULTI_CONF = True +DEPENDENCIES = ["i2c"] + +CONF_DS248X_ID = "ds248x_id" +CONF_BUS_SLEEP = "bus_sleep" +CONF_HUB_SLEEP = "hub_sleep" +CONF_ACTIVE_PULLUP = "active_pullup" + +CONF_RESET_LOW_TIME = "reset_low_time" +CONF_MASTER_SAMPLE_TIME = "master_sample_time" +CONF_WRITE_0_LOW_TIME = "write_0_low_time" +CONF_RECOVERY_TIME = "recovery_time" +CONF_ACTIVE_PULLUP_RESISTANCE = "active_pullup_resistance" + +TYPE_DS2482_100 = "ds2482-100" +TYPE_DS2482_101 = "ds2482-101" +TYPE_DS2482_800 = "ds2482-800" +TYPE_DS2484 = "ds2484" + +CHANNEL_COUNTS = { + TYPE_DS2482_100: 1, + TYPE_DS2482_101: 1, + TYPE_DS2482_800: 8, + TYPE_DS2484: 1, +} + +ds248x_ns = cg.esphome_ns.namespace("ds248x") +DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) + + +def _component_schema(*extras): + schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xComponent), + cv.Optional(CONF_ACTIVE_PULLUP, default=False): cv.boolean, + } + ) + for extra in extras: + schema = schema.extend(extra) + return schema.extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x18)) + + +SLEEP_SCHEMA = { + cv.Optional(CONF_SLEEP_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_BUS_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_HUB_SLEEP, default=False): cv.boolean, +} + +DS2484_SCHEMA = { + cv.Optional(CONF_RESET_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_MASTER_SAMPLE_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_WRITE_0_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_RECOVERY_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_ACTIVE_PULLUP_RESISTANCE): cv.enum( + { + # DS2484 Table 7: value codes 0-5 map to 500 ohm, 6-15 map to 1000 ohm. + "500ohm": 0, + "1000ohm": 6, + } + ), +} + +CONFIG_SCHEMA = cv.typed_schema( + { + TYPE_DS2482_100: _component_schema(), + TYPE_DS2482_101: _component_schema(SLEEP_SCHEMA), + TYPE_DS2482_800: _component_schema(), + TYPE_DS2484: _component_schema(SLEEP_SCHEMA, DS2484_SCHEMA), + }, + key=CONF_TYPE, + lower=True, +) + + +def get_channel_count(config): + return CHANNEL_COUNTS[config[CONF_TYPE]] + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) + cg.add(var.set_channel_count(get_channel_count(config))) + + if CONF_BUS_SLEEP in config: + cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP])) + if CONF_HUB_SLEEP in config: + cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP])) + + if CONF_RESET_LOW_TIME in config: + cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME])) + if CONF_MASTER_SAMPLE_TIME in config: + cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME])) + if CONF_WRITE_0_LOW_TIME in config: + cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME])) + if CONF_RECOVERY_TIME in config: + cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME])) + if CONF_ACTIVE_PULLUP_RESISTANCE in config: + cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) + + if CONF_SLEEP_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) + cg.add(var.set_sleep_pin(pin)) diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp new file mode 100644 index 0000000000..c8f7395119 --- /dev/null +++ b/esphome/components/ds248x/ds248x.cpp @@ -0,0 +1,320 @@ +#include "ds248x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x"; + +void DS248xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x..."); + + // Wake up device if sleep pin is configured + if (this->sleep_pin_) { + this->sleep_pin_->setup(); + this->sleep_pin_->pin_mode(esphome::gpio::FLAG_OUTPUT); + this->sleep_pin_->digital_write(true); // Wake up + delay(1); // DS2482-101 Datasheet: tOSCWUP = 100μs (using 10x margin) + } + + // Probe device + ESP_LOGD(TAG, "Probing DS248x..."); + uint8_t status = 0; + if (this->read(&status, 1) == i2c::ERROR_OK) { + ESP_LOGD(TAG, "Device responded! Status: 0x%02x", status); + } else { + ESP_LOGW(TAG, "Device did not respond. Trying reset anyway..."); + } + + if (!this->device_reset_()) { + ESP_LOGW(TAG, "DS248x reset failed during setup!"); + } + + // Configure device + if (!this->device_configure_()) { + ESP_LOGE(TAG, "DS248x configuration failed!"); + this->mark_failed(); + return; + } + + // Reset to Channel 0 + this->select_channel(0); + + ESP_LOGI(TAG, "DS248x initialized successfully."); +} + +void DS248xComponent::on_shutdown() { + if (this->sleep_pin_ && (this->hub_sleep_ || this->bus_sleep_)) { + this->sleep_pin_->digital_write(false); // Sleep + } +} + +void DS248xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Channel Count: %d", this->channel_count_); + ESP_LOGCONFIG(TAG, " Active Pullup: %s", YESNO(this->active_pullup_)); + if (this->ds2484_mode_) { + ESP_LOGCONFIG(TAG, " DS2484 Mode: enabled"); + } +} + +// --- Internal Helpers --- + +// Datasheet command durations are sub-2ms; allow a little margin before forcing recovery. +static constexpr uint32_t BUSY_TIMEOUT_MS = 5; + +bool DS248xComponent::set_read_pointer_(uint8_t ptr) { return this->write_byte(DS248X_COMMAND_SETREADPTR, ptr); } + +bool DS248xComponent::wait_busy_() { + uint32_t start = millis(); + do { + uint8_t status; + if (this->read(&status, 1) == i2c::ERROR_OK && !(status & DS248X_STATUS_BUSY)) + return true; + delayMicroseconds(100); + } while (millis() - start < BUSY_TIMEOUT_MS); + ESP_LOGW(TAG, "DS248x busy timeout"); + bool recovered = this->device_reset_() && this->device_configure_(); + this->current_channel_ = -1; + if (!recovered) { + ESP_LOGE(TAG, "DS248x recovery failed after busy timeout"); + this->mark_failed(); + } + return false; +} + +bool DS248xComponent::device_reset_() { + ESP_LOGD(TAG, "Resetting device..."); + uint8_t cmd = DS248X_COMMAND_RESET; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + if (!(status & DS248X_STATUS_RST)) { + ESP_LOGW(TAG, "Device reset failed (RST bit not set)"); + return false; + } + + this->current_channel_ = -1; + return true; +} + +bool DS248xComponent::device_configure_() { + ESP_LOGD(TAG, "Configuring device..."); + + if (!this->write_config_()) { + ESP_LOGW(TAG, "Config write/verify failed"); + return false; + } + + ESP_LOGD(TAG, "Configured successfully"); + + // DS2484 Configuration + if (this->ds2484_mode_) { + if (this->ds2484_trstl_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TRSTL, this->ds2484_trstl_)) + return false; + if (this->ds2484_tmsp_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TMSP, this->ds2484_tmsp_)) + return false; + if (this->ds2484_tw0l_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TW0L, this->ds2484_tw0l_)) + return false; + if (this->ds2484_trec0_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TREC0, this->ds2484_trec0_)) + return false; + if (this->ds2484_rwpu_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_RWPU, this->ds2484_rwpu_)) + return false; + } + + return true; +} + +bool DS248xComponent::configure_ds2484_port_(uint8_t param, uint8_t val) { + uint8_t cmd = DS2484_COMMAND_ADJUSTPORT; + // Control Byte format (DS2484 Table 6): P[2:0] in bits 7:5, OD in bit 4, VAL[3:0] in bits 3:0 + uint8_t data = ((param & 0x07) << 5) | (val & 0x0F); + + // The DS2484 always acknowledges the Adjust 1-Wire Port control byte (datasheet "Adjust + // 1-Wire Port"), so a successful write confirms the update. We deliberately do not read + // back to verify: a single read of the Port Configuration register always returns the + // fixed 8-byte report starting at Byte 1 (tRSTL standard speed), not the parameter that + // was just written, so a per-parameter readback comparison would spuriously fail for + // tMSP/tW0L/tREC0/RWPU. + if (!this->write_byte(cmd, data)) { + ESP_LOGW(TAG, "DS2484 port config failed (param %d)", param); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +bool DS248xComponent::write_config_() { + uint8_t config = 0; + if (this->active_pullup_) + config |= DS248X_CONFIG_ACTIVE_PULLUP; + + // The DS248x only accepts the config byte if the upper nibble is the one's-complement of the lower nibble. + uint8_t config_byte = (config & 0x0F) | ((~config & 0x0F) << 4); + + if (!this->write_byte(DS248X_COMMAND_WRITECONFIG, config_byte)) { + ESP_LOGW(TAG, "Failed to write config byte"); + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_CONFIG)) { + return false; + } + + uint8_t read_config; + if (this->read(&read_config, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Failed to read back config byte"); + return false; + } + + if ((read_config & 0x0F) != (config_byte & 0x0F)) { + ESP_LOGW(TAG, "Config mismatch! Wrote 0x%02x, Read 0x%02x", config_byte, read_config); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +// --- Channel Selection --- + +// Channel select codes: write code -> expected read code +static constexpr uint8_t CHANNEL_WRITE_CODES[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87}; +static constexpr uint8_t CHANNEL_READ_CODES[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87}; + +bool DS248xComponent::select_channel(uint8_t channel) { + if (this->channel_count_ <= 1) + return true; + if (channel >= this->channel_count_) + return false; + + if (this->current_channel_ == channel) + return true; + + if (!this->write_byte(DS248X_COMMAND_CHANNELSELECT, CHANNEL_WRITE_CODES[channel])) { + this->current_channel_ = -1; + return false; + } + + uint8_t read_code; + if (this->read(&read_code, 1) != i2c::ERROR_OK) { + this->current_channel_ = -1; + return false; + } + + if (read_code != CHANNEL_READ_CODES[channel]) { + ESP_LOGW(TAG, "Channel select failed! Expected 0x%02x, got 0x%02x", CHANNEL_READ_CODES[channel], read_code); + this->current_channel_ = -1; + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + this->current_channel_ = channel; + return true; +} + +// --- 1-Wire Bus Operations --- + +bool DS248xComponent::ow_reset(bool &presence) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_RESETWIRE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "ow_reset: wait busy failed"); + return false; + } + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "ow_reset: read status failed"); + return false; + } + + if (status & DS248X_STATUS_SD) { + ESP_LOGW(TAG, "Short detected on 1-Wire bus!"); + return false; + } + + presence = (status & DS248X_STATUS_PPD); + return true; +} + +bool DS248xComponent::ow_write_byte(uint8_t byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Device busy before writing byte 0x%02x", byte); + return false; + } + + uint8_t cmd[2] = {DS248X_COMMAND_WRITEBYTE, byte}; + if (this->write(cmd, 2) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write failed for byte 0x%02x", byte); + return false; + } + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Timeout waiting for write byte to complete!"); + return false; + } + + return true; +} + +bool DS248xComponent::ow_read_byte(uint8_t &byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_READBYTE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (!this->set_read_pointer_(DS248X_POINTER_DATA)) + return false; + + if (this->read(&byte, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +bool DS248xComponent::search_triplet(bool search_direction, uint8_t &status) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + // DS248x Datasheet: 1-Wire Triplet command requires 2 bytes: + // Byte 1: Command code 0x78 + // Byte 2: Direction byte (bit 7 = V, search direction if discrepancy) + uint8_t buffer[2] = {DS248X_COMMAND_TRIPLET, static_cast(search_direction ? 0x80 : 0x00)}; + if (this->write(buffer, 2) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x.h b/esphome/components/ds248x/ds248x.h new file mode 100644 index 0000000000..0873ee0e2a --- /dev/null +++ b/esphome/components/ds248x/ds248x.h @@ -0,0 +1,133 @@ +#pragma once + +// DS248x I2C-to-1-Wire Bridge Family +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-100.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-800.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2484.pdf + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds248x { + +// DS248x I2C Commands +static constexpr uint8_t DS248X_COMMAND_RESET = 0xF0; +static constexpr uint8_t DS248X_COMMAND_SETREADPTR = 0xE1; +static constexpr uint8_t DS248X_COMMAND_WRITECONFIG = 0xD2; +static constexpr uint8_t DS248X_COMMAND_CHANNELSELECT = 0xC3; +static constexpr uint8_t DS248X_COMMAND_RESETWIRE = 0xB4; +static constexpr uint8_t DS248X_COMMAND_WRITEBYTE = 0xA5; +static constexpr uint8_t DS248X_COMMAND_READBYTE = 0x96; +static constexpr uint8_t DS248X_COMMAND_TRIPLET = 0x78; +static constexpr uint8_t DS2484_COMMAND_ADJUSTPORT = 0xC3; + +// DS2484 "Adjust 1-Wire Port" parameter codes (datasheet Table 6, control byte P[2:0]) +static constexpr uint8_t DS2484_PORT_PARAM_TRSTL = 0x0; +static constexpr uint8_t DS2484_PORT_PARAM_TMSP = 0x1; +static constexpr uint8_t DS2484_PORT_PARAM_TW0L = 0x2; +static constexpr uint8_t DS2484_PORT_PARAM_TREC0 = 0x3; +static constexpr uint8_t DS2484_PORT_PARAM_RWPU = 0x4; + +// DS248x Status Register Bits +static constexpr uint8_t DS248X_STATUS_BUSY = 0x01; +static constexpr uint8_t DS248X_STATUS_PPD = 0x02; +static constexpr uint8_t DS248X_STATUS_SD = 0x04; +static constexpr uint8_t DS248X_STATUS_RST = 0x10; +static constexpr uint8_t DS248X_STATUS_SBR = 0x20; +static constexpr uint8_t DS248X_STATUS_TSB = 0x40; +static constexpr uint8_t DS248X_STATUS_DIR = 0x80; + +// DS248x Register Pointers +static constexpr uint8_t DS248X_POINTER_STATUS = 0xF0; +static constexpr uint8_t DS248X_POINTER_DATA = 0xE1; +static constexpr uint8_t DS248X_POINTER_CONFIG = 0xC3; + +// DS248x Configuration Bits +static constexpr uint8_t DS248X_CONFIG_ACTIVE_PULLUP = 0x01; + +/** + * @brief DS248x I2C-to-1-Wire Bridge Component. + * + * This component manages the DS248x chip (DS2482-100, DS2482-800, DS2484). + * It provides low-level 1-Wire bus operations via I2C. + * + * Usage: Configure DS248xOneWireBus instances for each channel. + * These buses implement the one_wire::OneWireBus interface for compatibility + * with all existing 1-Wire device components (dallas_temp, etc.). + */ +class DS248xComponent : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void set_sleep_pin(InternalGPIOPin *pin) { this->sleep_pin_ = pin; } + void set_bus_sleep(bool enabled) { this->bus_sleep_ = enabled; } + void set_hub_sleep(bool enabled) { this->hub_sleep_ = enabled; } + void set_channel_count(uint8_t count) { this->channel_count_ = count; } + void set_active_pullup(bool enabled) { this->active_pullup_ = enabled; } + + // DS2484 Timing Parameters + void set_val_trstl(uint8_t val) { + this->ds2484_trstl_ = val; + this->ds2484_mode_ = true; + } + void set_val_tmsp(uint8_t val) { + this->ds2484_tmsp_ = val; + this->ds2484_mode_ = true; + } + void set_val_tw0l(uint8_t val) { + this->ds2484_tw0l_ = val; + this->ds2484_mode_ = true; + } + void set_val_trec0(uint8_t val) { + this->ds2484_trec0_ = val; + this->ds2484_mode_ = true; + } + void set_val_rwpu(uint8_t val) { + this->ds2484_rwpu_ = val; + this->ds2484_mode_ = true; + } + + /// Get the channel count (1 for DS2482-100/DS2484, 8 for DS2482-800) + uint8_t get_channel_count() const { return this->channel_count_; } + + // --- Core 1-Wire API (used by DS248xOneWireBus) --- + bool select_channel(uint8_t channel); + bool ow_reset(bool &presence); + bool ow_write_byte(uint8_t byte); + bool ow_read_byte(uint8_t &byte); + + // --- Search support (used by DS248xOneWireBus) --- + bool search_triplet(bool search_direction, uint8_t &status); + + protected: + InternalGPIOPin *sleep_pin_{nullptr}; + uint8_t channel_count_ = 1; + bool bus_sleep_{false}; + bool hub_sleep_{false}; + bool active_pullup_ = false; + + // DS2484 Config + bool ds2484_mode_ = false; + static constexpr uint8_t DS2484_PARAM_UNSET = 0xFF; + uint8_t ds2484_trstl_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tmsp_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tw0l_{DS2484_PARAM_UNSET}; + uint8_t ds2484_trec0_{DS2484_PARAM_UNSET}; + uint8_t ds2484_rwpu_{DS2484_PARAM_UNSET}; + + int8_t current_channel_{-1}; + + // Internal helpers + bool set_read_pointer_(uint8_t ptr); + bool wait_busy_(); + bool device_reset_(); + bool device_configure_(); + bool configure_ds2484_port_(uint8_t param, uint8_t val); + bool write_config_(); +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp new file mode 100644 index 0000000000..ed5b05bab7 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -0,0 +1,171 @@ +#include "ds248x_one_wire_bus.h" +#include "ds248x.h" +#include "esphome/core/log.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x.one_wire"; + +void DS248xOneWireBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); + + // Parent setup happens in DS248xComponent::setup() + // We just need to scan for devices on this channel + if (!this->ensure_channel_()) { + ESP_LOGE(TAG, "Failed to select channel %d during setup", this->channel_); + this->mark_failed(); + return; + } + + // Perform device search on this channel + this->search(); + + ESP_LOGCONFIG(TAG, "Found %zu devices on channel %d", this->devices_.size(), this->channel_); +} + +void DS248xOneWireBus::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x 1-Wire Bus (Channel %d):", this->channel_); + this->dump_devices_(TAG); +} + +bool DS248xOneWireBus::ensure_channel_() { + if (this->parent_ == nullptr) { + ESP_LOGE(TAG, "Parent not set!"); + return false; + } + return this->parent_->select_channel(this->channel_); +} + +int DS248xOneWireBus::reset_int() { + if (!this->ensure_channel_()) { + return -1; + } + + bool presence = false; + if (!this->parent_->ow_reset(presence)) { + return -1; + } + return presence ? 1 : 0; +} + +void DS248xOneWireBus::write8(uint8_t val) { + if (!this->ensure_channel_()) { + return; + } + if (!this->parent_->ow_write_byte(val)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X on channel %d", val, this->channel_); + } +} + +void DS248xOneWireBus::write64(uint64_t val) { + if (!this->ensure_channel_()) { + return; + } + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = static_cast(val >> (i * 8)); + if (!this->parent_->ow_write_byte(byte)) { + ESP_LOGE(TAG, "Failed to write byte %d/8 (0x%02X) on channel %d - aborting write64", i + 1, byte, this->channel_); + return; // Stop writing to prevent sending corrupted data + } + } +} + +uint8_t DS248xOneWireBus::read8() { + if (!this->ensure_channel_()) { + return 0; + } + uint8_t value = 0; + if (!this->parent_->ow_read_byte(value)) { + ESP_LOGE(TAG, "Failed to read byte on channel %d", this->channel_); + } + return value; +} + +uint64_t DS248xOneWireBus::read64() { + if (!this->ensure_channel_()) { + return 0; + } + uint64_t value = 0; + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = 0; + if (!this->parent_->ow_read_byte(byte)) { + ESP_LOGE(TAG, "Failed to read byte %d/8 on channel %d - returning partial data", i + 1, this->channel_); + return value; // Return partial data to avoid blocking, caller should validate + } + value |= (static_cast(byte) << (i * 8)); + } + return value; +} + +void DS248xOneWireBus::reset_search() { + this->search_last_discrepancy_ = 0; + this->search_last_device_flag_ = false; + this->search_address_ = 0; +} + +uint64_t DS248xOneWireBus::search_int() { + if (!this->ensure_channel_()) { + return 0; + } + + if (this->search_last_device_flag_) { + return 0; + } + + uint8_t last_zero = 0; + uint64_t address = this->search_address_; + + // Iterate through all 64 bits + for (uint8_t bit_number = 1; bit_number <= 64; bit_number++) { + uint64_t bit_mask = 1ULL << (bit_number - 1); + + // Determine search direction + bool search_direction; + if (bit_number < this->search_last_discrepancy_) { + search_direction = (address & bit_mask) != 0; + } else { + search_direction = (bit_number == this->search_last_discrepancy_); + } + + // Perform triplet operation + uint8_t status = 0; + if (!this->parent_->search_triplet(search_direction, status)) { + ESP_LOGW(TAG, "1-Wire triplet failed at bit %d on channel %d - aborting search", bit_number, this->channel_); + this->reset_search(); + return 0; + } + + bool id_bit = (status & DS248X_STATUS_SBR) != 0; + bool cmp_id_bit = (status & DS248X_STATUS_TSB) != 0; + bool dir_taken = (status & DS248X_STATUS_DIR) != 0; + + if (id_bit && cmp_id_bit) { + // No devices participating + this->reset_search(); + return 0; + } + + if (!id_bit && !cmp_id_bit && !dir_taken) { + // Discrepancy, went 0 - record position + last_zero = bit_number; + } + + // Update address based on direction taken + if (dir_taken) { + address |= bit_mask; + } else { + address &= ~bit_mask; + } + } + + // Search successful + this->search_last_discrepancy_ = last_zero; + if (last_zero == 0) { + this->search_last_device_flag_ = true; + } + this->search_address_ = address; + + return address; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.h b/esphome/components/ds248x/ds248x_one_wire_bus.h new file mode 100644 index 0000000000..0591796d60 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/one_wire/one_wire_bus.h" + +namespace esphome::ds248x { + +class DS248xComponent; + +/** + * @brief OneWireBus implementation for DS248x I2C-to-1-Wire bridges. + * + * This class wraps the DS248xComponent to provide the one_wire::OneWireBus interface, + * enabling compatibility with all existing 1-Wire device components (dallas_temp, etc.). + * + * For DS2482-800, multiple instances of this class can be created (one per channel). + * For DS2482-100/DS2484, a single instance is used. + */ +class DS248xOneWireBus : public one_wire::OneWireBus, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + + /// Set the parent DS248x component + void set_parent(DS248xComponent *parent) { this->parent_ = parent; } + + /// Set the 1-Wire channel (0-7, only relevant for DS2482-800) + void set_channel(uint8_t channel) { this->channel_ = channel; } + + /// Get the channel number + uint8_t get_channel() const { return this->channel_; } + + // OneWireBus interface implementation + int reset_int() override; + void write8(uint8_t val) override; + void write64(uint64_t val) override; + uint8_t read8() override; + uint64_t read64() override; + + protected: + void reset_search() override; + uint64_t search_int() override; + + /// Select the channel on the DS248x before any 1-Wire operation + bool ensure_channel_(); + + DS248xComponent *parent_{nullptr}; + uint8_t channel_{0}; + + // Search state + uint64_t search_address_{0}; + uint8_t search_last_discrepancy_{0}; + bool search_last_device_flag_{false}; +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py new file mode 100644 index 0000000000..19861eae36 --- /dev/null +++ b/esphome/components/ds248x/one_wire.py @@ -0,0 +1,56 @@ +"""DS248x 1-Wire Bus Platform. + +This platform creates one_wire bus instances backed by a DS248x I2C-to-1-Wire bridge. +It supports DS2482-100/101 (single channel), DS2482-800 (8 channels), and DS2484 (single channel). + +For multi-channel devices (DS2482-800), create one platform entry per channel. +Each entry becomes a separate one_wire bus that can be used by dallas_temp and other 1-Wire devices. +""" + +from esphome import final_validate as fv +import esphome.codegen as cg +from esphome.components.one_wire import OneWireBus +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID + +from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count + +CODEOWNERS = ["@tomwellnitz"] +DEPENDENCIES = ["ds248x"] + +DS248xOneWireBus = ds248x_ns.class_("DS248xOneWireBus", OneWireBus, cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xOneWireBus), + cv.GenerateID(CONF_DS248X_ID): cv.use_id(DS248xComponent), + cv.Optional(CONF_CHANNEL, default=0): cv.int_range(min=0, max=7), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config): + """Validate that the channel is within the parent's channel count.""" + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] + parent_config = fconf.get_config_for_path(path) + channel_count = get_channel_count(parent_config) + channel = config[CONF_CHANNEL] + + if channel >= channel_count: + raise cv.Invalid( + f"Channel {channel} is invalid for DS248x with {channel_count} channel(s). " + f"Valid range: 0-{channel_count - 1}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_DS248X_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index d4ffd034a1..7b7c48c0b0 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); } +/** Map RGB color to discrete BWYRGB hex 6 color key + * + * Divides the RGB cube into 8 corners by which components are "on" (over 128), same as + * color_to_bwyr, but also resolves the green and blue corners instead of folding them into + * white/black. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) { + return hw_yellow; + } + if (r_on && !g_on && !b_on) { + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + +/** Map RGB color to discrete BWYRGBO hex 7 color key + * + * Same corner logic as color_to_bwyrgb, except the red/yellow corner is split three ways + * instead of two, for panels with a dedicated orange ink. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @param hw_orange Native value for orange + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue, NATIVE_COLOR hw_orange) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && !b_on) { + // Between red and yellow: split the gradient in three instead of two, since this panel has a + // dedicated orange ink. Named orange (e.g. 0xFFA500) has g close to the midpoint, so the + // plain g_on (>128) threshold used by color_to_bwyrgb can't tell it apart from yellow. + if (color.g > 170) { + return hw_yellow; + } + if (color.g > 85) { + return hw_orange; + } + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp new file mode 100644 index 0000000000..820dc3c77a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -0,0 +1,82 @@ +#include "epaper_spi_4bpp.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.4bpp"; + +void EPaper4bpp::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + + auto pixel_color = this->color_to_native(color); + + // We store 2 pixels per byte + this->buffer_.fill(pixel_color + (pixel_color << 4)); + + // Whole buffer just changed; mark the entire canvas dirty. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaper4bpp::clear() { + // clear buffer to white, just like real paper. + this->fill(COLOR_ON); +} + +void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = this->color_to_native(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +bool HOT EPaper4bpp::transfer_data() { + const uint32_t start_time = App.get_loop_component_start_time(); + const size_t buffer_length = this->buffer_length_; + if (this->current_data_index_ == 0) { + this->command(CMD_TRANSFER_DATA); + } + + size_t buf_idx = 0; + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + while (this->current_data_index_ != buffer_length) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); + buf_idx = 0; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + return false; + } + } + } + // Finished the entire dataset + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h new file mode 100644 index 0000000000..6eebe24c91 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -0,0 +1,35 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte). + * + * Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by + * this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via + * color_to_native() plus their IC-specific power/refresh/sleep command sequences. + */ +class EPaper4bpp : public EPaperBase { + public: + EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = width * height / 2; // 2 pixels per byte + } + + void fill(Color color) override; + void clear() override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + + /// Map an RGB color to this panel's native 4-bit color key (low nibble). + virtual uint8_t color_to_native(Color color) = 0; + + static constexpr uint8_t CMD_TRANSFER_DATA = 0x10; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp new file mode 100644 index 0000000000..b81542b6c1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -0,0 +1,47 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +#include "epaper_spi_inkplate6color.h" +#include "colorconv.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate6color"; + +// Native hardware color codes for this panel's 4-bit color values. +enum Inkplate6ColorHex : uint8_t { + BLACK = 0, + WHITE = 1, + GREEN = 2, + BLUE = 3, + RED = 4, + YELLOW = 5, + ORANGE = 6, +}; + +uint8_t EPaperInkplate6Color::color_to_native(Color color) { + return color_to_bwyrgbo(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE, ORANGE); +} + +void EPaperInkplate6Color::power_on() { + ESP_LOGV(TAG, "Power on"); + this->command(0x04); +} + +void EPaperInkplate6Color::power_off() { + ESP_LOGV(TAG, "Power off"); + this->command(0x02); +} + +void EPaperInkplate6Color::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh"); // full refresh only; partial is unused + this->cmd_data(0x12, {0x00}); +} + +void EPaperInkplate6Color::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.h b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h new file mode 100644 index 0000000000..8b00552ff8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_4bpp.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 6COLOR: 600x448 7-color (black/white/green/blue/red/yellow/orange) e-paper, +// UC8159-family controller. +class EPaperInkplate6Color final : public EPaper4bpp { + public: + using EPaper4bpp::EPaper4bpp; + + protected: + uint8_t color_to_native(Color color) override; + + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index ee117304c4..fffb2b5e84 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) { } void EPaperMono::deep_sleep() { - ESP_LOGV(TAG, "Deep sleep"); - if (this->is_using_partial_update_()) { - this->cmd_data(0x10, {0x00}); // sleep in power on mode - } else { + // Deep sleep loses RAM so cannot be used with partial update + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x10, {0x03}); // deep sleep } } diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 1ef2dd12c3..f47d37d550 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -1,76 +1,23 @@ #include "epaper_spi_spectra_e6.h" - -#include +#include "colorconv.h" #include "esphome/core/log.h" namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_spi.6c"; -static constexpr unsigned char GRAY_THRESHOLD = 50; -enum E6Color { - BLACK, - WHITE, - YELLOW, - RED, - SKIP_1, - BLUE, - GREEN, - CYAN, - SKIP_2, +// Native hardware color codes for this panel's 4-bit color values. +enum E6Color : uint8_t { + BLACK = 0, + WHITE = 1, + YELLOW = 2, + RED = 3, + BLUE = 5, + GREEN = 6, }; -static uint8_t color_to_hex(Color color) { - // --- Step 1: Check for Grayscale (Black or White) --- - // We define "grayscale" as a color where the min and max components - // are close to each other. - unsigned char max_rgb = std::max({color.r, color.g, color.b}); - unsigned char min_rgb = std::min({color.r, color.g, color.b}); - - if ((max_rgb - min_rgb) < GRAY_THRESHOLD) { - // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return WHITE; - } - return BLACK; - } - // --- Step 2: Check for Primary/Secondary Colors --- - // If it's not gray, it's a color. We check which components are - // "on" (over 128) vs "off". This divides the RGB cube into 8 corners. - bool r_on = (color.r > 128); - bool g_on = (color.g > 128); - bool b_on = (color.b > 128); - - if (r_on && g_on && !b_on) { - return YELLOW; - } - if (r_on && !g_on && !b_on) { - return RED; - } - if (!r_on && g_on && !b_on) { - return GREEN; - } - if (!r_on && !g_on && b_on) { - return BLUE; - } - // Handle "impure" colors (Cyan, Magenta) - if (!r_on && g_on && b_on) { - // Cyan (G+B) -> Closest is Green or Blue. Pick Green. - return GREEN; - } - if (r_on && !g_on) { - // Magenta (R+B) -> Closest is Red or Blue. Pick Red. - return RED; - } - // Handle the remaining corners (White-ish, Black-ish) - if (r_on) { - // All high (but not gray) -> White - return WHITE; - } - // !r_on && !g_on && !b_on - // All low (but not gray) -> Black - return BLACK; +uint8_t EPaperSpectraE6::color_to_native(Color color) { + return color_to_bwyrgb(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE); } void EPaperSpectraE6::power_on() { @@ -92,71 +39,4 @@ void EPaperSpectraE6::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x07, {0xA5}); } - -void EPaperSpectraE6::fill(Color color) { - // If clipping is active, fall back to base implementation - if (this->get_clipping().is_set()) { - EPaperBase::fill(color); - return; - } - - auto pixel_color = color_to_hex(color); - - // We store 2 pixels per byte - this->buffer_.fill(pixel_color + (pixel_color << 4)); -} - -void EPaperSpectraE6::clear() { - // clear buffer to white, just like real paper. - this->fill(COLOR_ON); -} - -void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - auto pixel_bits = color_to_hex(color); - uint32_t pixel_position = x + y * this->get_width_internal(); - uint32_t byte_position = pixel_position / 2; - auto original = this->buffer_[byte_position]; - if ((pixel_position & 1) != 0) { - this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; - } else { - this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); - } -} - -bool HOT EPaperSpectraE6::transfer_data() { - const uint32_t start_time = App.get_loop_component_start_time(); - const size_t buffer_length = this->buffer_length_; - if (this->current_data_index_ == 0) { - this->command(0x10); - } - - size_t buf_idx = 0; - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - while (this->current_data_index_ != buffer_length) { - bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; - - if (buf_idx == sizeof bytes_to_send) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); - buf_idx = 0; - - if (millis() - start_time > MAX_TRANSFER_TIME) { - // Let the main loop run and come back next loop - return false; - } - } - } - // Finished the entire dataset - if (buf_idx != 0) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - } - this->current_data_index_ = 0; - return true; -} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 9c251068af..e1e3d3d763 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -1,28 +1,20 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_4bpp.h" namespace esphome::epaper_spi { -class EPaperSpectraE6 final : public EPaperBase { +class EPaperSpectraE6 final : public EPaper4bpp { public: - EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = width * height / 2; // 2 pixels per byte - } - - void fill(Color color) override; - void clear() override; + using EPaper4bpp::EPaper4bpp; protected: + uint8_t color_to_native(Color color) override; + void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - void draw_pixel_at(int x, int y, Color color) override; - - bool transfer_data() override; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index 5735333761..95d1fcb484 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -311,9 +311,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows) { + return false; + } ESP_LOGD(TAG, "CS phase done"); this->disable(); this->cs_pin_->digital_write(true); // deselect CS @@ -346,9 +349,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows * 2) { + return false; + } ESP_LOGD(TAG, "CS1 phase done"); this->disable(); this->cs1_pin_->digital_write(true); // deselect CS1 diff --git a/esphome/components/epaper_spi/models/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py new file mode 100644 index 0000000000..33fecab848 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate6color.py @@ -0,0 +1,57 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +from esphome.components.mipi import delay + +from . import EpaperModel + + +class Inkplate6ColorModel(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs): + super().__init__(name, class_name, **kwargs) + + # fmt: off + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x00, 0xEF, 0x08,), # panel setting + (0x01, 0x37, 0x00, 0x05, 0x05,), # power setting + (0x03, 0x00,), # power off sequence setting + (0x06, 0xC7, 0xC7, 0x1D,), # booster soft start + (0x41, 0x00,), # temperature sensor enable + (0x50, 0x37,), # VCOM and data interval + (0x60, 0x20,), # TCON setting + (0x61, width // 256, width % 256, height // 256, height % 256,), # resolution set + (0xE3, 0xAA,), # power saving + delay(100), + (0x50, 0x37,), # VCOM and data interval, resent once the power-saving setting settles + ) + + +# Native orientation is landscape (600x448). +inkplate6color = Inkplate6ColorModel( + "inkplate6color", + width=600, + height=448, + # Vendor library drives the panel at 2MHz; the controller doesn't reliably support faster rates. + data_rate="2MHz", + # Vendor library waits 200ms after releasing reset before talking to the panel. + reset_duration="200ms", + # A full 7-color refresh takes tens of seconds; disallow faster updates to avoid FSM update loops. + minimum_update_interval="30s", + # Panel's native buffer orientation is rotated 180 degrees relative to the logical + # rotation=0 orientation; confirmed on real hardware. + mirror_x=True, + mirror_y=True, + # Default GPIO pins for the on-board Inkplate 6COLOR wiring. + reset_pin=19, + dc_pin=33, + cs_pin=27, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 57837eb9c1..ada6d25db5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -119,6 +119,7 @@ CONF_SIGNING_SCHEME = "signing_scheme" CONF_SRAM1_AS_IRAM = "sram1_as_iram" CONF_SUBTYPE = "subtype" CONF_VERIFICATION_KEY = "verification_key" +CONF_VERIFICATION_KEYS = "verification_keys" ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" @@ -141,12 +142,22 @@ ASSERTION_LEVELS = { "SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT", } +SIGNING_SCHEME_RSA3072 = "rsa3072" +SIGNING_SCHEME_ECDSA256 = "ecdsa256" +SIGNING_SCHEME_ECDSA_V1 = "ecdsa_v1" + 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", + SIGNING_SCHEME_RSA3072: "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", + SIGNING_SCHEME_ECDSA256: "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + SIGNING_SCHEME_ECDSA_V1: "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } +# A Secure Boot v2 image carries at most three signature blocks, and hardware +# secure boot exposes three eFuse key slots. The trusted-key list isn't bound by +# the per-image limit (an incoming image need only match one trusted key), but +# cap it at three to mirror those hardware limits. +SIGNED_OTA_MAX_KEYS = 3 + # 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 V2 @@ -811,14 +822,15 @@ 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, 10), - "latest": cv.Version(3, 3, 10), - "dev": cv.Version(3, 3, 10), + "recommended": cv.Version(3, 3, 11), + "latest": cv.Version(3, 3, 11), + "dev": cv.Version(3, 3, 11), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 11): cv.Version(55, 3, 311), cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), @@ -842,6 +854,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), @@ -876,7 +889,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 39), + cv.Version(5, 5, 5): cv.Version(55, 3, 311), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), @@ -897,8 +910,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, 39), - "latest": cv.Version(55, 3, 39), + "recommended": cv.Version(55, 3, 311), + "latest": cv.Version(55, 3, 311), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } @@ -1162,11 +1175,99 @@ def _ota_downgrade_protection_errors( return errs +def _sbv2_rsa_key_digest(path: Path) -> bytes: + """SHA-256 of a public key's Secure Boot v2 signature-block key region. + + This hashes the 776-byte {n, e, rinv, m'} region exactly as the ROM lays it + out -- i.e. the value the device computes per signature block and the one + ``espsecure digest-sbv2-public-key`` prints, not a hash of the DER key. + """ + import hashlib + import struct + + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + load_pem_public_key, + ) + + data = path.read_bytes() + try: + if b"PUBLIC KEY" in data: + public_key = load_pem_public_key(data) + else: + # verification_keys only needs the public half; warn so the private + # key doesn't end up committed alongside the config. + _LOGGER.warning( + "'%s' is a private key, but '%s' needs only the public key. Use a " + "public-key PEM or the 64-hex digest (espsecure " + "digest-sbv2-public-key) so the private key stays out of your config.", + path, + CONF_VERIFICATION_KEYS, + ) + public_key = load_pem_private_key(data, password=None).public_key() + except (ValueError, TypeError, UnsupportedAlgorithm) as err: + raise cv.Invalid(f"Could not load key '{path}': {err}") from err + if not isinstance(public_key, rsa.RSAPublicKey) or public_key.key_size != 3072: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be RSA-3072 keys; " + f"'{path}' is not." + ) + numbers = public_key.public_numbers() + n, e = numbers.n, numbers.e + m = (-pow(n, -1, 1 << 32)) & 0xFFFFFFFF + rinv = (1 << (public_key.key_size * 2)) % n + blob = struct.pack( + "<384sI384sI", + n.to_bytes(384, "big")[::-1], + e, + rinv.to_bytes(384, "big")[::-1], + m, + ) + return hashlib.sha256(blob).digest() + + +def _validate_trusted_key(value: Any) -> str: + """Normalize a trusted key to its 64-hex-char signature-block digest. + + Accepts either the digest directly (so CI can inject it without shipping a + key file) or a PEM key file whose digest is computed here. Typed ``Any`` + because YAML hands validators the parsed value -- e.g. an unquoted ``0x...`` + digest arrives as an int, which the guard below rejects with advice to quote. + """ + # An unquoted 0x... or all-digit digest is parsed by YAML as an int before it + # reaches here, so it never looks like a string digest -- reject it clearly + # rather than letting it fall through to cv.file_ as a bogus path. + if not isinstance(value, str): + raise cv.Invalid( + f"Expected a key file path or a 64-character hex digest, got {value!r}. " + f"Quote the digest so YAML keeps it as text (an unquoted '0x...' or " + f"all-digit value is parsed as a number)." + ) + stripped = value.strip() + if re.fullmatch(r"[0-9A-Fa-f]{64}", stripped): + return stripped.lower() + # An all-hex value that isn't exactly 64 chars is a mangled digest, not a + # path: a truncated or 0x-prefixed CI variable would otherwise fall through + # and fail as "file not found", pointing at the wrong problem. + if re.fullmatch(r"(?:0x)?[0-9A-Fa-f]+", stripped): + raise cv.Invalid( + f"'{stripped}' looks like a key digest but must be exactly 64 hex " + f"characters (a SHA-256, no '0x' prefix); check for truncation." + ) + return _sbv2_rsa_key_digest(cv.file_(value)).hex() + + _SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( { cv.Optional(CONF_SIGNING_KEY): cv.file_, cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + cv.Optional(CONF_VERIFICATION_KEYS): cv.All( + cv.ensure_list(_validate_trusted_key), + cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS), + ), + cv.Optional(CONF_SIGNING_SCHEME, default=SIGNING_SCHEME_RSA3072): cv.one_of( *SIGNING_SCHEMES, lower=True ), } @@ -1199,9 +1300,15 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: block appended to each image, so verifying externally-signed binaries needs no key in the config at all -- omitting both keys selects that external-signing mode. + + For external RSA (rsa3072, no signing key), an optional 'verification_keys' + list names the keys the running app trusts. ESPHome then verifies OTA + signatures against that compiled-in set instead of IDF's single-block + check, which enables key rotation and multi-provider backup keys. """ has_signing_key = CONF_SIGNING_KEY in config has_verification_key = CONF_VERIFICATION_KEY in config + has_verification_keys = CONF_VERIFICATION_KEYS in config scheme = config[CONF_SIGNING_SCHEME] if has_signing_key and has_verification_key: raise cv.Invalid( @@ -1209,7 +1316,35 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: f"'{CONF_VERIFICATION_KEY}', not both.", path=[CONF_VERIFICATION_KEY], ) - if scheme == "ecdsa_v1": + if has_verification_keys: + if scheme != SIGNING_SCHEME_RSA3072: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme " + f"'rsa3072' (externally-signed RSA images). With '{scheme}' the " + f"public key travels in each image's signature block.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_signing_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' verifies externally-signed images " + f"and cannot be combined with '{CONF_SIGNING_KEY}' (which signs " + f"during the build). Provide one or the other.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_VERIFICATION_KEY}' and " + f"'{CONF_VERIFICATION_KEYS}', not both.", + path=[CONF_VERIFICATION_KEYS], + ) + keys = config[CONF_VERIFICATION_KEYS] + if len(set(keys)) != len(keys): + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be unique (duplicate " + f"keys add nothing and waste a trusted-set slot).", + path=[CONF_VERIFICATION_KEYS], + ) + if scheme == SIGNING_SCHEME_ECDSA_V1: if not has_signing_key and not has_verification_key: raise cv.Invalid( f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " @@ -1236,6 +1371,13 @@ def final_validate(config): from .gpio import final_validate_pins + # Remove before 2027.2.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP32 is deprecated and will be removed " + "in ESPHome 2027.2.0. Please use 'toolchain: esp-idf' instead." + ) + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -1358,7 +1500,10 @@ def final_validate(config): ] # V1 ECDSA is only available on the original ESP32 - if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: + if ( + scheme == SIGNING_SCHEME_ECDSA_V1 + and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS + ): errs.append( cv.Invalid( f"Signing scheme 'ecdsa_v1' is only supported on " @@ -1371,7 +1516,9 @@ def final_validate(config): # 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"): + if scheme == SIGNING_SCHEME_RSA3072 and ( + min_rev is None or min_rev < "3.0" + ): errs.append( cv.Invalid( f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " @@ -1382,7 +1529,7 @@ def final_validate(config): ) ) # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) - elif scheme == "ecdsa256": + elif scheme == SIGNING_SCHEME_ECDSA256: errs.append( cv.Invalid( f"Signing scheme 'ecdsa256' is not supported on " @@ -1392,7 +1539,11 @@ def final_validate(config): ) ) # 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": + elif ( + scheme == SIGNING_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 " @@ -1403,8 +1554,14 @@ def final_validate(config): 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"), + SIGNING_SCHEME_ECDSA256: ( + SIGNED_OTA_V2_RSA_ONLY_VARIANTS, + SIGNING_SCHEME_RSA3072, + ), + SIGNING_SCHEME_RSA3072: ( + SIGNED_OTA_V2_ECC_ONLY_VARIANTS, + SIGNING_SCHEME_ECDSA256, + ), } if ( conflict := scheme_variant_conflicts.get(scheme) @@ -2091,6 +2248,62 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_vfs_fatfs_sdkconfig( + disable_vfs_termios: bool, + disable_vfs_select: bool, + disable_vfs_dir: bool, + disable_fatfs: bool, +) -> None: + """Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win.""" + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off. + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios) + + # VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread); + # sockets use lwip_select() either way. ~2.7KB flash when off. + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_SELECT", True) + else: + set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select) + + # Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off. + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_DIR", True) + else: + set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir) + + # FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only; + # sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set + # any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack. + lfn_keys = ( + "CONFIG_FATFS_LFN_NONE", + "CONFIG_FATFS_LFN_HEAP", + "CONFIG_FATFS_LFN_STACK", + ) + user_picked_lfn = any(k in opts for k in lfn_keys) + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", False) + set_opt("CONFIG_FATFS_LFN_HEAP", True) + set_opt("CONFIG_FATFS_MAX_LFN", 255) + set_opt("CONFIG_FATFS_VOLUME_COUNT", 4) + elif disable_fatfs: + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", True) + # Kconfig range is [1,10]; 0 gets clamped to the default. + set_opt("CONFIG_FATFS_VOLUME_COUNT", 1) + + @coroutine_with_priority(CoroPriority.FINAL - 1) async def _finalize_arduino_aware_flags(): """Build flags that depend on whether arduino-esp32 is linked in. @@ -2181,6 +2394,8 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") + # NVS finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) @@ -2444,47 +2659,6 @@ async def to_code(config): if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) - # Disable VFS support for termios (terminal I/O functions) - # USB Serial JTAG VFS functions require termios support. - # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected - # as the logger output) call require_vfs_termios(). - # Saves approximately 1.8KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): - # Component requires VFS termios - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) - - # Disable VFS support for select() with file descriptors - # ESPHome only uses select() with sockets via lwip_select(), which still works. - # VFS select is only needed for UART/eventfd file descriptors. - # Components that need it (e.g., openthread) call require_vfs_select(). - # Saves approximately 2.7KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): - # Component requires VFS select - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] - ) - - # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) - # ESPHome doesn't use directory functions on ESP32. - # Components that need it (e.g., storage components) call require_vfs_dir(). - # Saves approximately 0.5KB+ of flash when disabled (default). - if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): - # Component requires VFS directory support - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] - ) - if use_platformio: cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: @@ -2545,9 +2719,70 @@ async def to_code(config): # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) - add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True) scheme = signed_ota[CONF_SIGNING_SCHEME] + # For externally-signed RSA images with a declared 'verification_keys' + # list, ESPHome verifies the OTA signature itself instead of using IDF's + # on-update check. IDF only matches the incoming image's first signature + # block against the running app's first, which blocks key rotation and + # multi-provider backup keys; ESPHome accepts an image signed by any key + # in the compiled-in trusted set. Without 'verification_keys' there is no + # trust anchor, so fall back to IDF's built-in check. + # The build still produces the padded unsigned image (via SECURE_ + # SIGNED_APPS_NO_SECURE_BOOT above); only the on-update check moves. + # SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT defaults to y under + # SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it must be set explicitly: + # False to hand verification to ESPHome, True to keep IDF's check. + # Setting it False also drives the hidden CONFIG_SECURE_SIGNED_APPS to + # n; the 4 KiB padding and reserved signature sector the verifier + # depends on survive only because --secure-pad-v2 keys off + # CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol. + external_rsa = ( + scheme == SIGNING_SCHEME_RSA3072 and CONF_SIGNING_KEY not in signed_ota + ) + verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS) + # verification_keys is accepted only for external RSA (rsa3072 with no + # signing_key), enforced in _validate_signed_ota_keys. Assert the + # post-condition so validator/codegen drift fails the build loudly + # instead of silently dropping the declared trust anchor and downgrading + # to IDF's single-block check. + assert not verification_keys or external_rsa + multi_key = external_rsa and verification_keys + # Turning IDF's on-update check off is global -- it also drops the + # signature check from esp_ota_set_boot_partition() on the partition-table + # path and safe_mode's recovery rollback. Both deliberately select an + # already-installed image (or an MD5-checked partition table), not a + # freshly-downloaded one, so ESPHome's verifier only needs to cover the + # app and bootloader OTA paths, where a new image is actually written. + add_idf_sdkconfig_option( + "CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", not multi_key + ) + if multi_key: + cg.add_define("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY") + # Compile the trusted key digests in as the immutable trust anchor. + # Each is the SHA-256 of a key's signature-block region; the verifier + # accepts an OTA whose signature block matches one of these. + digests = [bytes.fromhex(k) for k in verification_keys] + # Echo the resolved digests so a stale or mistyped key (which builds + # cleanly but leaves the device updatable only by serial reflash) is + # visible in the build log. + _LOGGER.info( + "Signed OTA verification trusts %d key digest(s): %s", + len(digests), + ", ".join(d.hex() for d in digests), + ) + cg.add_define("OTA_TRUSTED_KEY_COUNT", len(digests)) + cg.add_define( + "OTA_TRUSTED_KEY_DIGESTS", + cg.RawExpression( + "{" + + ",".join( + "{" + ",".join(f"0x{b:02x}" for b in d) + "}" for d in digests + ) + + "}" + ), + ) + for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) @@ -2658,6 +2893,16 @@ async def to_code(config): # FINAL priority: runs after every network/coexistence request_*() call CORE.add_job(_reconcile_network_sdkconfig) + # FINAL: require_*() calls can come from to_code at or below this priority, so an + # inline read would be iteration-order-dependent; reconcile once after every job ran. + CORE.add_job( + _reconcile_vfs_fatfs_sdkconfig, + advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS], + advanced[CONF_DISABLE_VFS_SUPPORT_SELECT], + advanced[CONF_DISABLE_VFS_SUPPORT_DIR], + advanced[CONF_DISABLE_FATFS], + ) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: @@ -2673,17 +2918,6 @@ async def to_code(config): ): 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): - # Component called require_fatfs() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) - elif advanced[CONF_DISABLE_FATFS]: - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - # Kconfig range is [1,10]; 0 gets clamped to the default. - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) @@ -3046,33 +3280,65 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): - # _decode_pc runs from the api log processor's asyncio callback, which - # only catches EsphomeError. Any other exception escaping here tears down - # the protocol and triggers an infinite reconnect/replay loop. Convert - # toolchain-resolution errors (e.g. missing build dir / cmake cache) into - # EsphomeError so the caller can disable decoding cleanly. + # Convert toolchain-resolution errors (e.g. missing build dir / cmake + # cache) into EsphomeError. The api log processor stops decoding on any + # exception, so this is about the message it reports rather than about + # catching it at all: EsphomeError carries an explanation worth showing + # the user, where a raw OSError repr does not. if CORE.using_toolchain_esp_idf: from esphome.espidf import toolchain as idf_toolchain try: addr2line_path = idf_toolchain.get_addr2line_path() firmware_elf_path = idf_toolchain.get_elf_path() - except RuntimeError as err: + except (RuntimeError, OSError) as err: + # OSError covers a missing build directory or a cmake that isn't + # on PATH; both surface from the subprocess call, not as RuntimeError. raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + if not firmware_elf_path.is_file(): + raise EsphomeError(f"Firmware ELF not found: {firmware_elf_path}") else: from esphome.platformio import toolchain @@ -3103,9 +3369,10 @@ def _parse_register(config, regex, line): STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*") -STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_C3_MTVAL_RE = re.compile(r".*MTVAL\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -3123,9 +3390,10 @@ def process_stacktrace(config, line, backtrace_state): # ESP32 PC/EXCVADDR _parse_register(config, STACKTRACE_ESP32_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line) - # ESP32-C3 PC/RA + # ESP32-C3 PC/RA/MTVAL _parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line) + _parse_register(config, STACKTRACE_ESP32_C3_MTVAL_RE, line) # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 83fcfd233e..09f458c64b 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -1,9 +1,22 @@ import esphome.codegen as cg -KEY_ESP32 = "esp32" +# Re-exported for the many esp32-side users; defined in esphome.const +# and esphome.espidf so the upload/logs fast path can use them without +# importing this package. +from esphome.const import ( # noqa: F401 # pylint: disable=unused-import + KEY_ESP32, + KEY_FLASH_SIZE, + KEY_IDF_VERSION, + KEY_VARIANT, +) + +# Back compat for external components only; in-tree callers import it +# from esphome.espidf directly. +from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import + variant_to_idf_target, +) + KEY_BOARD = "board" -KEY_FLASH_SIZE = "flash_size" -KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" KEY_EXCLUDE_COMPONENTS = "exclude_components" @@ -15,7 +28,6 @@ KEY_PATH = "path" KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" -KEY_IDF_VERSION = "idf_version" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" @@ -63,4 +75,5 @@ VARIANT_FRIENDLY = { VARIANT_ESP32S31: "ESP32-S31", } + esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index a7de48a6ee..1b054dcc49 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "crash_handler.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include @@ -122,7 +123,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 2; +static constexpr uint32_t CRASH_DATA_VERSION = 4; struct RawCrashData { uint32_t version; uint32_t magic; @@ -132,7 +133,9 @@ struct RawCrashData { uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) uint32_t backtrace[MAX_BACKTRACE]; - uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V) + uint32_t build_time; // ESPHOME_BUILD_TIME of the firmware that captured this record uint8_t crashed_core; #if SOC_CPU_CORES_NUM > 1 static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); @@ -151,6 +154,16 @@ namespace esphome::esp32 { static const char *const TAG = "esp32.crash"; +// RAM copy of the build timestamp. The generated constant lives in flash, +// which the panic handler must not read (cache may be disabled during +// cache-error panics), so the wrapper stamps the record from this mirror +// instead. Filled during C++ dynamic initialization, well before arch_init(); +// ESPHOME_BUILD_TIME itself is constant-initialized, so the read is ordered. +// Unqualified name on purpose: the runtime header declares it in namespace +// esphome, while the static-analysis stub defines it as a macro. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); + void crash_handler_read_and_clear() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; @@ -240,6 +253,16 @@ static const char *get_exception_reason() { nullptr, "LoadProhibited", "StoreProhibited", + nullptr, + nullptr, + "Cp0Dis", + "Cp1Dis", + "Cp2Dis", + "Cp3Dis", + "Cp4Dis", + "Cp5Dis", + "Cp6Dis", + "Cp7Dis", }; uint32_t cause = s_raw_crash_data.cause; if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) @@ -320,6 +343,66 @@ static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *ad return pos; } +// Register holding the faulting memory address, named as in ESP-IDF's live +// register dump. The lowercase form is for old-build reports, where the +// stacktrace decoders must not match the line. +#if CONFIG_IDF_TARGET_ARCH_XTENSA +static const char *const FAULT_ADDR_REG = "EXCVADDR"; +static const char *const FAULT_ADDR_REG_LOWER = "excvaddr"; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +static const char *const FAULT_ADDR_REG = "MTVAL"; +static const char *const FAULT_ADDR_REG_LOWER = "mtval"; +#endif + +// Whether the fault address is meaningful — real CPU faults only, not +// aborts/watchdogs or SoC-level pseudo exceptions. +static bool has_fault_addr() { + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; +} + +// Append both cores' backtrace addresses to buf; returns the new position. +static int append_all_backtraces(char *buf, int size, int pos) { + pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); +#if SOC_CPU_CORES_NUM > 1 + pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count); +#endif + return pos; +} + +// The record was captured by a different firmware build (it survives soft +// resets, including the OTA reboot), so symbolizing its addresses against the +// current ELF would produce misleading symbols. Print them with lowercase +// labels the stacktrace decoders deliberately do not match, and skip the +// addr2line hint. One line per address so nothing is lost to a shared buffer. +// No is_return_addr() filtering here: it would inspect the current build's +// code bytes, which say nothing about addresses captured by the old build. +static uint8_t log_foreign_backtrace(const uint32_t *addrs, uint8_t count, uint8_t bt_num) { + for (uint8_t i = 0; i < count; i++) { + ESP_LOGE(TAG, " bt%d: 0x%08" PRIX32, bt_num++, addrs[i]); + } + return bt_num; +} + +static void log_foreign_addresses() { + ESP_LOGE(TAG, " Captured by a different firmware build; addresses belong to that build's ELF"); + ESP_LOGE(TAG, " pc: 0x%08" PRIX32, s_raw_crash_data.pc); + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32, FAULT_ADDR_REG_LOWER, s_raw_crash_data.fault_addr); + } + uint8_t bt_num = log_foreign_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, 0); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + // Lowercase like the address labels: carries no address, matches no decoder. + ESP_LOGE(TAG, " other core (%d):", 1 - s_raw_crash_data.crashed_core); + log_foreign_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, bt_num); + } +#else + (void) bt_num; // Single-core targets have no second list to continue numbering into. +#endif +} + // Intentionally uses separate ESP_LOGE calls per line instead of combining into // one multi-line log message. This ensures each address appears as its own line // on the serial console, making it possible to see partial output if the device @@ -332,12 +415,23 @@ void crash_handler_log() { ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); const char *reason = get_exception_reason(); if (reason != nullptr) { - ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + ESP_LOGE(TAG, " Reason: %s - %s (cause %" PRIu32 ")", get_exception_type(), reason, s_raw_crash_data.cause); } else { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); + if (s_raw_crash_data.build_time != s_current_build_time) { + // Captured by a different firmware build: the record survives soft resets + // including the OTA reboot, so its addresses belong to a previous ELF. + log_foreign_addresses(); + return; + } ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + // Uses the same register name as ESP-IDF's live register dump so the CLI + // decodes the address when it happens to be a code address. + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32 " (faulting address)", FAULT_ADDR_REG, s_raw_crash_data.fault_addr); + } log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); #if SOC_CPU_CORES_NUM > 1 @@ -352,14 +446,7 @@ void crash_handler_log() { // Build addr2line hint with all captured addresses for easy copy-paste char hint[256]; int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, - s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); -#else - (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. -#endif + append_all_backtraces(hint, sizeof(hint), pos); ESP_LOGE(TAG, "%s", hint); } @@ -382,6 +469,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot + s_raw_crash_data.cause = 0; + s_raw_crash_data.fault_addr = 0; + // Record which build's ELF the captured addresses belong to (RAM read, panic-safe). + // Still 0 if the panic precedes C++ dynamic initialization, so such a crash + // reports as a foreign build — conservative: addresses are shown raw instead + // of decoded. + s_raw_crash_data.build_time = esphome::esp32::s_current_build_time; #if SOC_CPU_CORES_NUM > 1 s_raw_crash_data.other_backtrace_count = 0; s_raw_crash_data.other_reg_frame_count = 0; @@ -392,6 +487,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -414,6 +510,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc7..c2ff6cf34d 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); } bool has_custom_mac_address() { #if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC) - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; // do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails #ifndef USE_ESP32_VARIANT_ESP32 return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) && diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index dc2b40455c..f3d5844cd7 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -176,12 +176,21 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty } s_open_err = ESP_OK; } - auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->nvs_handle = this->nvs_handle; - pref->key = type; - pref->in_flash = true; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new ESP32PreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +ESP32PreferenceBackend ESP32Preferences::make_backend_(uint32_t type) const { + // in_flash keeps its default of true, selecting the NVS path + ESP32PreferenceBackend backend; + backend.nvs_handle = this->nvs_handle; + backend.key = type; + return backend; +} + +bool ESP32Preferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + ESP32PreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); } #ifdef USE_ESP32_RTC_PREFERENCES_STORAGE diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 864d22312b..9125843958 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -23,12 +23,15 @@ class ESP32Preferences final : public PreferencesMixin { ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); uint32_t nvs_handle; protected: + ESP32PreferenceBackend make_backend_(uint32_t type) const; bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); #ifdef USE_ESP32_RTC_PREFERENCES_STORAGE diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2df2c3f90d..935d8b1b7e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,12 +1,19 @@ from collections.abc import Callable, MutableMapping -from dataclasses import dataclass from enum import Enum import logging -import re from typing import Any from esphome import automation import esphome.codegen as cg + +# bt_uuid validation lives in the platform-neutral ble_device_base; re-exported +# here for backward compatibility. +from esphome.components.ble_device_base import ( # noqa: F401 # pylint: disable=unused-import + BT_UUID16_FORMAT as bt_uuid16_format, + BT_UUID32_FORMAT as bt_uuid32_format, + BT_UUID128_FORMAT as bt_uuid128_format, + bt_uuid, +) from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, @@ -24,10 +31,11 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType +AUTO_LOAD = ["ble_device_base"] # ble_uuid.h builds on the neutral ESPBTUUID DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] DOMAIN = "esp32_ble" @@ -125,18 +133,21 @@ def _get_required_loggers() -> set[BTLoggers]: return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set()) -# Dataclass for handler registration counts -@dataclass -class HandlerCounts: - gap_event: int = 0 - gap_scan_event: int = 0 - gattc_event: int = 0 - gatts_event: int = 0 - ble_status_event: int = 0 - - -# Track handler registration counts for StaticVector sizing -_handler_counts = HandlerCounts() +# Handler slot counters sizing the StaticCallbackManager storage in ble.h; +# one request per register_* call below. +_request_gap_event_slot = cg.slot_counter("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") +_request_gap_scan_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT" +) +_request_gattc_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT" +) +_request_gatts_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT" +) +_request_ble_status_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT" +) def _add_callback( @@ -162,8 +173,8 @@ def _add_callback( def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: - """Register a GAP event handler and track the count.""" - _handler_counts.gap_event += 1 + """Register a GAP event handler and request a handler slot.""" + _request_gap_event_slot() _add_callback( parent_var, "add_gap_event_callback", @@ -176,8 +187,8 @@ def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) def register_gap_scan_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GAP scan event handler and track the count.""" - _handler_counts.gap_scan_event += 1 + """Register a GAP scan event handler and request a handler slot.""" + _request_gap_scan_event_slot() _add_callback( parent_var, "add_gap_scan_event_callback", @@ -190,8 +201,8 @@ def register_gap_scan_event_handler( def register_gattc_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTc event handler and track the count.""" - _handler_counts.gattc_event += 1 + """Register a GATTc event handler and request a handler slot.""" + _request_gattc_event_slot() _add_callback( parent_var, "add_gattc_event_callback", @@ -204,8 +215,8 @@ def register_gattc_event_handler( def register_gatts_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTs event handler and track the count.""" - _handler_counts.gatts_event += 1 + """Register a GATTs event handler and request a handler slot.""" + _request_gatts_event_slot() _add_callback( parent_var, "add_gatts_event_callback", @@ -218,8 +229,8 @@ def register_gatts_event_handler( def register_ble_status_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a BLE status event handler and track the count.""" - _handler_counts.ble_status_event += 1 + """Register a BLE status event handler and request a handler slot.""" + _request_ble_status_event_slot() _add_callback( parent_var, "add_ble_status_event_callback", @@ -372,43 +383,6 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -bt_uuid16_format = "XXXX" -bt_uuid32_format = "XXXXXXXX" -bt_uuid128_format = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" - - -def bt_uuid(value): - in_value = cv.string_strict(value) - value = in_value.upper() - - if len(value) == len(bt_uuid16_format): - pattern = re.compile("^[A-F0-9]{4,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid32_format): - pattern = re.compile("^[A-F0-9]{8,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid128_format): - pattern = re.compile( - "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" - ) - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 128 UUID format: '{in_value}'" - ) - return value - raise cv.Invalid( - f"Bluetooth UUID must be in 16 bit '{bt_uuid16_format}', 32 bit '{bt_uuid32_format}', or 128 bit '{bt_uuid128_format}' format" - ) - - def validate_variant(_): variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: @@ -546,36 +520,6 @@ def final_validation(config): FINAL_VALIDATE_SCHEMA = final_validation -# This needs to be run as a job with CoroPriority.FINAL priority so that all components have -# a chance to register their handlers before the counts are added to defines. -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_handler_defines(): - # Add defines for StaticVector sizing based on handler registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - if _handler_counts.gap_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT", _handler_counts.gap_event - ) - if _handler_counts.gap_scan_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT", - _handler_counts.gap_scan_event, - ) - if _handler_counts.gattc_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT", _handler_counts.gattc_event - ) - if _handler_counts.gatts_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT", _handler_counts.gatts_event - ) - if _handler_counts.ble_status_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT", - _handler_counts.ble_status_event, - ) - - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) @@ -661,9 +605,6 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") cg.add_define("USE_ESP32_BLE_UUID") - # Schedule the handler defines to be added after all components register - CORE.add_job(_add_ble_handler_defines) - @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) async def ble_enabled_to_code(config, condition_id, template_arg, args): diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fb75e8837f..16501ef3b2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,11 +674,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + // The running stack owns the address (on hosted controllers it lives in + // the remote chip's efuse); null before init becomes all-zero. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, MAC_ADDRESS_SIZE); + } else { + memset(out, 0, MAC_ADDRESS_SIZE); + } +} + float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - const uint8_t *mac_address = esp_bt_dev_get_address(); - if (mac_address) { + uint8_t mac_address[MAC_ADDRESS_SIZE]; + this->get_mac_msb_first(mac_address); + if (mac_address_is_valid(mac_address)) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: @@ -701,7 +713,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index c85ddfc983..2a355a6c8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -108,6 +108,8 @@ class ESP32BLE final : public Component { void setup() override; void loop() override; void dump_config() override; + /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 3cfa6f548a..6c8a97f453 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "ble_uuid.h" #include #include @@ -15,8 +16,6 @@ namespace esphome::esp32_ble { -class ESPBTUUID; - class BLEAdvertising { public: BLEAdvertising(uint32_t advertising_cycle_time); diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp deleted file mode 100644 index 886f8237ad..0000000000 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ /dev/null @@ -1,193 +0,0 @@ -#include "ble_uuid.h" - -#ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_UUID - -#include -#include -#include -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" - -namespace esphome::esp32_ble { - -static const char *const TAG = "esp32_ble"; - -ESPBTUUID::ESPBTUUID() : uuid_() {} -ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, data, ESP_UUID_LEN_128); - return ret; -} -ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - for (uint8_t i = 0; i < ESP_UUID_LEN_128; i++) - ret.uuid_.uuid.uuid128[ESP_UUID_LEN_128 - 1 - i] = data[i]; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { - ESPBTUUID ret; - if (length == 4) { - // 16-bit UUID as 4-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = parsed.value(); - } - } else if (length == 8) { - // 32-bit UUID as 8-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = parsed.value(); - } - } else if (length == 16) { // how we can have 16 byte length string reprezenting 128 bit uuid??? needs to be - // investigated (lack of time) - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, reinterpret_cast(data), 16); - } else if (length == 36) { - // If the length of the string is 36 bytes then we will assume it is a long hex string in - // UUID format. - ret.uuid_.len = ESP_UUID_LEN_128; - int n = 0; - for (size_t i = 0; i < length; i += 2) { - if (data[i] == '-') - i++; - uint8_t msb = data[i]; - uint8_t lsb = data[i + 1]; - - if (msb > '9') - msb -= 7; - if (lsb > '9') - lsb -= 7; - ret.uuid_.uuid.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); - } - } else { - ESP_LOGE(TAG, "ERROR: UUID value not 2, 4, 16 or 36 bytes - %s", data); - } - return ret; -} -ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = uuid.len; - if (uuid.len == ESP_UUID_LEN_16) { - ret.uuid_.uuid.uuid16 = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - ret.uuid_.uuid.uuid32 = uuid.uuid.uuid32; - } else if (uuid.len == ESP_UUID_LEN_128) { - memcpy(ret.uuid_.uuid.uuid128, uuid.uuid.uuid128, ESP_UUID_LEN_128); - } - return ret; -} -ESPBTUUID ESPBTUUID::as_128bit() const { - if (this->uuid_.len == ESP_UUID_LEN_128) { - return *this; - } - uint8_t data[] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - uint32_t uuid32; - if (this->uuid_.len == ESP_UUID_LEN_32) { - uuid32 = this->uuid_.uuid.uuid32; - } else { - uuid32 = this->uuid_.uuid.uuid16; - } - for (uint16_t i = 0; i < this->uuid_.len; i++) { - data[12 + i] = ((uuid32 >> i * 8) & 0xFF); - } - return ESPBTUUID::from_raw(data); -} -bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { - if (this->uuid_.len == ESP_UUID_LEN_16) { - return (this->uuid_.uuid.uuid16 >> 8) == data2 && (this->uuid_.uuid.uuid16 & 0xFF) == data1; - } else if (this->uuid_.len == ESP_UUID_LEN_32) { - for (uint8_t i = 0; i < 3; i++) { - bool a = ((this->uuid_.uuid.uuid32 >> i * 8) & 0xFF) == data1; - bool b = ((this->uuid_.uuid.uuid32 >> (i + 1) * 8) & 0xFF) == data2; - if (a && b) - return true; - } - } else { - for (uint8_t i = 0; i < 15; i++) { - if (this->uuid_.uuid.uuid128[i] == data1 && this->uuid_.uuid.uuid128[i + 1] == data2) - return true; - } - } - return false; -} -bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { - if (this->uuid_.len == uuid.uuid_.len) { - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - return this->uuid_.uuid.uuid16 == uuid.uuid_.uuid.uuid16; - case ESP_UUID_LEN_32: - return this->uuid_.uuid.uuid32 == uuid.uuid_.uuid.uuid32; - case ESP_UUID_LEN_128: - return memcmp(this->uuid_.uuid.uuid128, uuid.uuid_.uuid.uuid128, ESP_UUID_LEN_128) == 0; - default: - return false; - } - } - return this->as_128bit() == uuid.as_128bit(); -} -esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } -const char *ESPBTUUID::to_str(std::span output) const { - char *pos = output.data(); - - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - *pos++ = '0'; - *pos++ = 'x'; - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); - *pos = '\0'; - return output.data(); - - case ESP_UUID_LEN_32: - *pos++ = '0'; - *pos++ = 'x'; - for (int shift = 28; shift >= 0; shift -= 4) { - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); - } - *pos = '\0'; - return output.data(); - - default: - case ESP_UUID_LEN_128: - // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - for (int8_t i = 15; i >= 0; i--) { - uint8_t byte = this->uuid_.uuid.uuid128[i]; - *pos++ = format_hex_pretty_char(byte >> 4); - *pos++ = format_hex_pretty_char(byte & 0x0F); - if (i == 12 || i == 10 || i == 8 || i == 6) { - *pos++ = '-'; - } - } - *pos = '\0'; - return output.data(); - } -} -std::string ESPBTUUID::to_string() const { - char buf[UUID_STR_LEN]; - this->to_str(buf); - return std::string(buf); -} - -} // namespace esphome::esp32_ble - -#endif // USE_ESP32_BLE_UUID -#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 503fde6945..fd8da4baee 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -1,59 +1,21 @@ #pragma once #include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_UUID -#include -#include -#include -#include +// The BLE UUID type is owned by the platform-neutral ble_device_base layer; +// this header re-exports it under the historical esp32_ble name (esp32 only). +// The full historical API surface — including from_uuid()/get_uuid() with the +// ESP-IDF esp_bt_uuid_t type — is preserved on esp32 builds. + +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::esp32_ble { -/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" -static constexpr size_t UUID_STR_LEN = 37; - -class ESPBTUUID { - public: - ESPBTUUID(); - - static ESPBTUUID from_uint16(uint16_t uuid); - - static ESPBTUUID from_uint32(uint32_t uuid); - - static ESPBTUUID from_raw(const uint8_t *data); - static ESPBTUUID from_raw_reversed(const uint8_t *data); - - static ESPBTUUID from_raw(const char *data, size_t length); - static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } - static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } - static ESPBTUUID from_raw(std::initializer_list data) { - return from_raw(reinterpret_cast(data.begin()), data.size()); - } - - static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); - - ESPBTUUID as_128bit() const; - - bool contains(uint8_t data1, uint8_t data2) const; - - bool operator==(const ESPBTUUID &uuid) const; - bool operator!=(const ESPBTUUID &uuid) const { return !(*this == uuid); } - - esp_bt_uuid_t get_uuid() const; - - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; // NOLINT - const char *to_str(std::span output) const; - - protected: - esp_bt_uuid_t uuid_; -}; +using ble_device_base::UUID_STR_LEN; +using ESPBTUUID = ble_device_base::ESPBTUUID; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 3fb9632e9a..e6cdde9cda 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Intermediate connection parameters for standard operation -// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, -// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms -static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms -// The timeout value was increased from 6s to 8s to address stability issues observed -// in certain BLE devices when operating through WiFi-based BLE proxies. The longer -// timeout reduces the likelihood of disconnections during periods of high latency. -static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s - -// Fastest connection parameters for devices with short discovery timeouts -static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) -static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms -static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +// Connection parameters are shared with the other GATT client backends +// (ble_device_base/ble_client_state.h) so the platforms cannot drift. +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, @@ -125,6 +119,9 @@ void BLEClientBase::connect() { } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; + // A registration whose event never arrived must not block this connection's release. + this->services_released_ = false; + this->pending_notify_regs_ = 0; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -200,10 +197,26 @@ void BLEClientBase::release_services() { this->services_.clear(); #endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // Only the cache clean makes the stack's database unsafe to walk. + this->services_released_ = true; esp_ble_gattc_cache_clean(this->remote_bda_); #endif } +esp_err_t BLEClientBase::register_for_notify(uint16_t char_handle) { + esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, char_handle); + if (err != ESP_OK) + return err; + if (this->pending_notify_regs_ == UINT8_MAX) { + // Saturating undercounts, so the release can run before the last registration completes. + // Wrapping to zero would undercount by the full range instead, which is worse. + this->log_warning_("Too many outstanding notify registrations to track"); + return err; + } + this->pending_notify_regs_++; + return err; +} + void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name); } @@ -498,12 +511,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->log_gattc_data_event_("REG_FOR_NOTIFY"); + // The event carries no conn_id, so this is the only place the request can be retired. + if (this->pending_notify_regs_ > 0) + this->pending_notify_regs_--; if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // Client is responsible for flipping the descriptor value // when using the cache break; } + if (this->services_released_) { + // The lookup below walks the freed GATT cache, and Bluedroid asserts on it rather than erroring. + this->log_warning_("REG_FOR_NOTIFY after services released, notifications not enabled"); + break; + } esp_gattc_descr_elem_t desc_result; uint16_t count = 1; esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle( diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0291a4b993..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -44,6 +44,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void unconditional_disconnect(); void release_services(); + /// Register for notifications, holding the service release until the registration completes. + esp_err_t register_for_notify(uint16_t char_handle); + + /// True while a register_for_notify() request has not completed. + bool notify_registration_pending() const { return this->pending_notify_regs_ > 0; } + bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; } void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } @@ -86,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } @@ -125,9 +133,15 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { espbt::ConnectionType connection_type_{espbt::ConnectionType::V1}; uint8_t connection_index_; uint8_t service_count_{0}; // ESP32 has max handles < 255, typical devices have < 50 services + // Outstanding register_for_notify() requests + // A count, not per-request state, so a raw esp_ble_gattc_register_for_notify() on the same client can retire one + // services_released_ is the backstop if that ever lets the release run early + uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // 6 bytes used, 2 bytes padding + // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + bool services_released_{false}; + // 8 bytes used, no padding void log_event_(const char *name); void log_gattc_lifecycle_event_(const char *name); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 5ca80d6a7a..3dcac3691c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -77,6 +77,10 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ case ESP_GATTS_WRITE_EVT: { if (this->handle_ != param->write.handle) break; + if (param->write.len > this->value_.attr_max_len) { + ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", param->write.len, this->value_.attr_max_len); + break; + } this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); if (this->on_write_callback_) { diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e4139bed65..634b8c3bef 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,11 +1,11 @@ from __future__ import annotations -from dataclasses import dataclass import logging from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble, ota +from esphome.components import ble_device_base, esp32_ble, ota +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, request_bluetooth, @@ -39,14 +39,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble"] +AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] +ble_device_base.register_hub_provider("esp32_ble_tracker") + CONF_ESP32_BLE_ID = "esp32_ble_id" -CONF_SCAN_PARAMETERS = "scan_parameters" -CONF_WINDOW = "window" -CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" _LOGGER = logging.getLogger(__name__) @@ -57,16 +56,8 @@ class BLEFeatures(StrEnum): ESP_BT_DEVICE = "ESP_BT_DEVICE" -# Dataclass for registration counts -@dataclass -class RegistrationCounts: - listeners: int = 0 - clients: int = 0 - - -# CORE.data keys for state management +# CORE.data key for state management ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features" -ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts" def _get_required_features() -> set[BLEFeatures]: @@ -74,11 +65,10 @@ def _get_required_features() -> set[BLEFeatures]: return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set()) -def _get_registration_counts() -> RegistrationCounts: - """Get the registration counts from CORE.data.""" - return CORE.data.setdefault( - ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts() - ) +# Slot counters sizing the tracker's StaticVector storage; one request per +# registered listener or client. +_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") +_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") def register_ble_features(features: set[BLEFeatures]) -> None: @@ -93,6 +83,7 @@ def register_ble_features(features: set[BLEFeatures]) -> None: esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") ESP32BLETracker = esp32_ble_tracker_ns.class_( "ESP32BLETracker", + ble_device_base.BLEHub, cg.Component, cg.Parented.template(esp32_ble.ESP32BLE), ) @@ -125,25 +116,6 @@ ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( ) -def validate_scan_parameters(config): - duration = config[CONF_DURATION] - interval = config[CONF_INTERVAL] - window = config[CONF_WINDOW] - - if window > interval: - raise cv.Invalid( - f"Scan window ({window}) needs to be smaller than scan interval ({interval})" - ) - - if interval.total_milliseconds * 3 > duration.total_milliseconds: - raise cv.Invalid( - "Scan duration needs to be at least three times the scan interval to" - "cover all BLE channels." - ) - - return config - - def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: if CONF_MAX_CONNECTIONS in config: _LOGGER.warning( @@ -153,26 +125,16 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config -def as_hex(value): - return cg.RawExpression(f"0x{value}ULL") +# 320 ms is the ESP-IDF reference scan interval; the shared schema also +# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects +# window/interval pairs that collapse to the same 0.625 ms unit count. +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") - -def as_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") - - -def as_reversed_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression( - f"(uint8_t*)(const uint8_t[16]){{{','.join(reversed(cpp_array))}}}" - ) +# Codegen helpers are owned by ble_device_base; kept under the historical names +# here for the components that import them from this module. +as_hex = ble_device_base.as_hex +as_hex_array = ble_device_base.as_hex_array +as_reversed_hex_array = ble_device_base.as_reversed_hex_array CONFIG_SCHEMA = cv.All( @@ -183,24 +145,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS) ), - cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All( - cv.Schema( - { - cv.Optional( - CONF_DURATION, default="5min" - ): cv.positive_time_period_seconds, - cv.Optional( - CONF_INTERVAL, default="320ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_WINDOW, default="30ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, - } - ), - validate_scan_parameters, - ), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -254,6 +199,13 @@ async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) + # Behavior parity with the pre-split tracker: IRK resolution is always + # available on esp32 (sensors with irk: worked without opting in). + ble_device_base.request_irk_support() + + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_ESP32_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -266,8 +218,8 @@ async def to_code(config): params = config[CONF_SCAN_PARAMETERS] cg.add(var.set_scan_duration(params[CONF_DURATION])) - cg.add(var.set_scan_interval(int(params[CONF_INTERVAL].total_milliseconds / 0.625))) - cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625))) + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW]))) cg.add(var.set_scan_active(params[CONF_ACTIVE])) cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) @@ -279,17 +231,15 @@ async def to_code(config): ): register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - registration_counts = _get_registration_counts() - for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format): cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID]))) @@ -302,7 +252,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format): cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID]))) @@ -315,7 +265,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_SCAN_END, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -346,22 +296,16 @@ async def to_code(config): async def _add_ble_features(): # Add feature-specific defines based on what's needed required_features = _get_required_features() + # Sensors registered through the neutral ble_device_base path (BLEHub) need + # the parsed-device pipeline compiled in, exactly like esp32-path listeners. + if cg.get_slot_count(ble_device_base.LISTENER_COUNT_DEFINE): + # The neutral (BLEHub) listener count define itself is emitted by + # ble_device_base's own job; only the feature coupling lives here. + required_features.add(BLEFeatures.ESP_BT_DEVICE) if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") - # Add defines for StaticVector sizing based on registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - registration_counts = _get_registration_counts() - if registration_counts.listeners > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners - ) - if registration_counts.clients > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients - ) - ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( { @@ -414,7 +358,7 @@ async def register_ble_device( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -422,26 +366,12 @@ async def register_ble_device( async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var -async def register_raw_ble_device( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a BLE device listener that only needs raw advertisement data. - - This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice - will not be compiled in if this is the only registration method used. - """ - _get_registration_counts().listeners += 1 - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.register_listener(var)) - return var - - async def register_raw_client( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: @@ -450,7 +380,7 @@ async def register_raw_client( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index b653325f56..541b63b2fd 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -3,6 +3,8 @@ #include "esphome/core/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include + #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index f57cb7f5dc..798fd6e0ca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -27,15 +27,6 @@ #include #endif -#ifdef USE_ESP32_BLE_DEVICE -#ifdef USE_BLE_TRACKER_PSA_AES -#include -#else -#define MBEDTLS_AES_ALT -#include -#endif -#endif // USE_ESP32_BLE_DEVICE - // bt_trace.h #undef TAG @@ -43,32 +34,8 @@ namespace esphome::esp32_ble_tracker { static const char *const TAG = "esp32_ble_tracker"; -// BLE advertisement max: 31 bytes adv data + 31 bytes scan response -static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; - ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -const char *client_state_to_string(ClientState state) { - switch (state) { - case ClientState::INIT: - return "INIT"; - case ClientState::DISCONNECTING: - return "DISCONNECTING"; - case ClientState::IDLE: - return "IDLE"; - case ClientState::DISCOVERED: - return "DISCOVERED"; - case ClientState::CONNECTING: - return "CONNECTING"; - case ClientState::CONNECTED: - return "CONNECTED"; - case ClientState::ESTABLISHED: - return "ESTABLISHED"; - default: - return "UNKNOWN"; - } -} - float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { @@ -88,6 +55,7 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + ESP_LOGD(TAG, "Stopping scan for OTA"); this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT @@ -223,7 +191,9 @@ void ESP32BLETracker::loop() { void ESP32BLETracker::start_scan() { this->start_scan_(true); } void ESP32BLETracker::stop_scan() { - ESP_LOGD(TAG, "Stopping scan."); + // V to match the start log: the mode-switch and OTA callers narrate their + // reason at D themselves, and the user-facing stop action is deliberate. + ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; this->stop_scan_(); } @@ -232,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - // If scanner is already idle, there's nothing to stop - this is not an error - if (this->scanner_state_ != ScannerState::IDLE) { + // IDLE means there is nothing to stop; STOPPING means a stop is already in + // flight and will finish on its own. Neither is an error. + if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } return; @@ -263,10 +234,14 @@ void ESP32BLETracker::start_scan_(bool first) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_scan_end(); +#endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) + listener->on_scan_end(); #endif } #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE; this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; @@ -300,7 +275,17 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_advertisement_parser_types(); + // Registration is add-only, so the flag is a monotonic OR. + if (client->wants_parsed_advertisements()) + this->parse_advertisements_ = true; +#endif +} + +void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Neutral BLEHub path (migrated sensors): parsed-advertisement consumers only. + this->neutral_listeners_.push_back(listener); + this->parse_advertisements_ = true; #endif } @@ -308,30 +293,7 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_advertisement_parser_types(); -#endif -} - -void ESP32BLETracker::recalculate_advertisement_parser_types() { - this->raw_advertisements_ = false; - this->parse_advertisements_ = false; -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } + this->parse_advertisements_ = true; #endif } @@ -429,275 +391,13 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; - for (auto *listener : this->scanner_state_listeners_) { - listener->on_scanner_state(state); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(state); } -} - -#ifdef USE_ESP32_BLE_DEVICE -ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); } -optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { - if (!data.uuid.contains(0x4C, 0x00)) - return {}; - - if (data.data.size() != 23) - return {}; - return ESPBLEiBeacon(data.data.data()); -} - -void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { - this->scan_result_ = &scan_result; - for (uint8_t i = 0; i < ESP_BD_ADDR_LEN; i++) - this->address_[i] = scan_result.bda[i]; - this->address_type_ = static_cast(scan_result.ble_addr_type); - this->rssi_ = scan_result.rssi; - - // Parse advertisement data directly - uint8_t total_len = scan_result.adv_data_len + scan_result.scan_rsp_len; - this->parse_adv_(scan_result.ble_adv, total_len); - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "Parse Result:"); - const char *address_type; - switch (this->address_type_) { - case BLE_ADDR_TYPE_PUBLIC: - address_type = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type = "RPA_RANDOM"; - break; - default: - address_type = "UNKNOWN"; - break; - } - ESP_LOGVV(TAG, " Address: %02X:%02X:%02X:%02X:%02X:%02X (%s)", this->address_[0], this->address_[1], - this->address_[2], this->address_[3], this->address_[4], this->address_[5], address_type); - - ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); - ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); - for (auto &it : this->tx_powers_) { - ESP_LOGVV(TAG, " TX Power: %d", it); - } - if (this->appearance_.has_value()) { - ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); - } - if (this->ad_flag_.has_value()) { - ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); - } - for (auto &uuid : this->service_uuids_) { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf); - } - char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; - for (auto &data : this->manufacturer_datas_) { - auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data); - if (ibeacon.has_value()) { - ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - ibeacon.value().get_uuid().to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); - ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); - ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); - } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", uuid_buf, - format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - } - for (auto &data : this->service_datas_) { - ESP_LOGVV(TAG, " Service data:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - - ESP_LOGVV(TAG, " Adv data: %s", - format_hex_pretty_to(hex_buf, scan_result.ble_adv, scan_result.adv_data_len + scan_result.scan_rsp_len)); #endif } -void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { - size_t offset = 0; - - while (offset + 2 < len) { - const uint8_t field_length = payload[offset++]; // First byte is length of adv record - if (field_length == 0) { - continue; // Possible zero padded advertisement data - } - - // Validate field fits in remaining payload - if (offset + field_length > len) { - break; - } - - // first byte of adv record is adv record type - const uint8_t record_type = payload[offset++]; - const uint8_t *record = &payload[offset]; - const uint8_t record_length = field_length - 1; - offset += record_length; - - // See also Generic Access Profile Assigned Numbers: - // https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ See also ADVERTISING AND SCAN - // RESPONSE DATA FORMAT: https://www.bluetooth.com/specifications/bluetooth-core-specification/ (vol 3, part C, 11) - // See also Core Specification Supplement: https://www.bluetooth.com/specifications/bluetooth-core-specification/ - // (called CSS here) - - switch (record_type) { - case ESP_BLE_AD_TYPE_NAME_SHORT: - case ESP_BLE_AD_TYPE_NAME_CMPL: { - // CSS 1.2 LOCAL NAME - // "The Local Name data type shall be the same as, or a shortened version of, the local name assigned to the - // device." CSS 1: Optional in this context; shall not appear more than once in a block. - // SHORTENED LOCAL NAME - // "The Shortened Local Name data type defines a shortened version of the Local Name data type. The Shortened - // Local Name data type shall not be used to advertise a name that is longer than the Local Name data type." - if (record_length > this->name_.length()) { - this->name_ = std::string(reinterpret_cast(record), record_length); - } - break; - } - case ESP_BLE_AD_TYPE_TX_PWR: { - // CSS 1.5 TX POWER LEVEL - // "The TX Power Level data type indicates the transmitted power level of the packet containing the data type." - // CSS 1: Optional in this context (may appear more than once in a block). - this->tx_powers_.push_back(*record); - break; - } - case ESP_BLE_AD_TYPE_APPEARANCE: { - // CSS 1.12 APPEARANCE - // "The Appearance data type defines the external appearance of the device." - // See also https://www.bluetooth.com/specifications/gatt/characteristics/ - // CSS 1: Optional in this context; shall not appear more than once in a block and shall not appear in both - // the AD and SRD of the same extended advertising interval. - this->appearance_ = *reinterpret_cast(record); - break; - } - case ESP_BLE_AD_TYPE_FLAG: { - // CSS 1.3 FLAGS - // "The Flags data type contains one bit Boolean flags. The Flags data type shall be included when any of the - // Flag bits are non-zero and the advertising packet is connectable, otherwise the Flags data type may be - // omitted." - // CSS 1: Optional in this context; shall not appear more than once in a block. - this->ad_flag_ = *record; - break; - } - // CSS 1.1 SERVICE UUID - // The Service UUID data type is used to include a list of Service or Service Class UUIDs. - // There are six data types defined for the three sizes of Service UUIDs that may be returned: - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_16SRV_CMPL: - case ESP_BLE_AD_TYPE_16SRV_PART: { - // • 16-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 2; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint16(*reinterpret_cast(record + 2 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_32SRV_CMPL: - case ESP_BLE_AD_TYPE_32SRV_PART: { - // • 32-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 4; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint32(*reinterpret_cast(record + 4 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_128SRV_CMPL: - case ESP_BLE_AD_TYPE_128SRV_PART: { - // • Global 128-bit Service UUIDs - this->service_uuids_.push_back(ESPBTUUID::from_raw(record)); - break; - } - case ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE: { - // CSS 1.4 MANUFACTURER SPECIFIC DATA - // "The Manufacturer Specific data type is used for manufacturer specific data. The first two data octets shall - // contain a company identifier from Assigned Numbers. The interpretation of any other octets within the data - // shall be defined by the manufacturer specified by the company identifier." - // CSS 1: Optional in this context (may appear more than once in a block). - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->manufacturer_datas_.push_back(data); - break; - } - - // CSS 1.11 SERVICE DATA - // "The Service Data data type consists of a service UUID with the data associated with that service." - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_SERVICE_DATA: { - // «Service Data - 16 bit UUID» - // Size: 2 or more octets - // The first 2 octets contain the 16 bit Service UUID fol- lowed by additional service data - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_32SERVICE_DATA: { - // «Service Data - 32 bit UUID» - // Size: 4 or more octets - // The first 4 octets contain the 32 bit Service UUID fol- lowed by additional service data - if (record_length < 4) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_32SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint32(*reinterpret_cast(record)); - data.data.assign(record + 4UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_128SERVICE_DATA: { - // «Service Data - 128 bit UUID» - // Size: 16 or more octets - // The first 16 octets contain the 128 bit Service UUID followed by additional service data - if (record_length < 16) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_128SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_raw(record); - data.data.assign(record + 16UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_INT_RANGE: - // Avoid logging this as it's very verbose - break; - default: { - ESP_LOGV(TAG, "Unhandled type: advType: 0x%02x", record_type); - break; - } - } - } -} - -std::string ESPBTDevice::address_str() const { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return this->address_str_to(buf); -} - -uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); } -#endif // USE_ESP32_BLE_DEVICE - void ESP32BLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BLE Tracker:"); ESP_LOGCONFIG(TAG, @@ -721,124 +421,33 @@ void ESP32BLETracker::dump_config() { #ifdef USE_ESP32_BLE_DEVICE void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { - const uint64_t address = device.address_uint64(); - for (auto &disc : this->already_discovered_) { - if (disc == address) - return; - } - this->already_discovered_.push_back(address); - - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGD(TAG, "Found device %s RSSI=%d", device.address_str_to(addr_buf), device.get_rssi()); - - const char *address_type_s; - switch (device.get_address_type()) { - case BLE_ADDR_TYPE_PUBLIC: - address_type_s = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type_s = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type_s = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type_s = "RPA_RANDOM"; - break; - default: - address_type_s = "UNKNOWN"; - break; - } - - ESP_LOGD(TAG, " Address Type: %s", address_type_s); - if (!device.get_name().empty()) { - ESP_LOGD(TAG, " Name: '%s'", device.get_name().c_str()); - } - for (auto &tx_power : device.get_tx_powers()) { - ESP_LOGD(TAG, " TX Power: %d", tx_power); - } + // Shared implementation in ble_device_base — identical output and per-period + // MAC dedup on every tracker backend. + this->discovered_log_.log_device(TAG, device); } -bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { - static constexpr size_t AES_BLOCK_SIZE = 16; - static constexpr size_t AES_KEY_BITS = 128; - - uint8_t ecb_key[AES_BLOCK_SIZE]; - uint8_t ecb_plaintext[AES_BLOCK_SIZE]; - uint8_t ecb_ciphertext[AES_BLOCK_SIZE]; - - uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_); - - memcpy(&ecb_key, irk, AES_BLOCK_SIZE); - memset(&ecb_plaintext, 0, AES_BLOCK_SIZE); - - ecb_plaintext[13] = (addr64 >> 40) & 0xff; - ecb_plaintext[14] = (addr64 >> 32) & 0xff; - ecb_plaintext[15] = (addr64 >> 24) & 0xff; - -#ifdef USE_BLE_TRACKER_PSA_AES - // Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, AES_KEY_BITS); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING); - - mbedtls_svc_key_id_t key_id; - if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) { - return false; - } - - size_t output_length; - psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE, - ecb_ciphertext, AES_BLOCK_SIZE, &output_length); - psa_destroy_key(key_id); - if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) { - return false; - } -#else - // Use legacy mbedtls AES API (IDF < 6.0) - mbedtls_aes_context ctx = {0, 0, {0}}; - mbedtls_aes_init(&ctx); - - if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - if (mbedtls_aes_crypt_ecb(&ctx, ESP_AES_ENCRYPT, ecb_plaintext, ecb_ciphertext) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - mbedtls_aes_free(&ctx); -#endif - - return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && - ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); -} +// resolve_irk() is provided by ble_device_base (portable software AES). #endif // USE_ESP32_BLE_DEVICE void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { - // Process raw advertisements - if (this->raw_advertisements_) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - listener->parse_devices(&scan_result, 1); - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->parse_devices(&scan_result, 1); - } -#endif + // Neutral raw-advertisement subscriber (the bluetooth_proxy path). + if (this->raw_advertisement_callback_.is_set()) { + ble_device_base::RawAdvertisement adv; + adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda); + adv.data = scan_result.ble_adv; + adv.data_len = static_cast(scan_result.adv_data_len) + scan_result.scan_rsp_len; + adv.rssi = scan_result.rssi; + adv.addr_type = scan_result.ble_addr_type; + this->raw_advertisement_callback_.invoke(adv); } // Process parsed advertisements if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE ESPBTDevice device; + // The historical ingest keeps the raw scan-result fields populated for + // external components. device.parse_scan_rst(scan_result); bool found = false; @@ -848,6 +457,12 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { found = true; } #endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) { + if (listener->parse_device(device)) + found = true; + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { @@ -867,7 +482,7 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGV(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif // Reset timeout state machine instead of cancelling scheduler timeout this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; @@ -876,6 +491,10 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { for (auto *listener : this->listeners_) listener->on_scan_end(); #endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) + listener->on_scan_end(); +#endif this->set_scanner_state_(ScannerState::IDLE); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3415196a11..7c3e5538fd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -8,17 +8,9 @@ #include #include #include -#include #ifdef USE_ESP32 -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API. -// Use the PSA Crypto API instead. -#define USE_BLE_TRACKER_PSA_AES -#endif - #include #include #include @@ -26,6 +18,9 @@ #include #include +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" #include "esphome/components/esp32_ble/ble_scan_result.h" @@ -38,118 +33,31 @@ namespace esphome::esp32_ble_tracker { using namespace esp32_ble; -using adv_data_t = std::vector; - -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; +using adv_data_t = ble_device_base::adv_data_t; #ifdef USE_ESP32_BLE_UUID -struct ServiceData { - ESPBTUUID uuid; - adv_data_t data; -}; +using ServiceData = ble_device_base::ServiceData; #endif #ifdef USE_ESP32_BLE_DEVICE -class ESPBLEiBeacon { - public: - ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } - ESPBLEiBeacon(const uint8_t *data); - static optional from_manufacturer_data(const ServiceData &data); - - uint16_t get_major() { return byteswap(this->beacon_data_.major); } - uint16_t get_minor() { return byteswap(this->beacon_data_.minor); } - int8_t get_signal_power() { return this->beacon_data_.signal_power; } - ESPBTUUID get_uuid() { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } - - protected: - struct { - uint8_t sub_type; - uint8_t length; - uint8_t proximity_uuid[16]; - uint16_t major; - uint16_t minor; - int8_t signal_power; - } PACKED beacon_data_; -}; - -class ESPBTDevice { - public: - void parse_scan_rst(const BLEScanResult &scan_result); - - std::string address_str() const; - - /// Format MAC address into provided buffer, returns pointer to buffer for convenience - const char *address_str_to(std::span buf) const { - format_mac_addr_upper(this->address_, buf.data()); - return buf.data(); - } - - uint64_t address_uint64() const; - - const uint8_t *address() const { return address_; } - - esp_ble_addr_type_t get_address_type() const { return this->address_type_; } - int get_rssi() const { return rssi_; } - const std::string &get_name() const { return this->name_; } - - const std::vector &get_tx_powers() const { return tx_powers_; } - - const optional &get_appearance() const { return appearance_; } - const optional &get_ad_flag() const { return ad_flag_; } - const std::vector &get_service_uuids() const { return service_uuids_; } - - const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } - - const std::vector &get_service_datas() const { return service_datas_; } - - // Exposed through a function for use in lambdas - const BLEScanResult &get_scan_result() const { return *scan_result_; } - - bool resolve_irk(const uint8_t *irk) const; - - optional get_ibeacon() const { - for (auto &it : this->manufacturer_datas_) { - auto res = ESPBLEiBeacon::from_manufacturer_data(it); - if (res.has_value()) - return res; - } - return {}; - } - - protected: - void parse_adv_(const uint8_t *payload, uint8_t len); - - esp_bd_addr_t address_{ - 0, - }; - esp_ble_addr_type_t address_type_{BLE_ADDR_TYPE_PUBLIC}; - int rssi_{0}; - std::string name_{}; - std::vector tx_powers_{}; - optional appearance_{}; - optional ad_flag_{}; - std::vector service_uuids_{}; - std::vector manufacturer_datas_{}; - std::vector service_datas_{}; - const BLEScanResult *scan_result_{nullptr}; -}; +// The advertisement device types are owned by the platform-neutral +// ble_device_base layer; re-exported here (esp32 only) for backward +// compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult. +using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon; +using ESPBTDevice = ble_device_base::ESPBTDevice; #endif // USE_ESP32_BLE_DEVICE class ESP32BLETracker; -class ESPBTDeviceListener { +// esp32-flavored listener: the neutral parse_device/on_scan_end come from +// ble_device_base; this subclass adds the esp32-only raw-advertisement path +// (BLEScanResult batches) and the tracker back-pointer. +class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { public: - virtual void on_scan_end() {} -#ifdef USE_ESP32_BLE_DEVICE - virtual bool parse_device(const ESPBTDevice &device) = 0; +#ifndef USE_ESP32_BLE_DEVICE + // Raw-only build: no parsed-device support is compiled in. + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; - virtual AdvertisementParserType get_advertisement_parser_type() { - return AdvertisementParserType::PARSED_ADVERTISEMENTS; - }; void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -172,60 +80,14 @@ struct ClientStateCounts { bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } }; -enum class ClientState : uint8_t { - // Connection is allocated - INIT, - // Client is disconnecting - DISCONNECTING, - // Connection is idle, no device detected. - IDLE, - // Device advertisement found. - DISCOVERED, - // Connection in progress. - CONNECTING, - // Initial connection established. - CONNECTED, - // The client and sub-clients have completed setup. - ESTABLISHED, -}; +// The client connection state types are owned by the platform-neutral +// ble_device_base layer; re-exported here for backward compatibility. +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::client_state_to_string; -enum class ScannerState { - // Scanner is idle, init state - IDLE, - // Scanner is starting - STARTING, - // Scanner is running - RUNNING, - // Scanner failed to start - FAILED, - // Scanner is stopping - STOPPING, -}; - -/** Listener interface for BLE scanner state changes. - * - * Components can implement this interface to receive scanner state updates - * without the overhead of std::function callbacks. - */ -class BLEScannerStateListener { - public: - virtual void on_scanner_state(ScannerState state) = 0; -}; - -// Helper function to convert ClientState to string -const char *client_state_to_string(ClientState state); - -enum class ConnectionType : uint8_t { - // The default connection type, we hold all the services in ram - // for the duration of the connection. - V1, - // The client has a cache of the services and mtu so we should not - // fetch them again - V3_WITH_CACHE, - // The client does not need the services and mtu once we send them - // so we should wipe them from memory as soon as we send them - V3_WITHOUT_CACHE -}; +// Neutral scanner lifecycle re-exported for backward compatibility. +using ScannerState = ble_device_base::ScannerState; /// Base class for BLE GATT clients that connect to remote devices. /// @@ -242,6 +104,10 @@ enum class ConnectionType : uint8_t { /// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { public: + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this client's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } + virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; @@ -314,9 +180,32 @@ class ESP32BLETracker final : public Component, void loop() override; + // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); - void recalculate_advertisement_parser_types(); + + // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->raw_advertisement_callback_ = callback; + } +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; + } +#endif + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // scan_mode_switch is false: the mode is driven through this tracker's own + // API (set_scan_active + restart), not the neutral request_scan_mode(). + return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, + /* scan_mode_switch = */ false}; + } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() { return this->scan_active_; } + // The mode is driven through this tracker's own API (see get_capabilities); + // the neutral request refuses without changing any state. + bool request_scan_mode(bool active) { return false; } #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); @@ -334,10 +223,6 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif - /// Add a listener for scanner state changes - void add_scanner_state_listener(BLEScannerStateListener *listener) { - this->scanner_state_listeners_.push_back(listener); - } ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -404,10 +289,18 @@ class ESP32BLETracker final : public Component, #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - std::vector scanner_state_listeners_; + // Parsed listeners registered through the neutral BLEHub contract (migrated + // sensors); dispatched alongside listeners_. +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + StaticVector neutral_listeners_; +#endif + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + ble_device_base::ScannerStateCallback scanner_state_callback_{}; +#endif #ifdef USE_ESP32_BLE_DEVICE - /// Vector of addresses that have already been printed in print_bt_device_info - std::vector already_discovered_; + /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) + ble_device_base::DiscoveredDeviceLog discovered_log_; #endif // Group 2: Structs (aligned to 4 bytes) @@ -443,7 +336,6 @@ class ESP32BLETracker final : public Component, bool scan_continuous_before_ota_{false}; #endif bool ble_was_disabled_{true}; - bool raw_advertisements_{false}; bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 7527bbf7e4..88579e9632 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -13,7 +13,9 @@ namespace esphome::esp32_camera_web_server { -static const int IMAGE_REQUEST_TIMEOUT = 5000; +static const uint32_t IMAGE_REQUEST_TIMEOUT = 5000; +// How often streaming_handler_ reports its throughput. +static const uint32_t STREAM_STATS_INTERVAL = 5000; static const char *const TAG = "esp32_camera_web_server"; #define PART_BOUNDARY "123456789000000000000987654321" @@ -113,10 +115,31 @@ std::shared_ptr CameraWebServer::wait_for_image_() std::shared_ptr image; image.swap(this->image_); - if (!image) { - // retry as we might still be fetching image - xSemaphoreTake(this->semaphore_, IMAGE_REQUEST_TIMEOUT / portTICK_PERIOD_MS); + if (image) + return image; + + // Keep waiting until a frame really shows up, rather than trusting a single + // take() to mean one is there. + // + // on_camera_image() gives the semaphore for every frame it accepts, but the + // swap above hands frames out without taking it, so as soon as the camera is + // faster than this task for one frame the (binary) semaphore is left + // signalled by a frame that has already been consumed. The next take() then + // returns immediately with nothing to swap in, and the caller reports a lost + // frame and closes the stream -- after an arbitrary number of good frames, + // which is exactly when the camera happens to fall behind for one iteration. + // + // running_ is re-checked on every pass so a shutdown or a client that went + // away is noticed straight away instead of after the full timeout. + const uint32_t start = millis(); + while (this->running_) { + uint32_t elapsed = millis() - start; + if (elapsed >= IMAGE_REQUEST_TIMEOUT) + break; + xSemaphoreTake(this->semaphore_, pdMS_TO_TICKS(IMAGE_REQUEST_TIMEOUT - elapsed)); image.swap(this->image_); + if (image) + break; } return image; @@ -170,8 +193,14 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { return res; } - uint32_t last_frame = millis(); uint32_t frames = 0; + // Frame statistics are aggregated over STREAM_STATS_INTERVAL rather than + // logged per frame. A line per frame comes out of this (non-main) task tens + // of times a second, and formatting and buffering it costs more than the + // stream it is reporting on. + uint32_t stats_since = millis(); + uint32_t stats_frames = 0; + uint32_t stats_bytes = 0; camera::Camera::instance()->start_stream(esphome::camera::WEB_REQUESTER); @@ -179,7 +208,10 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { auto image = this->wait_for_image_(); if (!image) { - ESP_LOGW(TAG, "STREAM: failed to acquire frame"); + // A shutdown is not a lost frame: wait_for_image_() returns empty as soon + // as running_ clears, and the loop condition below ends the stream anyway. + if (this->running_) + ESP_LOGW(TAG, "STREAM: failed to acquire frame"); res = ESP_FAIL; } if (res == ESP_OK) { @@ -194,14 +226,29 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { } if (res == ESP_OK) { frames++; - int64_t frame_time = millis() - last_frame; - last_frame = millis(); - - ESP_LOGD(TAG, "MJPG: %" PRIu32 "B %" PRIu32 "ms (%.1ffps)", (uint32_t) image->get_data_length(), - (uint32_t) frame_time, 1000.0 / (uint32_t) frame_time); + stats_frames++; + stats_bytes += image->get_data_length(); + uint32_t elapsed = millis() - stats_since; + if (elapsed >= STREAM_STATS_INTERVAL) { + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + stats_since = millis(); + stats_frames = 0; + stats_bytes = 0; + } } } + // Report whatever did not fill a whole interval, so a stream that only ran for + // a second or two still says what it managed rather than nothing at all. + if (stats_frames > 0) { + uint32_t elapsed = millis() - stats_since; + if (elapsed == 0) + elapsed = 1; + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + } + if (!frames) { res = httpd_send_all(req, STREAM_ERROR, strlen(STREAM_ERROR)); } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 16e9d49782..b15ae53711 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -256,10 +256,10 @@ async def to_code(config): idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ed2a8c5a68..95391ef100 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -255,11 +255,11 @@ light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index break; } uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, + return {this->buf_ + (index * multiplier) + r + (white <= r), + this->buf_ + (index * multiplier) + g + (white <= g), + this->buf_ + (index * multiplier) + b + (white <= b), this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, &this->effect_data_[index], &this->correction_}; @@ -295,11 +295,22 @@ void ESP32RMTLEDStripLightOutput::dump_config() { rgb_order = "UNKNOWN"; break; } + if (this->is_rgbw_ || this->is_wrgb_) { + char rgbw_order[5]; + uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; + uint8_t rgb_index = 0; + for (uint8_t i = 0; i < 4; i++) { + rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; + } + rgbw_order[4] = '\0'; + ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); + } else { + ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); + } ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index d7ba2aafbf..3e31309bff 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -52,6 +52,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_rgbw_order(uint8_t white_index) { + this->is_rgbw_ = true; + this->is_wrgb_ = false; + this->white_index_ = white_index; + } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -91,6 +96,8 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint16_t num_leds_; bool is_rgbw_{false}; bool is_wrgb_{false}; + // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. + uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 1c6943b003..2722a9b656 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_RMT_SYMBOLS, CONF_USE_DMA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -62,6 +63,7 @@ CHIPSETS = { } CONF_IS_WRGB = "is_wrgb" +CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -70,6 +72,26 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" +def _validate_rgbw_order(value: str) -> str: + value = cv.string(value).upper() + if len(value) != 4 or set(value) != set("RGBW"): + raise cv.Invalid("RGBW order must be a permutation of RGBW") + return value + + +def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: + return rgbw_order.replace("W", ""), rgbw_order.index("W") + + +def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: + if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): + raise cv.Invalid( + f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " + f"'{CONF_IS_WRGB}'" + ) + return config + + CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -80,7 +102,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -130,6 +153,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), + _validate_rgbw_order_exclusivity, ) @@ -173,9 +198,14 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: + rgb_order, white_index = _split_rgbw_order(rgbw_order) + cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) + cg.add(var.set_rgbw_order(white_index)) + else: + cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) + cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 7ce10d465d..1f7159919d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -30,6 +30,7 @@ from esphome.core import ( ) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import IS_MACOS, copy_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -294,9 +295,11 @@ async def to_code(config): ) extra_scripts = [ + "pre:ccache.py", "pre:testing_mode.py", "pre:exclude_updater.py", "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", ] if not enable_scanf_float: extra_scripts.append("pre:remove_float_scanf.py") @@ -443,31 +446,19 @@ async def finalize_serial_config() -> None: # Called by writer.py def copy_files() -> None: dir = Path(__file__).parent - post_build_file = dir / "post_build.py.script" - copy_file_if_changed( - post_build_file, - CORE.relative_build_path("post_build.py"), - ) - testing_mode_file = dir / "testing_mode.py.script" - copy_file_if_changed( - testing_mode_file, - CORE.relative_build_path("testing_mode.py"), - ) - exclude_updater_file = dir / "exclude_updater.py.script" - copy_file_if_changed( - exclude_updater_file, - CORE.relative_build_path("exclude_updater.py"), - ) - exclude_waveform_file = dir / "exclude_waveform.py.script" - copy_file_if_changed( - exclude_waveform_file, - CORE.relative_build_path("exclude_waveform.py"), - ) - remove_float_scanf_file = dir / "remove_float_scanf.py.script" - copy_file_if_changed( - remove_float_scanf_file, - CORE.relative_build_path("remove_float_scanf.py"), - ) + for script in ( + "post_build", + "testing_mode", + "exclude_updater", + "exclude_waveform", + "remove_float_scanf", + "relocate_ratetable", + ): + copy_file_if_changed( + dir / f"{script}.py.script", + CORE.relative_build_path(f"{script}.py"), + ) + copy_ccache_script() # ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 229ac61f24..3e89ab989f 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,4 +1,8 @@ import esphome.codegen as cg + +# Re-exported from the shared definition; here it indexes the BOARDS +# metadata dicts, whose entries in boards.py spell the literal. +from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import from esphome.core import CORE KEY_ESP8266 = "esp8266" @@ -8,7 +12,6 @@ CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" -KEY_FLASH_SIZE = "flash_size" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" diff --git a/esphome/components/esp8266/relocate_ratetable.py.script b/esphome/components/esp8266/relocate_ratetable.py.script new file mode 100644 index 0000000000..c9d0ba166d --- /dev/null +++ b/esphome/components/esp8266/relocate_ratetable.py.script @@ -0,0 +1,70 @@ +# pylint: disable=E0602 +Import("env") # noqa + +# Move the NONOS SDK wifi rate tables from flash to DRAM +# +# libnet80211.a ships its 802.11b/11g rate tables in the .irom.text section +# of ieee80211_phy.o (440 bytes of pure data, no relocations). The Arduino +# core linker script places .irom.text in flash, but the SDK reads these +# tables with byte loads and ets_memcpy from the wifi RX path while parsing +# beacons. Byte access to flash-mapped memory from that context misbehaves +# and crashes with StoreProhibited in ROM memcpy (PC 0x4000df64): +# +# scan_parse_beacon -> cnx_update_bss_more -> ieee80211_phy_init +# -> ieee80211_setup_ratetable -> ets_memcpy -> crash +# +# See https://github.com/espressif/ESP8266_NONOS_SDK/issues/320 (1000+ +# reports). The SDK is abandoned so the fix from +# https://github.com/espressif/ESP8266_NONOS_SDK/pull/345 was never merged; +# we apply the same linker rule here: place ieee80211_phy.o's .irom.text +# inside the DRAM .data output section so the tables are copied to RAM at +# boot. Costs 440 bytes of DRAM. +# +# The rule is inserted into the working linker script that PlatformIO +# generates in the build directory (local.eagle.app.v6.common.ld). SDK +# package files are never modified. + +import re +from os.path import join + +RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + + +def relocate_ratetable(source, target, env): + """Insert the rate table DRAM rule into the generated linker script. + + Runs as a pre-action of the link step; the linker script is a declared + dependency of the elf, so it has already been generated at this point. + """ + ld_path = join(env.subst("$BUILD_DIR"), "ld", "local.eagle.app.v6.common.ld") + with open(ld_path, encoding="utf-8") as f: + contents = f.read() + + if RULE in contents: + return # Already patched (incremental build) + + match = ANCHOR.search(contents) + if match is None: + raise RuntimeError( + f"ESPHome: '_data_start' anchor not found in {ld_path}; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + + insert_pos = match.end() + patched = ( + contents[:insert_pos] + + "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" + + f"\n {RULE}" + + contents[insert_pos:] + ) + with open(ld_path, "w", encoding="utf-8") as f: + f.write(patched) + print("ESPHome: Relocated wifi rate tables to DRAM (fixes beacon parse crash)") + + +# Register the callback to run before the link step +env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", relocate_ratetable) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index c6c90ed67a..373ef345d1 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WIFI, ) -from esphome.core import HexInt +from esphome.core import CORE, HexInt from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -151,6 +151,10 @@ async def to_code(config): cg.add_define("USE_ESPNOW") cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) + + if CONF_WIFI in CORE.config: + # Track the Wi-Fi channel via connect events instead of polling every loop + wifi.request_wifi_connect_state_listener() if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f28d7f3354..df9a1b8668 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -7,7 +7,6 @@ #include #include -#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -75,6 +74,7 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -90,8 +90,8 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW send event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW send event + global_esp_now->enable_loop_soon_any_context(); } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { @@ -101,6 +101,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // larger frame would overflow packet_.receive.data. if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -109,6 +110,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -120,8 +122,8 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW receive event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW receive event + global_esp_now->enable_loop_soon_any_context(); } ESPNowComponent::ESPNowComponent() { global_esp_now = this; } @@ -156,6 +158,11 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->add_connect_state_listener(this); + } +#endif if (this->enable_on_boot_) { this->enable_(); } else { @@ -163,6 +170,19 @@ void ESPNowComponent::setup() { } } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::span bssid) { + if (ssid.empty()) { + return; // Disconnected; the channel is only meaningful while associated + } + uint8_t old_channel = this->wifi_channel_; + this->get_wifi_channel(); + if (this->wifi_channel_ != old_channel) { + ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); + } +} +#endif + void ESPNowComponent::enable() { if (this->state_ == ESPNOW_STATE_ENABLED) return; @@ -254,15 +274,6 @@ void ESPNowComponent::apply_wifi_channel() { } void ESPNowComponent::loop() { -#ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { - int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); - if (new_channel != this->wifi_channel_) { - ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel); - this->wifi_channel_ = new_channel; - } - } -#endif // Process received packets ESPNowPacket *packet = this->receive_packet_queue_.pop(); while (packet != nullptr) { @@ -348,6 +359,15 @@ void ESPNowComponent::loop() { if (send_dropped > 0) { ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } + + // Nothing left to do; sleep until a callback or send() re-enables the loop. + // A packet in flight (current_send_packet_) needs no loop time even when more + // packets are queued behind it: the send callback re-enables the loop when + // the result arrives, and the SENT event handler above starts the next send. + if (this->receive_packet_queue_.empty() && + (this->current_send_packet_ != nullptr || this->send_packet_queue_.empty())) { + this->disable_loop(); + } } uint8_t ESPNowComponent::get_wifi_channel() { @@ -390,6 +410,9 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl packet->load_data(peer_address, payload, size, callback); // Push the packet to the send queue this->send_packet_queue_.push(packet); + // Loop may be disabled while idle; re-enable it to send the packet + // (any-context variant so callers off the main loop are safe too) + this->enable_loop_soon_any_context(); return ESP_OK; } diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index d95255c5df..af693b47cf 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -2,6 +2,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 @@ -9,6 +10,10 @@ #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#endif + #include #include @@ -88,7 +93,11 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +class ESPNowComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class ESPNowComponent final : public Component { +#endif public: ESPNowComponent(); void setup() override; @@ -114,6 +123,11 @@ class ESPNowComponent final : public Component { void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + // WiFiConnectStateListener interface: refresh the cached channel after each (re)connect + void on_wifi_connect_state(StringRef ssid, std::span bssid) override; +#endif + void enable(); void disable(); bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 2b1a256599..8bdd536ffb 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,12 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import add_use_address, ip_address_literal +from esphome.components.network import ( + add_use_address, + get_network_priority, + get_priority_interfaces_from_full_config, + ip_address_literal, +) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -50,7 +55,6 @@ from esphome.core import ( import esphome.final_validate as fv from esphome.types import ConfigType -CONFLICTS_WITH = ["wifi"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -128,6 +132,7 @@ ETHERNET_TYPES = { "W6300": EthernetType.ETHERNET_TYPE_W6300, "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, + "CH390": EthernetType.ETHERNET_TYPE_CH390, } # PHY types that need compile-time defines for conditional compilation @@ -149,6 +154,7 @@ _PHY_TYPE_TO_DEFINE = { "W6300": "USE_ETHERNET_W6300", "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -172,13 +178,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), } # These types are always external IDF components (never built-in to ESP-IDF) -_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} +_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -476,6 +483,12 @@ SPI_SCHEMA = _spi_schema() # of spec for it and makes the driver's CS hold time helper compute no hold SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) +# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V, +# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not. +# CH390 datasheet v1.8, tables 9-4 and 9-5: +# https://www.wch-ic.com/downloads/CH390DS1_PDF.html +SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -490,6 +503,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, + "CH390": SPI_SCHEMA_CH390, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), @@ -546,6 +560,14 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + + # Apply network priority before register_component (which emits the user's + # explicit setup_priority: if set) so that, as in wifi, an explicit + # setup_priority: still wins over the network-priority-derived value. + prio = get_network_priority("ethernet") + if prio is not None: + cg.set_setup_priority(var, prio) + await cg.register_component(var, config) if CORE.is_esp32: @@ -617,8 +639,11 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 - # ENC28J60 was never built-in to IDF, so it has no Kconfig option - if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60": + # Types that are never built into IDF ship no Kconfig option at all + if ( + idf_version() < cv.Version(6, 0, 0) + and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS + ): add_idf_sdkconfig_option( f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True ) @@ -655,8 +680,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Register Ethernet with the esp32 sdkconfig reconciler, which disables the - # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + # Register Ethernet with the esp32 sdkconfig reconciler. It disables the + # WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi, + # so multi-interface configs (network: priority: with both) keep WiFi. request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) @@ -743,6 +769,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> ConfigType: """Final validation for Ethernet component.""" + # Allow ethernet + wifi coexistence only when both are declared in network: priority:. + if "wifi" in fv.full_config.get(): + priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get()) + missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces] + if missing and priority_ifaces: + # A priority list exists but is incomplete: point at what to add. + raise cv.Invalid( + "When ethernet and wifi are used together, 'network: priority:' must " + f"list both interfaces; missing: {', '.join(missing)}" + ) + if missing: + raise cv.Invalid( + "Component ethernet cannot be used together with component wifi " + "unless both are listed under 'network: priority:'" + ) + _final_validate_spi(config) _final_validate_rmii_pins(config) return config diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7160351727..646e0af8e6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -88,6 +88,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W6300, ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { @@ -112,8 +113,10 @@ enum class EthernetComponentState : uint8_t { // Platform-neutral duplex/speed types #ifndef USE_ESP32 +// NOLINTBEGIN(readability-identifier-naming) enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +// NOLINTEND(readability-identifier-naming) #endif class EthernetComponent final : public Component { @@ -137,11 +140,17 @@ class EthernetComponent final : public Component { bool is_disabled() { return this->disabled_; } bool is_enabled() { return !this->disabled_; } +#ifdef USE_ESP32 + /// esp_netif handle, used by network for default-route arbitration. + /// nullptr until the driver/netif installation has run. + esp_netif_t *get_esp_netif() { return this->eth_netif_; } +#endif + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); #endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); @@ -333,7 +342,7 @@ class EthernetComponent final : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 94f4c23479..0220d6a19b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -50,6 +50,12 @@ #include "esp_eth_enc28j60.h" #endif +// CH390 headers exist on all IDF versions (always an external component) +#ifdef USE_ETHERNET_CH390 +#include "esp_eth_mac_ch390.h" +#include "esp_eth_phy_ch390.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include @@ -215,6 +221,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_ENC28J60) eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_CH390) + eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -236,6 +244,11 @@ void EthernetComponent::ethernet_lazy_init_() { // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; +#elif defined(USE_ETHERNET_CH390) + ch390_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ch390_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -360,6 +373,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_enc28j60(&phy_config); break; } +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: { + mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config); + this->phy_ = esp_eth_phy_new_ch390(&phy_config); + break; + } #endif #endif default: { @@ -410,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() { #endif // !USE_ETHERNET_SPI // use ESP internal eth mac - uint8_t mac_addr[6]; + uint8_t mac_addr[MAC_ADDRESS_SIZE]; if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); + memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac_addr, ESP_MAC_ETH); } @@ -519,6 +538,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_ENC28J60: eth_type = "ENC28J60"; break; +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: + eth_type = "CH390"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: @@ -766,16 +789,25 @@ void EthernetComponent::start_connect_() { #ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { - LwIPLock lock; + // Set DNS through esp_netif so the servers are stored in the netif's own + // dns[] array; raw dns_setserver() would be lost when the default-route + // arbitration re-applies the default netif's DNS. + // Log-only on failure: the link still has a working IP/gateway, so degraded + // name resolution does not justify marking the whole component failed. + esp_netif_dns_info_t dns{}; if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); + dns.ip = this->manual_ip_->dns1; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); + } } if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); + dns.ip = this->manual_ip_->dns2; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); + } } } else #endif @@ -903,7 +935,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. if (this->fixed_mac_.has_value()) { - memcpy(mac, this->fixed_mac_->data(), 6); + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac, ESP_MAC_ETH); } @@ -921,7 +953,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index d2e3f14e02..119e447689 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -187,17 +187,18 @@ void EthernetComponent::loop() { } void EthernetComponent::dump_config() { - const char *type_str = "Unknown"; #if defined(USE_ETHERNET_W5500) - type_str = "W5500"; + const char *type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) - type_str = "W5100"; + const char *type_str = "W5100"; #elif defined(USE_ETHERNET_W6100) - type_str = "W6100"; + const char *type_str = "W6100"; #elif defined(USE_ETHERNET_W6300) - type_str = "W6300"; + const char *type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) - type_str = "ENC28J60"; + const char *type_str = "ENC28J60"; +#else + const char *type_str = "Unknown"; #endif #if defined(USE_ETHERNET_W6300) // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used @@ -244,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (this->eth_ != nullptr) { this->eth_->macAddress(mac); } else { - memset(mac, 0, 6); + memset(mac, 0, MAC_ADDRESS_SIZE); } } @@ -255,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index ab7416a264..6cb5b750dd 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -1,33 +1,59 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_TRIGGER_ID +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") ExposureNotificationTrigger = exposure_notifications_ns.class_( "ExposureNotificationTrigger", - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, automation.Trigger.template(ExposureNotification), ) CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification" +_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications") + +_VALIDATE_AUTOMATION = automation.validate_automation( + cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger), + } + # The trigger is the BLE listener, so the hub id lives on it. + ).extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +# validate_automation() needs a dict-based schema, so the rename cannot go +# inside it and has to run on the option value first. That value may also be a +# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so +# map over lists and let validate_automation() report anything else. +# schema_extractor keeps the key typed as a trigger in the generated editor +# schema; build_language_schema.py recurses into cv.All but not into a plain +# function. +@schema_extractor("automation") +def _validate_on_exposure_notification(value: Any) -> list[ConfigType]: + if value is SCHEMA_EXTRACT: + return _VALIDATE_AUTOMATION(value) + if isinstance(value, dict): + value = _RENAME_HUB_ID(value) + elif isinstance(value, list): + value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value] + return _VALIDATE_AUTOMATION(value) + + CONFIG_SCHEMA = cv.Schema( { - cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation( - cv.Schema( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ExposureNotificationTrigger - ), - } - ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - ), + cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification, } ) @@ -36,4 +62,4 @@ async def to_code(config): for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) await automation.build_automation(trigger, [(ExposureNotification, "x")], conf) - await esp32_ble_tracker.register_ble_device(trigger, conf) + await ble_device_base.register_ble_device(trigger, conf) diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index e7038d2ca9..4f4b93b59c 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -2,11 +2,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { -using namespace esp32_ble_tracker; +using namespace ble_device_base; static const char *const TAG = "exposure_notifications"; @@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { } } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 6a703a9a92..dc1241db56 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -2,11 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { struct ExposureNotification { @@ -17,11 +15,9 @@ struct ExposureNotification { }; class ExposureNotificationTrigger final : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index a0473b292c..ed4efeeabc 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -23,8 +23,8 @@ CONF_PUMP_VOLTAGE = "pump_voltage" CONF_LAST_VOLUME_REQUESTED = "last_volume_requested" CONF_MAX_FLOW_RATE = "max_flow_rate" -UNIT_MILILITER = "ml" -UNIT_MILILITERS_PER_MINUTE = "ml/min" +UNIT_MILILITER = "mL" +UNIT_MILILITERS_PER_MINUTE = "mL/min" CONFIG_SCHEMA = cv.Schema( { diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 0fa69a23b4..da4dbf2ed7 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "fastled_light.h" #include "esphome/core/log.h" diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 1261b742a1..9f903b4530 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/component.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b6..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,6 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -295,45 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: 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: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -344,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -356,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -385,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index fc35271017..d2102496a2 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -7,7 +7,6 @@ namespace esphome::growatt_solar { static const char *const TAG = "growatt_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion void GrowattSolar::loop() { @@ -31,11 +30,12 @@ void GrowattSolar::update() { } this->waiting_to_update_ = false; - this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]); + this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); this->last_send_ = millis(); } -void GrowattSolar::on_modbus_data(const std::vector &data) { +void GrowattSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); // Other components might be sending commands to our device. But we don't get called with enough // context to know what is what. So if we didn't do a send, we ignore the data. if (!this->last_send_) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 18a7c917d5..a172f49001 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::growatt_solar { @@ -69,7 +69,7 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD public: void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; } diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index 11bb24ce44..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -68,6 +70,21 @@ MODELS = { CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", }, }, + "GUITION-JC8012P4A1": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: False, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 880, + CONF_Y_MAX: 1648, + CONF_RESET_PIN: 22, + CONF_INTERRUPT_PIN: 21, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, "CUSTOM": {}, } @@ -88,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -141,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 74a218263d..294aa53b03 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -132,7 +132,7 @@ void HaierClimateBase::save_settings() { } bool HaierClimateBase::get_display_state() const { - return (this->display_status_ == SwitchState::ON) || (this->display_status_ == SwitchState::PENDING_ON); + return (this->display_status_ == SwitchState::SWITCH_ON) || (this->display_status_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_display_state(bool state) { @@ -144,7 +144,7 @@ void HaierClimateBase::set_display_state(bool state) { } bool HaierClimateBase::get_health_mode() const { - return (this->health_mode_ == SwitchState::ON) || (this->health_mode_ == SwitchState::PENDING_ON); + return (this->health_mode_ == SwitchState::SWITCH_ON) || (this->health_mode_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_health_mode(bool state) { diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 13e8d7548d..db4c1abceb 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -147,8 +147,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional message; }; enum class SwitchState { - OFF = 0b00, - ON = 0b01, + SWITCH_OFF = 0b00, + SWITCH_ON = 0b01, PENDING_OFF = 0b10, PENDING_ON = 0b11, }; @@ -157,8 +157,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional action_request_; uint8_t fan_mode_speed_; uint8_t other_modes_fan_speed_; - SwitchState display_status_{SwitchState::ON}; - SwitchState health_mode_{SwitchState::OFF}; + SwitchState display_status_{SwitchState::SWITCH_ON}; + SwitchState health_mode_{SwitchState::SWITCH_OFF}; bool force_send_control_; bool forced_request_status_; bool reset_protocol_request_; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 88d446829a..881a2328cb 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -50,7 +50,7 @@ void HonClimate::set_quiet_mode_state(bool state) { this->quiet_mode_state_ = state ? SwitchState::PENDING_ON : SwitchState::PENDING_OFF; this->force_send_control_ = true; } else { - this->quiet_mode_state_ = state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } this->settings_.quiet_mode_state = state; #ifdef USE_SWITCH @@ -63,7 +63,7 @@ void HonClimate::set_quiet_mode_state(bool state) { } bool HonClimate::get_quiet_mode_state() const { - return (this->quiet_mode_state_ == SwitchState::ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); + return (this->quiet_mode_state_ == SwitchState::SWITCH_ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); } esphome::optional HonClimate::get_vertical_airflow() const { @@ -513,7 +513,7 @@ void HonClimate::initialization() { } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; - this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } haier_protocol::HaierMessage HonClimate::get_control_message() { @@ -939,14 +939,14 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->display_status_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { @@ -1008,7 +1008,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // In proper mode and not in pending state bool new_quiet_mode = packet.control.quiet_mode != 0; if (new_quiet_mode != this->get_quiet_mode_state()) { - this->quiet_mode_state_ = new_quiet_mode ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = new_quiet_mode ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; this->settings_.quiet_mode_state = new_quiet_mode; #ifdef USE_SWITCH if (this->quiet_mode_switch_ != nullptr) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index ba36e6a8fb..a34b4422c6 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -197,7 +197,7 @@ class HonClimate final : public HaierClimateBase { esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; - SwitchState quiet_mode_state_{SwitchState::OFF}; + SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; }; } // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index a013371649..fdb3b779e2 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -464,14 +464,14 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->health_mode_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 9257a37fd9..6af72c352b 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -7,10 +7,10 @@ namespace esphome::havells_solar { static const char *const TAG = "havells_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03; static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers -void HavellsSolar::on_modbus_data(const std::vector &data) { +void HavellsSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for HavellsSolar!"); return; @@ -121,7 +121,7 @@ void HavellsSolar::on_modbus_data(const std::vector &data) { this->dci_of_t_sensor_->publish_state(dci_of_t); } -void HavellsSolar::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); } void HavellsSolar::dump_config() { ESP_LOGCONFIG(TAG, "HAVELLS Solar:\n" diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index 02e999c56c..ed5d13b8b6 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::havells_solar { @@ -77,7 +77,7 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index cd1b7d2bb0..21f7ea6393 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@rob-deutsch"] @@ -97,6 +98,18 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } + +def _default_visual(config: ConfigType) -> ConfigType: + # Seed the visual min/max from the required min/max_temperature so the entity + # reports the configured range in Home Assistant instead of the ClimateIR + # 0-100 default. Done during validation so the effective range is visible in + # the dumped config and set before new_climate_ir() reads CONF_VISUAL. + visual = config.setdefault(CONF_VISUAL, {}) + visual.setdefault(CONF_MAX_TEMPERATURE, config[CONF_MAX_TEMPERATURE]) + visual.setdefault(CONF_MIN_TEMPERATURE, config[CONF_MIN_TEMPERATURE]) + return config + + CONFIG_SCHEMA = cv.All( climate_ir.climate_ir_with_receiver_schema(HeatpumpIRClimate).extend( { @@ -108,18 +121,12 @@ CONFIG_SCHEMA = cv.All( } ), cv.Any(cv.only_with_arduino, cv.only_on_esp32), + _default_visual, ) async def to_code(config): var = await climate_ir.new_climate_ir(config) - if CONF_VISUAL not in config: - config[CONF_VISUAL] = {} - visual = config[CONF_VISUAL] - if CONF_MAX_TEMPERATURE not in visual: - visual[CONF_MAX_TEMPERATURE] = config[CONF_MAX_TEMPERATURE] - if CONF_MIN_TEMPERATURE not in visual: - visual[CONF_MIN_TEMPERATURE] = config[CONF_MIN_TEMPERATURE] cg.add(var.set_protocol(config[CONF_PROTOCOL])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 7a0dc0690c..964d26dfbc 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -242,7 +242,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { return; } - if (data[1] != HlkFm22xResult::SUCCESS) { + if (data[1] != HlkFm22xResult::SUCCEEDED) { ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); switch (expected) { case HlkFm22xCommand::ENROLL: diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 34246f52f0..3bdf6e2c71 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -41,7 +41,7 @@ enum HlkFm22xNoteType { }; enum HlkFm22xResult { - SUCCESS = 0x00, + SUCCEEDED = 0x00, REJECTED = 0x01, ABORTED = 0x02, FAILED4_CAMERA = 0x04, diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index f46a6b8580..02c6e75146 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -61,7 +61,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); - aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); + aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value, /*extended_range=*/false); } if (pm_1_0_value != -1) { diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..a780854831 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,464 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; + +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(command->pressed_value_2); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Between the two events there is nothing to report, including in the second register. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); + registers.push_back(command->released_value); + registers.push_back(command->released_value_2); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + if (command.clears_target) + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_queued_at_ = millis(); + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..41fd7617e4 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. Each half also carries a second register, which only the lamp command uses. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + bool stop_door(); + bool set_position(float position); + bool toggle_light(); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); + + protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 50deb1acf6..b6a3b8b615 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE +from esphome.platformio.toolchain import copy_ccache_script from .const import KEY_HOST @@ -42,6 +43,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") + # The prefs file finds stored preferences by key, so key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) cg.add_build_flag("-std=gnu++20") cg.add_define("ESPHOME_BOARD", "host") @@ -49,3 +52,9 @@ async def to_code(config): cg.add_platformio_option("platform", "platformio/native") cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) + + +# Called by writer.py +def copy_files() -> None: + copy_ccache_script() diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7e8849b3e1..7274d9de57 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS; + static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); } diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 5f723e0675..b591fa0aab 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -27,6 +27,9 @@ class HostPreferences final : public PreferencesMixin { return true; } + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len) { return this->load(type, data, len); } + bool load(uint32_t key, uint8_t *data, size_t len) { if (len > 255) return false; diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 1760cb9395..84333e7169 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -1,6 +1,6 @@ #include "http_request_arduino.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index c109de8a39..028b9f44a1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,7 +2,7 @@ #include "http_request.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #if defined(USE_RP2) #include diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 871f67a4c8..cc036b12c3 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "i2c_bus_arduino.h" #include diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index ded28dd80c..71e91e770b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include #include "esphome/core/component.h" diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 8e432695a1..4809bf5a92 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -29,6 +29,7 @@ DEPENDENCIES = ["esp32"] MULTI_CONF = True CONF_PDM = "pdm" +CONF_PDM_DSR = "pdm_dsr" CONF_ADC_TYPE = "adc_type" CONF_I2S_DOUT_PIN = "i2s_dout_pin" diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 1392d1d4ec..9c6228087c 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -17,6 +17,7 @@ from .. import ( CONF_LEFT, CONF_MONO, CONF_PDM, + CONF_PDM_DSR, CONF_RIGHT, I2SAudioIn, i2s_audio_component_schema, @@ -38,6 +39,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") +I2S_PDM_DSR = { + 8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S, + 16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S, +} + def _validate_esp32_variant(config): variant = esp32.get_esp32_variant() @@ -111,6 +118,9 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_PDM, default=False): cv.boolean, + cv.Optional(CONF_PDM_DSR, default=8): cv.enum( + I2S_PDM_DSR, int=True + ), } ), }, @@ -142,5 +152,7 @@ async def to_code(config): cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) cg.add(var.set_pdm(config[CONF_PDM])) + if esp32.get_esp32_variant() in PDM_VARIANTS: + cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR])) cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET])) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 66ca32b830..c577ed092e 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -128,7 +128,7 @@ bool I2SAudioMicrophone::start_driver_() { .sample_rate_hz = this->sample_rate_, .clk_src = clk_src, .mclk_multiple = this->mclk_multiple_, - .dn_sample_mode = I2S_PDM_DSR_8S, + .dn_sample_mode = this->pdm_dsr_, }; i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_); @@ -245,7 +245,7 @@ void I2SAudioMicrophone::mic_task(void *params) { while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) { if (this_microphone->data_callbacks_.size() > 0) { samples.resize(bytes_to_read); - size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * pdMS_TO_TICKS(READ_DURATION_MS)); + size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS); samples.resize(bytes_read); if (this_microphone->correct_dc_offset_) { this_microphone->fix_dc_offset_(samples); @@ -318,12 +318,11 @@ void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { } } -size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { +size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { size_t bytes_read = 0; - // i2s_channel_read expects the timeout value in ms, not ticks - esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, pdTICKS_TO_MS(ticks_to_wait)); - if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (ticks_to_wait != 0))) { - // Ignore ESP_ERR_TIMEOUT if ticks_to_wait = 0, as it will read the data on the next call + esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); + if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) { + // Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call if (!this->status_has_warning()) { // Avoid spamming the logs with the error message if its repeated ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); @@ -331,7 +330,7 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w this->status_set_warning(); return 0; } - if ((bytes_read == 0) && (ticks_to_wait > 0)) { + if ((bytes_read == 0) && (timeout_ms > 0)) { this->status_set_warning(); return 0; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 65ad7df1af..37895ac4e7 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -29,6 +29,10 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon void set_pdm(bool pdm) { this->pdm_ = pdm; } +#if SOC_I2S_SUPPORTS_PDM_RX + void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } +#endif + protected: /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. /// @return True if succesful, false otherwise @@ -42,7 +46,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon /// @param data void fix_dc_offset_(std::vector &data); - size_t read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait); + size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. void configure_stream_settings_(); @@ -57,6 +61,9 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; bool pdm_{false}; +#if SOC_I2S_SUPPORTS_PDM_RX + i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; +#endif bool correct_dc_offset_; bool locked_driver_{false}; diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index e175aa2220..5929f2b60a 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -42,4 +42,4 @@ async def setup_improv_core(var: MockObj, config: ConfigType, component: str): cg.add(var.set_next_url(_process_next_url(next_url))) cg.add_define(f"USE_{component.upper()}_NEXT_URL") - cg.add_library("improv/Improv", "1.2.4") + cg.add_library("improv/Improv", "1.2.6") diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 4ee703f363..a191889138 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -16,6 +16,7 @@ void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_ESP32 this->uart_num_ = logger::global_logger->get_uart_num(); + this->uart_selection_ = logger::global_logger->get_uart(); #elif defined(USE_ARDUINO) this->hw_serial_ = logger::global_logger->get_hw_serial(); #endif @@ -30,21 +31,23 @@ void ImprovSerialComponent::setup() { } void ImprovSerialComponent::loop() { - if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { this->last_read_byte_ = 0; this->rx_buffer_.clear(); ESP_LOGV(TAG, "Timeout"); } - auto byte = this->read_byte_(); - while (byte.has_value()) { + while (true) { + auto byte = this->read_byte_(); + if (!byte.has_value()) + break; if (this->parse_improv_serial_byte_(byte.value())) { - this->last_read_byte_ = millis(); + this->last_read_byte_ = now; } else { this->last_read_byte_ = 0; this->rx_buffer_.clear(); } - byte = this->read_byte_(); } if (this->state_ == improv::STATE_PROVISIONING) { @@ -63,53 +66,6 @@ void ImprovSerialComponent::loop() { void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } -optional ImprovSerialComponent::read_byte_() { - optional byte; - uint8_t data = 0; -#ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { - case logger::UART_SELECTION_UART0: - case logger::UART_SELECTION_UART1: -#if defined(USE_ESP32_VARIANT_ESP32) - case logger::UART_SELECTION_UART2: -#endif - if (this->uart_num_ >= 0) { - size_t available; - uart_get_buffered_data_len(this->uart_num_, &available); - if (available) { - uart_read_bytes(this->uart_num_, &data, 1, 0); - byte = data; - } - } - break; -#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) - case logger::UART_SELECTION_USB_CDC: - if (esp_usb_console_available_for_read()) { - esp_usb_console_read_buf((char *) &data, 1); - byte = data; - } - break; -#endif // USE_LOGGER_USB_CDC -#ifdef USE_LOGGER_USB_SERIAL_JTAG - case logger::UART_SELECTION_USB_SERIAL_JTAG: { - if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { - byte = data; - } - break; - } -#endif // USE_LOGGER_USB_SERIAL_JTAG - default: - break; - } -#elif defined(USE_ARDUINO) - if (this->hw_serial_->available()) { - this->hw_serial_->readBytes(&data, 1); - byte = data; - } -#endif - return byte; -} - void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) { // First, set length field this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1; @@ -133,7 +89,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) this->tx_header_[TX_CHECKSUM_IDX] = checksum; #ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { + switch (this->uart_selection_) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if defined(USE_ESP32_VARIANT_ESP32) diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 4df6f6df2d..00c40c4c7e 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/improv_base/improv_base.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/wifi/wifi_component.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" @@ -65,7 +66,52 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv std::vector build_rpc_settings_response_(improv::Command command); std::vector build_version_info_(); - optional read_byte_(); + ESPHOME_ALWAYS_INLINE optional read_byte_() { + optional byte; + uint8_t data = 0; +#ifdef USE_ESP32 + switch (this->uart_selection_) { + case logger::UART_SELECTION_UART0: + case logger::UART_SELECTION_UART1: +#if defined(USE_ESP32_VARIANT_ESP32) + case logger::UART_SELECTION_UART2: +#endif + if (this->uart_num_ >= 0) { + size_t available; + uart_get_buffered_data_len(this->uart_num_, &available); + if (available) { + uart_read_bytes(this->uart_num_, &data, 1, 0); + byte = data; + } + } + break; +#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) + case logger::UART_SELECTION_USB_CDC: + if (esp_usb_console_available_for_read()) { + esp_usb_console_read_buf((char *) &data, 1); + byte = data; + } + break; +#endif +#ifdef USE_LOGGER_USB_SERIAL_JTAG + case logger::UART_SELECTION_USB_SERIAL_JTAG: { + if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { + byte = data; + } + break; + } +#endif + default: + break; + } +#elif defined(USE_ARDUINO) + if (this->hw_serial_->available()) { + this->hw_serial_->readBytes(&data, 1); + byte = data; + } +#endif + return byte; + } void write_data_(const uint8_t *data = nullptr, size_t size = 0); uint8_t tx_header_[TX_BUFFER_SIZE] = { @@ -85,6 +131,7 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv #ifdef USE_ESP32 uart_port_t uart_num_; + logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0}; #elif defined(USE_ARDUINO) Stream *hw_serial_{nullptr}; #endif diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..288b1e5c40 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,12 +154,8 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) 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()); + api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), + &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 4df22aa9de..d360142bcb 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -1,8 +1,6 @@ #include "inkbird_ibsth1_mini.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::inkbird_ibsth1_mini { static const char *const TAG = "inkbird_ibsth1_mini"; @@ -15,7 +13,7 @@ void InkbirdIbstH1Mini::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) { // The below is based on my research and reverse engineering of a single device // It is entirely possible that some of that may be inaccurate or incomplete @@ -32,7 +30,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; } - if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) { + if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) { ESP_LOGVV(TAG, "parse_device(): address is not public"); return false; } @@ -46,7 +44,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return false; } const auto &mnf_data = mnf_datas[0]; - if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { + if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; } @@ -71,7 +69,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic auto external_temperature = NAN; // Read bluetooth data into variable - auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f; + auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f; // Set temperature or external_temperature based on which sensor is in use if (mnf_data.data[2] == 0) { @@ -104,5 +102,3 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 4c90d6d35b..726ea8c5ea 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -29,5 +27,3 @@ class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index b446c9f1e2..2dcdb9a118 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,14 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@fkirill"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini") InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_( - "InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"), cv.Schema( { cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini), @@ -57,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 6df114e93c..c47a80777c 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -7,13 +7,13 @@ namespace esphome::kuntze { static const char *const TAG = "kuntze"; -static const uint8_t CMD_READ_REG = 0x03; static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; // Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8; -void Kuntze::on_modbus_data(const std::vector &data) { +void Kuntze::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); }; this->waiting_ = false; @@ -76,7 +76,7 @@ void Kuntze::loop() { if (this->waiting_ || (this->state_ == 0)) return; this->last_send_ = now; - send(CMD_READ_REG, REGISTER[this->state_ - 1], 2); + this->read_holding_registers(REGISTER[this->state_ - 1], 2); this->waiting_ = true; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 46681843d2..28c8089748 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -4,6 +4,8 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#include + namespace esphome::kuntze { class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { @@ -19,7 +21,7 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index a0cce36d16..061846f1f1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f722f938ae..a52402c2ea 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ce58cedf11..ae13900e7a 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -21,7 +22,6 @@ DEPENDENCIES = ["ld2450"] CONF_MOVING_TARGET_COUNT = "moving_target_count" CONF_STILL_TARGET_COUNT = "still_target_count" -CONF_TARGET_COUNT = "target_count" ICON_ACCOUNT_GROUP = "mdi:account-group" ICON_ACCOUNT_SWITCH = "mdi:account-switch" diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py new file mode 100644 index 0000000000..99f2ead3bb --- /dev/null +++ b/esphome/components/ld6002b/__init__.py @@ -0,0 +1,74 @@ +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_WAKEUP_PIN +from esphome.types import ConfigType + +from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE + +CODEOWNERS = ["@hepter"] +DEPENDENCIES = ["uart"] +MULTI_CONF = True + +ld6002b_ns = cg.esphome_ns.namespace("ld6002b") +LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) + + +def _validate_wakeup_options(config: ConfigType) -> ConfigType: + """Reject wake options that would silently do nothing. + + Runs before the schema so the defaults for the keys below have not been + filled in yet and an explicit user value is still distinguishable from one. + """ + if not isinstance(config, dict): + return config + if CONF_WAKEUP_PIN in config: + return config + for key in (CONF_AUTO_WAKE, CONF_WAKEUP_PULSE): + if key in config: + raise cv.Invalid( + f"'{key}' requires '{CONF_WAKEUP_PIN}' to be configured", path=[key] + ) + return config + + +CONFIG_SCHEMA = cv.All( + _validate_wakeup_options, + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD6002BComponent), + cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_WAKEUP_PULSE, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ld6002b", + baud_rate=115200, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): + pin = await cg.gpio_pin_expression(wakeup_pin_config) + cg.add(var.set_wakeup_pin(pin)) + + cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) + + cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE])) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py new file mode 100644 index 0000000000..63f7b40c23 --- /dev/null +++ b/esphome/components/ld6002b/binary_sensor.py @@ -0,0 +1,54 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY + +from . import LD6002BComponent +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_config := config.get(CONF_TARGET): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_presence_binary_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py new file mode 100644 index 0000000000..c327c331c6 --- /dev/null +++ b/esphome/components/ld6002b/button/__init__.py @@ -0,0 +1,139 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_ID, + CONF_WAKEUP_PIN, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, + CONF_GET_DELAY, + CONF_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME, + CONF_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE, + CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, + CONF_RESET_UNATTENDED, + CONF_WAKE, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button) +ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_DELAY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_SENSITIVITY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_Z_RANGE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_INSTALLATION): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_RESET_UNATTENDED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_WAKE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +def final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + + if config.get(CONF_WAKE): + hub_path = full_config.get_path_for_id(hub_id) + hub_config = full_config.get_config_for_path(hub_path[:-1]) + if hub_config.get(CONF_WAKEUP_PIN) is None: + raise cv.Invalid( + f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", + path=[CONF_WAKE], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + +BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, + CONF_GET_DELAY: ButtonType.GET_DELAY, + CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE, + CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME, + CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED, + CONF_WAKE: ButtonType.WAKE, +} + + +async def to_code(config): + for key, button_type in BUTTON_MAP.items(): + if button_config := config.get(key): + b = cg.new_Pvariable(button_config[CONF_ID], button_type) + await button.register_button(b, button_config) + await cg.register_parented(b, config[CONF_LD6002B_ID]) diff --git a/esphome/components/ld6002b/button/ld6002b_button.cpp b/esphome/components/ld6002b/button/ld6002b_button.cpp new file mode 100644 index 0000000000..fb398a9a59 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.cpp @@ -0,0 +1,7 @@ +#include "ld6002b_button.h" + +namespace esphome::ld6002b { + +void LD6002BButton::press_action() { this->parent_->press_button(this->type_); } + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/button/ld6002b_button.h b/esphome/components/ld6002b/button/ld6002b_button.h new file mode 100644 index 0000000000..c222143453 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BButton : public button::Button, public Parented { + public: + explicit LD6002BButton(ButtonType type) : type_(type) {} + + protected: + void press_action() override; + + ButtonType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py new file mode 100644 index 0000000000..b7c3f54a6f --- /dev/null +++ b/esphome/components/ld6002b/const.py @@ -0,0 +1,41 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" +CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" +CONF_CLUSTER_ID = "cluster_id" +CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" +CONF_GET_DELAY = "get_delay" +CONF_GET_INSTALLATION = "get_installation" +CONF_GET_LOW_POWER_MODE = "get_low_power_mode" +CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" +CONF_GET_SENSITIVITY = "get_sensitivity" +CONF_GET_TRIGGER_SPEED = "get_trigger_speed" +CONF_GET_Z_RANGE = "get_z_range" +CONF_HOLD_DELAY = "hold_delay" +CONF_INSTALLATION_MODE = "installation_mode" +CONF_LD6002B_ID = "ld6002b_id" +CONF_LOW_POWER = "low_power" +CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" +CONF_OTA_VERSION = "ota_version" +CONF_POINT_CLOUD = "point_cloud" +CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" +CONF_RESET_UNATTENDED = "reset_unattended" +CONF_TARGET_DISPLAY = "target_display" +CONF_TRIGGER_SPEED = "trigger_speed" +CONF_WAKE = "wake" +CONF_WAKEUP_PULSE = "wakeup_pulse" +CONF_WORK_MODE = "work_mode" +CONF_Z = "z" +CONF_Z_MAX = "z_max" +CONF_Z_MIN = "z_min" + +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 +MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp new file mode 100644 index 0000000000..ca6b9b9552 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -0,0 +1,1879 @@ +#include "ld6002b.h" +#include "esphome/core/log.h" +#include +#include +#include +#include +#include + +namespace esphome::ld6002b { + +static const char *const TAG = "ld6002b"; + +static constexpr uint8_t TF_SOF = 0x01; +static constexpr uint32_t SETUP_DELAY_MS = 100; + +// Command/message types +static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; +static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; + +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; +static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; + +// Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; +static constexpr uint32_t CMD_GET_DELAY = 0x05; +static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; +static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; +static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; +static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; +static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; +static constexpr uint32_t CMD_GET_TRIGGER = 0x11; +static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_INSTALL_TOP = 0x13; +static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; +static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; +static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; +static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; +static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; +static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; + +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display + +static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE +static const char *control_command_name(uint32_t command) { + switch (command) { + case CMD_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; + case CMD_GET_DELAY: + return "get_delay"; + case CMD_POINT_CLOUD_ON: + return "point_cloud_on"; + case CMD_POINT_CLOUD_OFF: + return "point_cloud_off"; + case CMD_TARGET_DISPLAY_ON: + return "target_display_on"; + case CMD_TARGET_DISPLAY_OFF: + return "target_display_off"; + case CMD_SENSITIVITY_LOW: + return "sensitivity_low"; + case CMD_SENSITIVITY_MEDIUM: + return "sensitivity_medium"; + case CMD_SENSITIVITY_HIGH: + return "sensitivity_high"; + case CMD_GET_SENSITIVITY: + return "get_sensitivity"; + case CMD_TRIGGER_SLOW: + return "trigger_slow"; + case CMD_TRIGGER_MEDIUM: + return "trigger_medium"; + case CMD_TRIGGER_FAST: + return "trigger_fast"; + case CMD_GET_TRIGGER: + return "get_trigger"; + case CMD_GET_Z_RANGE: + return "get_z_range"; + case CMD_INSTALL_TOP: + return "install_top"; + case CMD_INSTALL_SIDE: + return "install_side"; + case CMD_GET_INSTALLATION: + return "get_installation"; + case CMD_LOW_POWER_ON: + return "low_power_on"; + case CMD_LOW_POWER_OFF: + return "low_power_off"; + case CMD_GET_LOW_POWER: + return "get_low_power"; + case CMD_GET_LOW_POWER_SLEEP: + return "get_low_power_sleep"; + case CMD_RESET_UNATTENDED: + return "reset_unattended"; + default: + return "unknown"; + } +} + +static const char *frame_type_name(uint16_t type) { + switch (type) { + case TYPE_CONTROL: + return "control"; + case TYPE_SET_AREA: + return "set_area"; + case TYPE_SET_HOLD_DELAY: + return "set_hold_delay"; + case TYPE_SET_Z_RANGE: + return "set_z_range"; + case TYPE_SET_LOW_POWER_SLEEP: + return "set_low_power_sleep"; + case TYPE_REPORT_TARGET: + return "report_target"; + case TYPE_REPORT_POINT_CLOUD: + return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; + case TYPE_REPORT_DELAY: + return "report_delay"; + case TYPE_REPORT_SENSITIVITY: + return "report_sensitivity"; + case TYPE_REPORT_TRIGGER: + return "report_trigger"; + case TYPE_REPORT_Z_RANGE: + return "report_z_range"; + case TYPE_REPORT_INSTALLATION: + return "report_installation"; + case TYPE_REPORT_LOW_POWER: + return "report_low_power"; + case TYPE_REPORT_LOW_POWER_SLEEP: + return "report_low_power_sleep"; + case TYPE_REPORT_WORK_MODE: + return "report_work_mode"; + case TYPE_QUERY_VERSION: + return "query_version"; + default: + return "unknown"; + } +} + +static bool is_expected_control_report(uint32_t command, uint16_t type) { + switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; + case CMD_GET_DELAY: + return type == TYPE_REPORT_DELAY; + case CMD_GET_SENSITIVITY: + return type == TYPE_REPORT_SENSITIVITY; + case CMD_GET_TRIGGER: + return type == TYPE_REPORT_TRIGGER; + case CMD_GET_Z_RANGE: + return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_INSTALLATION: + return type == TYPE_REPORT_INSTALLATION; + case CMD_GET_LOW_POWER: + case CMD_LOW_POWER_ON: + case CMD_LOW_POWER_OFF: + return type == TYPE_REPORT_LOW_POWER; + case CMD_GET_LOW_POWER_SLEEP: + return type == TYPE_REPORT_LOW_POWER_SLEEP; + default: + return false; + } +} +#endif + +uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } + +uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { + return static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); +} + +int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + int32_t value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +float LD6002BComponent::read_f32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + float value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { + data[0] = value & 0xFF; + data[1] = (value >> 8) & 0xFF; + data[2] = (value >> 16) & 0xFF; + data[3] = (value >> 24) & 0xFF; +} + +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + +void LD6002BComponent::write_f32_le(uint8_t *data, float value) { + uint32_t raw; + std::memcpy(&raw, &value, sizeof(raw)); + write_u32_le(data, raw); +} + +void LD6002BComponent::setup() { + // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. + bool point_cloud_configured = false; +#ifdef USE_SENSOR + point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr; +#endif +#ifdef USE_SWITCH + point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; +#endif + this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; + // One allocation for the component lifetime; the parser reuses it for the header and every payload. + RAMAllocator allocator; + this->data_buf_ = allocator.allocate(this->max_data_len_); + if (this->data_buf_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + if (this->wakeup_pin_ != nullptr) { + this->wakeup_pin_->setup(); + this->wakeup_pin_->digital_write(true); + } + + this->set_timeout(SETUP_DELAY_MS, [this]() { + bool want_target_stream = false; +#ifdef USE_SENSOR + want_target_stream = want_target_stream || this->target_count_sensor_ != nullptr; + if (!want_target_stream) { + for (const auto &target : this->targets_) { + if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr || + target.cluster_id != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif +#ifdef USE_BINARY_SENSOR + want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; + if (!want_target_stream) { + for (auto *sensor : this->target_presence_) { + if (sensor != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif +#ifdef USE_TEXT_SENSOR + // The work mode fallback reads presence off this stream, so it counts as a + // consumer of it here. This only feeds the automatic branch below: with a + // target_display switch configured that switch still decides, and the + // fallback weighs no presence at all while the stream is off. + want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr; +#endif + bool target_display_controlled = false; +#ifdef USE_SWITCH + if (this->target_display_switch_ != nullptr) { + target_display_controlled = true; + // Nothing reports this switch back, so its restored state is the only state + // there is. Restoring through the switch keeps its inversion in the path: + // the restored value is logical, and turn_on()/turn_off() are what turn it + // into the raw command, the published state and the stream flag. + const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); + if (state) { + this->target_display_switch_->turn_on(); + } else { + this->target_display_switch_->turn_off(); + } + } +#endif + if (!target_display_controlled) { + // No switch: the stream follows its consumers. With none, nothing is sent + // and the module's own default stands -- but the reports are gated out + // regardless, because there is nothing configured for them to feed. + this->target_display_enabled_ = want_target_stream; + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } + } + + bool point_cloud_controlled = false; +#ifdef USE_SWITCH + if (this->point_cloud_switch_ != nullptr) { + point_cloud_controlled = true; + // The switch owns the stream, so it is also what applies the restored state: + // driving it rather than the module keeps the entity's inversion in the path. + const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->point_cloud_switch_->turn_on(); + } else { + this->point_cloud_switch_->turn_off(); + } + } +#endif + if (!point_cloud_controlled) { + // No switch: the stream follows the sensor that reads it, which is also what + // the frame buffer above was sized for. + bool want_point_cloud = false; +#ifdef USE_SENSOR + want_point_cloud = this->point_count_sensor_ != nullptr; +#endif + this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); + this->point_cloud_enabled_ = want_point_cloud; + } + +#ifdef USE_SELECT + if (this->sensitivity_select_ != nullptr) { + this->send_control_command_(CMD_GET_SENSITIVITY); + } + if (this->trigger_speed_select_ != nullptr) { + this->send_control_command_(CMD_GET_TRIGGER); + } + if (this->installation_select_ != nullptr) { + this->send_control_command_(CMD_GET_INSTALLATION); + } +#endif +#ifdef USE_NUMBER + if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { + this->send_control_command_(CMD_GET_Z_RANGE); + } + if (this->low_power_sleep_number_ != nullptr) { + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + } + if (this->hold_delay_number_ != nullptr) { + this->send_control_command_(CMD_GET_DELAY); + } +#endif +#ifdef USE_SWITCH + bool want_low_power = this->low_power_switch_ != nullptr; + if (want_low_power) { + // The module reports this one back, so the query below confirms what it took. + // Driving the switch applies its inversion; it also marks the restored value + // as reported, so the work mode fallback runs on that until the query lands. + const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->low_power_switch_->turn_on(); + } else { + this->low_power_switch_->turn_off(); + } + } +#else + bool want_low_power = false; +#endif +#ifdef USE_TEXT_SENSOR + want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_low_power) { + this->send_control_command_(CMD_GET_LOW_POWER); + } + + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); + this->init_version_pref_(); + +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ != nullptr) { + this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); + } +#endif + }); +} + +void LD6002BComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "HLK-LD6002B:\n" + " Auto wake: %s\n" + " Max data length: %u", + this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); + if (this->wakeup_pin_ != nullptr) { + LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); + LOG_SENSOR(" ", "Point Count", this->point_count_sensor_); + for (auto &target : this->targets_) { + LOG_SENSOR(" ", "Target X", target.x); + LOG_SENSOR(" ", "Target Y", target.y); + LOG_SENSOR(" ", "Target Z", target.z); + LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); + LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); + } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); + } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); + LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); +#endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); + LOG_NUMBER(" ", "Z Min", this->z_min_number_); + LOG_NUMBER(" ", "Z Max", this->z_max_number_); + LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Low Power", this->low_power_switch_); + LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); + LOG_SWITCH(" ", "Target Display", this->target_display_switch_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); + LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); + LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); +#endif +} + +void LD6002BComponent::loop() { + while (this->available()) { + uint8_t byte = this->read(); + this->parse_byte_(byte); + } + this->process_command_queue_(); +} + +void LD6002BComponent::reset_parser_() { + this->parse_state_ = ParseState::SOF; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->data_len_ = 0; + this->data_pos_ = 0; + this->data_xor_ = 0; + this->discard_remaining_ = 0; + this->frame_oversize_ = false; +} + +void LD6002BComponent::parse_byte_(uint8_t byte) { + switch (this->parse_state_) { + case ParseState::DISCARD: + // discard_remaining_ is unsigned: an unguarded decrement at zero would swallow 4 GB of stream. + if (this->discard_remaining_ > 0) { + this->discard_remaining_--; + } + if (this->discard_remaining_ == 0) { + this->reset_parser_(); + } + return; + case ParseState::SOF: + if (byte != TF_SOF) + return; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->header_xor_ ^= byte; + this->parse_state_ = ParseState::HEADER; + return; + case ParseState::HEADER: + if (this->header_pos_ < 6) { + this->data_buf_[this->header_pos_] = byte; + this->header_xor_ ^= byte; + this->header_pos_++; + if (this->header_pos_ == 6) { + this->frame_id_ = read_u16_be(this->data_buf_); + this->data_len_ = read_u16_be(this->data_buf_ + 2); + this->frame_type_ = read_u16_be(this->data_buf_ + 4); + // The length is only trustworthy once the header checksum has been verified, so just + // remember that the frame is oversized and let the HCK state act on it. + this->frame_oversize_ = this->data_len_ > this->max_data_len_; + this->parse_state_ = ParseState::HCK; + } + } + return; + case ParseState::HCK: { + uint8_t expected = static_cast(~this->header_xor_); + if (byte != expected) { + ESP_LOGV(TAG, "Header checksum mismatch"); + this->reset_parser_(); + return; + } + if (this->frame_oversize_) { + ESP_LOGW(TAG, "Frame too large: %u", this->data_len_); + // The header is verified, so the length can be trusted: skip the payload and its checksum. + this->discard_remaining_ = static_cast(this->data_len_) + 1; + this->parse_state_ = ParseState::DISCARD; + return; + } + if (this->data_len_ == 0) { + this->handle_frame_(this->frame_type_, nullptr, 0); + this->reset_parser_(); + } else { + this->data_pos_ = 0; + this->data_xor_ = 0; + this->parse_state_ = ParseState::DATA; + } + return; + } + case ParseState::DATA: + this->data_buf_[this->data_pos_++] = byte; + this->data_xor_ ^= byte; + if (this->data_pos_ >= this->data_len_) { + this->parse_state_ = ParseState::DCK; + } + return; + case ParseState::DCK: { + uint8_t expected = static_cast(~this->data_xor_); + if (byte == expected) { + this->handle_frame_(this->frame_type_, this->data_buf_, this->data_len_); + } else { + ESP_LOGV(TAG, "Data checksum mismatch"); + } + this->reset_parser_(); + return; + } + } +} + +void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) { + this->last_traffic_ms_ = millis(); + if (this->stale_ack_count_ > 0 && millis() - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) { + this->stale_ack_count_ = 0; + } + // ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command. + if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) { + this->stale_ack_count_--; + ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type, + this->frame_id_); + return; + } + if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { + ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; + // This settles one expected reply; the rest stay owed and become the debt for the next command. + this->send_generation_++; + this->stale_ack_type_ = type; + this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast(this->acks_expected_ - 1) : 0; + this->stale_ack_ms_ = millis(); + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } + return; + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + const uint32_t active_control_command = + (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { + ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, + control_command_name(active_control_command), active_control_command); + } +#endif + + switch (type) { + case TYPE_REPORT_TARGET: + this->handle_target_report_(data, len); + break; + case TYPE_REPORT_POINT_CLOUD: + this->handle_point_cloud_(data, len); + break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; + case TYPE_REPORT_DELAY: + this->handle_delay_report_(data, len); + break; + case TYPE_REPORT_SENSITIVITY: + this->handle_sensitivity_report_(data, len); + break; + case TYPE_REPORT_TRIGGER: + this->handle_trigger_speed_report_(data, len); + break; + case TYPE_REPORT_Z_RANGE: + this->handle_z_range_report_(data, len); + break; + case TYPE_REPORT_INSTALLATION: + this->handle_installation_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER: + this->handle_low_power_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER_SLEEP: + this->handle_low_power_sleep_report_(data, len); + break; + case TYPE_REPORT_WORK_MODE: + this->handle_work_mode_report_(data, len); + break; + case TYPE_QUERY_VERSION: + this->handle_version_report_(data, len); + break; + default: + break; + } +} + +void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + // The module stops streaming when it acts on the command, not when the command + // is queued, so trailing frames after an off must not repopulate what + // set_switch_state just cleared. + if (!this->target_display_enabled_) { + return; + } + if (len < 4) + return; + + uint32_t target_num = read_u32_le(data); + uint16_t available = (len - 4) / TARGET_DATA_LEN; + // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". + const uint32_t reported = std::min(target_num, available); + uint8_t count = static_cast(std::min(reported, MAX_TARGETS)); + + // The module re-sorts its array by cluster id, so slots key on the id to track the person. + std::array wire_cluster{}; + std::array wire_placed{}; + std::array slot_seen{}; + std::array slot_wire{}; + for (uint8_t i = 0; i < count; i++) { + uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; + wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + } + for (uint8_t i = 0; i < count; i++) { + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { + slot_seen[s] = true; + wire_placed[i] = true; + slot_wire[s] = i; + break; + } + } + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!slot_seen[s]) { + this->slot_occupied_[s] = false; + } + } + for (uint8_t i = 0; i < count; i++) { + if (wire_placed[i]) { + continue; + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!this->slot_occupied_[s]) { + this->slot_occupied_[s] = true; + this->slot_cluster_[s] = wire_cluster[i]; + slot_wire[s] = i; + break; + } + } + } + +#ifdef USE_SENSOR + if (this->target_count_sensor_ != nullptr) { + if (reported != this->last_target_count_) { + this->target_count_sensor_->publish_state(reported); + this->last_target_count_ = reported; + } + } +#endif + + this->target_presence_any_ = (reported > 0); +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); + + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + bool has_target = this->slot_occupied_[i]; + if (has_target) { +#ifdef USE_SENSOR + uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN); + float x = read_f32_le(data + offset + 0); + float y = read_f32_le(data + offset + 4); + float z = read_f32_le(data + offset + 8); + int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t cluster_id = this->slot_cluster_[i]; + TargetSensors &target = this->targets_[i]; + if (target.x != nullptr) { + target.x->publish_state(x); + } + if (target.y != nullptr) { + target.y->publish_state(y); + } + if (target.z != nullptr) { + target.z->publish_state(z); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(static_cast(dop_idx)); + } + if (target.cluster_id != nullptr) { + if (!this->last_cluster_id_valid_[i] || cluster_id != this->last_cluster_id_[i]) { + target.cluster_id->publish_state(static_cast(cluster_id)); + this->last_cluster_id_[i] = cluster_id; + this->last_cluster_id_valid_[i] = true; + } + } +#endif + } else { +#ifdef USE_SENSOR + this->clear_target_slot_(i); +#endif + } +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + // publish_state() already skips unchanged states, no manual de-dup needed. + this->target_presence_[i]->publish_state(has_target); + } +#endif +#ifdef USE_SENSOR + this->last_target_presence_[i] = has_target; +#endif + } +} + +void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { + // Same window as the target stream: a frame already in flight must not put the + // count back after the switch cleared it. + if (!this->point_cloud_enabled_) { + return; + } + if (len < 4) + return; + +#ifdef USE_SENSOR + uint32_t point_num = read_u32_le(data); + if (this->point_count_sensor_ != nullptr) { + if (point_num != this->last_point_count_) { + this->point_count_sensor_->publish_state(point_num); + this->last_point_count_ = point_num; + } + } +#endif +} + +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + +void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t delay = read_u32_le(data); + this->publish_number_clamped_(this->hold_delay_number_, delay); +#endif +} + +void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->sensitivity_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->sensitivity_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->trigger_speed_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->trigger_speed_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { + if (len < 8) + return; + float z_min = read_f32_le(data); + float z_max = read_f32_le(data + 4); + this->z_min_ = z_min; + this->z_max_ = z_max; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->z_min_number_, z_min); + this->publish_number_clamped_(this->z_max_number_, z_max); +#endif +} + +void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->installation_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 1) { + this->installation_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + bool enabled = data[0] != 0; + this->low_power_enabled_ = enabled; + this->low_power_reported_ = true; +#ifdef USE_SWITCH + if (this->low_power_switch_ != nullptr) { + this->low_power_switch_->publish_state(enabled); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t sleep_ms = read_u32_le(data); + this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); +#endif +} + +void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. + const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ != nullptr) { + this->work_mode_reported_ = true; + this->publish_work_mode_(low_power); + } +#endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } +} + +void LD6002BComponent::update_work_mode_fallback_() { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) { + return; + } + if (!this->low_power_reported_) { + return; + } + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; + this->publish_work_mode_(this->low_power_enabled_ && !presence); +#endif +} + +void LD6002BComponent::publish_work_mode_(bool low_power) { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr) { + return; + } + if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) { + return; + } + this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); + this->last_work_mode_valid_ = true; + this->last_work_mode_low_power_ = low_power; +#endif +} + +#ifdef USE_NUMBER +void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { + if (number == nullptr) + return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } + const float min_value = number->traits.get_min_value(); + const float max_value = number->traits.get_max_value(); + // Outside the declared range the user cannot write the value back, so publish + // what they can reach and say what the module actually sent. + if (value < min_value || value > max_value) { + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value, + max_value); + value = std::clamp(value, min_value, max_value); + } + number->publish_state(value); +} +#endif + +void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) + return; + uint8_t project = data[0]; + uint8_t major = data[1]; + uint8_t minor = data[2]; + uint8_t patch = data[3]; + char buf[32]; + if (project == 0) { + std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); + } else { + std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); + } + this->ota_version_text_sensor_->publish_state(buf); + this->save_version_pref_(buf); +#endif +} + +bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + return false; + } + if (this->cmd_count_ >= CMD_QUEUE_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); + return false; + } + + PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; + cmd.type = type; + cmd.len = len; + if (len > 0 && data != nullptr) { + std::memcpy(cmd.data.data(), data, len); + } + + this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_++; + this->process_command_queue_(); + return true; +} + +void LD6002BComponent::process_command_queue_() { + uint32_t now = millis(); + if (this->command_active_) { + // A sleeping module consumes the opening attempt as its wake-up instead of answering it. + const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS; + if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { + const uint32_t active_control_command = + (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (this->retries_left_ > 0) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + if (active_control_command != 0) { + ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining", + control_command_name(active_control_command), active_control_command, this->retries_left_); + } else { + // Writes without a control subcommand (hold delay, z-range) had no retry trace at all. + ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type, + this->retries_left_); + } +#endif + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); + this->retries_left_--; + } else { + if (active_control_command != 0) { + ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type, + active_control_command); + } else { + ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); + } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } + // A reply may still be in flight for the attempt we just gave up on, so carry one over as + // debt rather than clearing the ledger, or that late ACK would retire the successor. Only + // one: reaching this point means nothing was answered at all, so the older attempts are + // speculative, and carrying them would swallow the successor's own replies. + const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) + + (this->acks_expected_ > 0 ? 1 : 0); + this->stale_ack_type_ = this->active_command_.type; + this->stale_ack_count_ = static_cast(std::min(owed, 255)); + this->stale_ack_ms_ = now; + this->send_generation_++; + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + } + return; + } + + if (this->cmd_count_ == 0) + return; + + this->active_command_ = this->cmd_queue_[this->cmd_head_]; + this->cmd_head_ = (this->cmd_head_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_--; + + this->send_generation_++; + this->retries_left_ = CMD_MAX_RETRIES; + this->command_active_ = true; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->attempts_sent_ = 0; + this->acks_expected_ = 0; + if (this->stale_ack_type_ != this->active_command_.type) { + this->stale_ack_count_ = 0; + } + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); +} + +void LD6002BComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) { + this->send_command_internal_(type, data, len, true); +} + +void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + if (track) { + // Release the slot: an unwritten command is never acked and never times out. + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + return; + } + + // Anonymous timeouts never replace each other; with a pulse already pending the module is waking anyway. + if (this->auto_wake_ && this->wakeup_pin_ != nullptr && !this->wake_pulse_pending_) { + // Snapshot the payload: the deferred write must not depend on state a completing command changes. + if (len > 0 && data != nullptr) { + std::memcpy(this->wake_scratch_.data(), data, len); + } + // A button pulse must not raise the pin in the middle of this one. + this->cancel_timeout(WAKE_BUTTON_TIMEOUT); + this->wake_pulse_pending_ = true; + this->wakeup_pin_->digital_write(false); + const uint8_t generation = this->send_generation_; + this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { + this->wakeup_pin_->digital_write(true); + this->wake_pulse_pending_ = false; + // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been + // retired must not transmit: the frame would land after its successor and be booked to it. + if (track && generation != this->send_generation_) { + return; + } + this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); + }); + return; + } + + this->write_frame_(type, data, len, track); +} + +void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + uint16_t frame_id = this->next_frame_id_++ & 0x7FFF; + frame_id |= 0x8000; + + uint8_t header_xor = 0; + auto write_header = [&](uint8_t b) { + this->write_byte(b); + header_xor ^= b; + }; + + write_header(TF_SOF); + write_header((frame_id >> 8) & 0xFF); + write_header(frame_id & 0xFF); + write_header((len >> 8) & 0xFF); + write_header(len & 0xFF); + write_header((type >> 8) & 0xFF); + write_header(type & 0xFF); + + this->write_byte(static_cast(~header_xor)); + + if (len > 0 && data != nullptr) { + uint8_t data_xor = 0; + for (uint8_t i = 0; i < len; i++) { + this->write_byte(data[i]); + data_xor ^= data[i]; + } + this->write_byte(static_cast(~data_xor)); + } + const uint32_t now = millis(); + if (track) { + // A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered. + if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) { + this->acks_expected_++; + } + this->last_send_ms_ = now; + this->command_sent_ = true; + this->attempts_sent_++; + } + this->last_traffic_ms_ = now; +} + +bool LD6002BComponent::send_control_command_(uint32_t command) { + uint8_t data[4]; + write_u32_le(data, command); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); +} + +void LD6002BComponent::send_z_range_() { + // One frame carries both bounds, so half a range cannot be written. + if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { + ESP_LOGW(TAG, "Z range not written, other bound unknown"); + return; + } + // Both bounds are known and crossed; the frame has no way to say that. + if (this->z_min_ > this->z_max_) { + ESP_LOGW(TAG, "Z range not written, min above max"); + return; + } + uint8_t data[8]; + write_f32_le(data, this->z_min_); + write_f32_le(data + 4, this->z_max_); + this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); +} + +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + +void LD6002BComponent::wake_() { + // A command's own pulse raises the pin and writes after it, so ride along instead of + // claiming the flag: claiming it would send that command down the immediate-write path + // with the pin still low. + if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) + return; + this->wakeup_pin_->digital_write(false); + this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); +} + +void LD6002BComponent::set_number_value(NumberType type, float value) { + switch (type) { + case NumberType::HOLD_DELAY: { + uint32_t delay = static_cast(value); + uint8_t data[4]; + write_u32_le(data, delay); + this->queue_command_(TYPE_SET_HOLD_DELAY, data, sizeof(data)); + break; + } + case NumberType::Z_MIN: + this->z_min_ = value; + this->send_z_range_(); + break; + case NumberType::Z_MAX: + this->z_max_ = value; + this->send_z_range_(); + break; + case NumberType::LOW_POWER_SLEEP: { + uint32_t sleep_ms = static_cast(value); + uint8_t data[4]; + write_u32_le(data, sleep_ms); + this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); + break; + } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; + } +} + +void LD6002BComponent::set_select_value(SelectType type, size_t index) { + switch (type) { + case SelectType::SENSITIVITY: + if (index == 0) { + this->send_control_command_(CMD_SENSITIVITY_LOW); + } else if (index == 1) { + this->send_control_command_(CMD_SENSITIVITY_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_SENSITIVITY_HIGH); + } + break; + case SelectType::TRIGGER_SPEED: + if (index == 0) { + this->send_control_command_(CMD_TRIGGER_SLOW); + } else if (index == 1) { + this->send_control_command_(CMD_TRIGGER_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_TRIGGER_FAST); + } + break; + case SelectType::INSTALLATION_MODE: + if (index == 0) { + this->send_control_command_(CMD_INSTALL_TOP); + } else if (index == 1) { + this->send_control_command_(CMD_INSTALL_SIDE); + } + break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; + } +} + +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + +void LD6002BComponent::init_version_pref_() { +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) { + return; + } + this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); + this->version_pref_initialized_ = true; + + VersionPref pref{}; + if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { + pref.value[sizeof(pref.value) - 1] = '\0'; + this->ota_version_text_sensor_->publish_state(pref.value); + } +#endif +} + +void LD6002BComponent::save_version_pref_(const char *value) { +#ifdef USE_TEXT_SENSOR + if (!this->version_pref_initialized_) { + return; + } + VersionPref pref{}; + std::strncpy(pref.value, value, sizeof(pref.value) - 1); + pref.value[sizeof(pref.value) - 1] = '\0'; + this->version_pref_.save(&pref); +#endif +} + +#ifdef USE_SENSOR +void LD6002BComponent::clear_target_slot_(uint8_t index) { + if (!this->last_target_presence_[index]) { + return; + } + TargetSensors &target = this->targets_[index]; + if (target.x != nullptr) { + target.x->publish_state(NAN); + } + if (target.y != nullptr) { + target.y->publish_state(NAN); + } + if (target.z != nullptr) { + target.z->publish_state(NAN); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(NAN); + } + if (target.cluster_id != nullptr) { + target.cluster_id->publish_state(NAN); + } + // The slot is free: the next person's id is new even when it repeats this one. + this->last_cluster_id_valid_[index] = false; +} +#endif + +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif +} + +void LD6002BComponent::clear_target_state_() { + // Nothing corrects any of this until the stream comes back. The slot table goes + // with it: slots key on cluster ids, which only track a person while reports are + // arriving, and the room can empty and refill across the gap -- so the next + // report starts from an empty table and fills slots in wire order, rather than + // handing one back to whoever last held that id. + for (uint8_t i = 0; i < MAX_TARGETS; i++) { +#ifdef USE_SENSOR + this->clear_target_slot_(i); + this->last_target_presence_[i] = false; +#endif + if (this->slot_occupied_[i]) { + this->slot_occupied_[i] = false; +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + this->target_presence_[i]->publish_state(false); + } +#endif + } + } +#ifdef USE_SENSOR + if (this->last_target_count_ != 0xFFFFFFFF) { + if (this->target_count_sensor_ != nullptr) { + this->target_count_sensor_->publish_state(NAN); + } + this->last_target_count_ = 0xFFFFFFFF; + } +#endif + if (this->target_presence_any_) { + this->target_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); + } +} + +void LD6002BComponent::set_switch_state(SwitchType type, bool state) { + switch (type) { + case SwitchType::LOW_POWER: + this->low_power_enabled_ = state; + this->low_power_reported_ = true; + this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF); + this->update_work_mode_fallback_(); + break; + case SwitchType::POINT_CLOUD: + this->point_cloud_enabled_ = state; + this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); +#ifdef USE_SENSOR + // The count only moves while the stream runs, so the last one would stand as + // a live reading. The dedup sentinel is cleared with it: the same count is + // new again when the stream comes back. + if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) { + this->point_count_sensor_->publish_state(NAN); + this->last_point_count_ = 0xFFFFFFFF; + } +#endif + break; + case SwitchType::TARGET_DISPLAY: + this->target_display_enabled_ = state; + this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF); + if (!state) { + // Every target entity is fed by the reports this just stopped. + this->clear_target_state_(); + } + break; + } +} + +void LD6002BComponent::press_button(ButtonType type) { + switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_DELAY: + this->send_control_command_(CMD_GET_DELAY); + break; + case ButtonType::GET_SENSITIVITY: + this->send_control_command_(CMD_GET_SENSITIVITY); + break; + case ButtonType::GET_TRIGGER_SPEED: + this->send_control_command_(CMD_GET_TRIGGER); + break; + case ButtonType::GET_Z_RANGE: + this->send_control_command_(CMD_GET_Z_RANGE); + break; + case ButtonType::GET_INSTALLATION: + this->send_control_command_(CMD_GET_INSTALLATION); + break; + case ButtonType::GET_LOW_POWER_MODE: + this->send_control_command_(CMD_GET_LOW_POWER); + break; + case ButtonType::GET_LOW_POWER_SLEEP_TIME: + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + break; + case ButtonType::RESET_UNATTENDED: + this->send_control_command_(CMD_RESET_UNATTENDED); + break; + case ButtonType::WAKE: + this->wake_(); + break; + } +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h new file mode 100644 index 0000000000..bea3804312 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.h @@ -0,0 +1,503 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#include "esphome/core/gpio.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#include +#include + +namespace esphome::ld6002b { + +static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; +static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; +static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; +// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. +static constexpr size_t CMD_MAX_DATA_LEN = 28; + +enum class NumberType : uint8_t { + HOLD_DELAY, + Z_MIN, + Z_MAX, + LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, +}; + +enum class SelectType : uint8_t { + SENSITIVITY, + TRIGGER_SPEED, + INSTALLATION_MODE, + AREA_ID, +}; + +enum class SwitchType : uint8_t { + LOW_POWER, + POINT_CLOUD, + TARGET_DISPLAY, +}; + +enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, + GET_DELAY, + GET_SENSITIVITY, + GET_TRIGGER_SPEED, + GET_Z_RANGE, + GET_INSTALLATION, + GET_LOW_POWER_MODE, + GET_LOW_POWER_SLEEP_TIME, + RESET_UNATTENDED, + WAKE, +}; + +#ifdef USE_SENSOR +struct TargetSensors { + sensor::Sensor *x{nullptr}; + sensor::Sensor *y{nullptr}; + sensor::Sensor *z{nullptr}; + sensor::Sensor *dop_idx{nullptr}; + sensor::Sensor *cluster_id{nullptr}; +}; + +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; +#endif + +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + +struct VersionPref { + char value[20]; +}; + +class LD6002BComponent : public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; } + void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; } + void set_auto_wake(bool enable) { this->auto_wake_ = enable; } + +#ifdef USE_SENSOR + void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; } + + void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].x = sensor; + } + void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].y = sensor; + } + void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].z = sensor; + } + void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].dop_idx = sensor; + } + void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].cluster_id = sensor; + } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } +#endif + +#ifdef USE_BINARY_SENSOR + void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; } + void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->target_presence_[target] = sensor; + } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } +#endif + +#ifdef USE_TEXT_SENSOR + void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; } + void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; } +#endif + +#ifdef USE_NUMBER + void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; } + void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } + void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } + void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; } +#endif + +#ifdef USE_SELECT + void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } + void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } + void set_installation_select(select::Select *select) { this->installation_select_ = select; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } +#endif + +#ifdef USE_SWITCH + void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } + void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } + void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; } +#endif + + void set_number_value(NumberType type, float value); + void set_select_value(SelectType type, size_t index); + void set_switch_state(SwitchType type, bool state); + void press_button(ButtonType type); + + protected: + enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; + + struct PendingCommand { + uint16_t type{0}; + uint8_t len{0}; + std::array data{}; + }; + + void parse_byte_(uint8_t byte); + void reset_parser_(); + void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); + void handle_target_report_(const uint8_t *data, uint16_t len); + void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); + void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_sensitivity_report_(const uint8_t *data, uint16_t len); + void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); + void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_installation_report_(const uint8_t *data, uint16_t len); + void handle_low_power_report_(const uint8_t *data, uint16_t len); + void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); + void handle_work_mode_report_(const uint8_t *data, uint16_t len); + void handle_version_report_(const uint8_t *data, uint16_t len); + void update_work_mode_fallback_(); + void publish_work_mode_(bool low_power); + // Drops every target-derived reading and the slot table they are indexed by. + void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); +#ifdef USE_SENSOR + void clear_target_slot_(uint8_t index); +#endif +#ifdef USE_NUMBER + void publish_number_clamped_(number::Number *number, float value); +#endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); + void init_version_pref_(); + void save_version_pref_(const char *value); + + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + void process_command_queue_(); + void send_command_(uint16_t type, const uint8_t *data, uint8_t len); + void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); + void send_z_range_(); + void apply_area_config_(); + void wake_(); + + static uint16_t read_u16_be(const uint8_t *data); + static uint32_t read_u32_le(const uint8_t *data); + static int32_t read_int32_le(const uint8_t *data); + static float read_f32_le(const uint8_t *data); + static void write_u32_le(uint8_t *data, uint32_t value); + static void write_int32_le(uint8_t *data, int32_t value); + static void write_f32_le(uint8_t *data, float value); + +#ifdef USE_SENSOR + std::array targets_{}; + sensor::Sensor *target_count_sensor_{nullptr}; + sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; + std::array target_presence_{}; + std::array area_presence_{}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; + text_sensor::TextSensor *ota_version_text_sensor_{nullptr}; + ESPPreferenceObject version_pref_{}; + bool version_pref_initialized_{false}; +#endif +#ifdef USE_NUMBER + number::Number *hold_delay_number_{nullptr}; + number::Number *z_min_number_{nullptr}; + number::Number *z_max_number_{nullptr}; + number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *sensitivity_select_{nullptr}; + select::Select *trigger_speed_select_{nullptr}; + select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; +#endif +#ifdef USE_SWITCH + switch_::Switch *low_power_switch_{nullptr}; + switch_::Switch *point_cloud_switch_{nullptr}; + switch_::Switch *target_display_switch_{nullptr}; +#endif + + GPIOPin *wakeup_pin_{nullptr}; + uint32_t wakeup_pulse_ms_{50}; + bool auto_wake_{true}; + + ParseState parse_state_{ParseState::SOF}; + uint8_t header_pos_{0}; + uint8_t header_xor_{0}; + uint16_t data_len_{0}; + uint16_t frame_type_{0}; + uint16_t frame_id_{0}; + uint16_t data_pos_{0}; + uint8_t data_xor_{0}; + uint32_t discard_remaining_{0}; + bool frame_oversize_{false}; + size_t max_data_len_{0}; + uint8_t *data_buf_{nullptr}; + uint16_t next_frame_id_{0}; + + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; + static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; + // A sleeping module consumes the first frame to wake and answers only the one after it. + static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; + // How long the module stays awake after any frame, and so still answers the next one. + static constexpr uint32_t MODULE_AWAKE_MS = 10000; + static constexpr uint8_t CMD_MAX_RETRIES = 3; + // Named so a repeated press replaces its own pending timeout instead of stacking + // another, and so the command path can cancel it when it takes the pin over. + static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; + // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. + static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; + + std::array cmd_queue_{}; + uint8_t cmd_head_{0}; + uint8_t cmd_tail_{0}; + uint8_t cmd_count_{0}; + bool command_active_{false}; + bool command_sent_{false}; + PendingCommand active_command_{}; + // Frame a pending wake pulse will write, snapshotted because active_command_ may move on first. + std::array wake_scratch_{}; + bool wake_pulse_pending_{false}; + uint8_t retries_left_{0}; + uint32_t last_send_ms_{0}; + // Last frame seen in either direction; any traffic keeps the module awake. + uint32_t last_traffic_ms_{0}; + // Frames transmitted for the command in flight, including retries; drives the retry budget. + uint8_t attempts_sent_{0}; + // Subset of those the module can actually answer: a frame that woke it is consumed, not replied to. + uint8_t acks_expected_{0}; + // ACKs still owed for superseded attempts; they carry no id, only their arrival order. + uint16_t stale_ack_type_{0}; + uint8_t stale_ack_count_{0}; + // When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK. + uint32_t stale_ack_ms_{0}; + // Bumped whenever the active command changes, so a deferred send can tell it was retired. + uint8_t send_generation_{0}; + + float z_min_{NAN}; + float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; + + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. + std::array slot_cluster_{}; + std::array slot_occupied_{}; + + bool target_presence_any_{false}; + // What the switches and setup asked the module for, which is not the same as + // what it is doing yet: a stream keeps sending until it acts on the command. + // The report handlers read these and drop anything a stopped stream still emits. + bool target_display_enabled_{false}; + bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; + bool work_mode_reported_{false}; + bool low_power_enabled_{false}; + bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; + bool last_work_mode_valid_{false}; + bool last_work_mode_low_power_{false}; + +#ifdef USE_SENSOR + std::array last_target_presence_{}; // one-shot NAN clear for target sensors + // A cluster id names a person, so like the counts it is published on change, not per frame. + std::array last_cluster_id_{}; + std::array last_cluster_id_valid_{}; + uint32_t last_target_count_{0xFFFFFFFF}; + uint32_t last_point_count_{0xFFFFFFFF}; +#endif +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py new file mode 100644 index 0000000000..7e0be66c64 --- /dev/null +++ b/esphome/components/ld6002b/number/__init__.py @@ -0,0 +1,181 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + UNIT_METER, + UNIT_MILLISECOND, + UNIT_SECOND, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, + CONF_HOLD_DELAY, + CONF_LD6002B_ID, + CONF_LOW_POWER_SLEEP_TIME, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number) +NumberType = ld6002b_ns.enum("NumberType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_HOLD_DELAY): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_SECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } + ), + } +) + + +def final_validate(config: ConfigType) -> ConfigType: + if config.get(CONF_AREA_CONFIG) is None: + return config + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, number_type, setter, min_value, max_value, step in ( + (CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1), + (CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1), + (CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1), + # 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as + # the default and no upper bound, so the range ends at a minute rather + # than at a default the module is free to be sleeping past. + ( + CONF_LOW_POWER_SLEEP_TIME, + NumberType.LOW_POWER_SLEEP, + "set_low_power_sleep_number", + 0, + 60000, + 100, + ), + ): + if conf := config.get(key): + n = await number.new_number( + conf, number_type, min_value=min_value, max_value=max_value, step=step + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/number/ld6002b_number.cpp b/esphome/components/ld6002b/number/ld6002b_number.cpp new file mode 100644 index 0000000000..b0b1b6f72b --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_number.h" + +namespace esphome::ld6002b { + +void LD6002BNumber::control(float value) { + this->publish_state(value); + this->parent_->set_number_value(this->type_, value); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/ld6002b_number.h b/esphome/components/ld6002b/number/ld6002b_number.h new file mode 100644 index 0000000000..3101b4d3cd --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BNumber : public number::Number, public Parented { + public: + explicit LD6002BNumber(NumberType type) : type_(type) {} + + protected: + void control(float value) override; + + NumberType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py new file mode 100644 index 0000000000..3da647ee2c --- /dev/null +++ b/esphome/components/ld6002b/select/__init__.py @@ -0,0 +1,74 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED + +DEPENDENCIES = ["ld6002b"] + +LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) +SelectType = ld6002b_ns.enum("SelectType", is_class=True) + +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_SENSITIVITY): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + } +) + + +SELECT_MAP = ( + ( + CONF_SENSITIVITY, + SelectType.SENSITIVITY, + "set_sensitivity_select", + ["low", "medium", "high"], + ), + ( + CONF_TRIGGER_SPEED, + SelectType.TRIGGER_SPEED, + "set_trigger_speed_select", + ["slow", "medium", "fast"], + ), + ( + CONF_INSTALLATION_MODE, + SelectType.INSTALLATION_MODE, + "set_installation_select", + ["top", "side"], + ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, select_type, setter, options in SELECT_MAP: + if conf := config.get(key): + s = await select.new_select(conf, select_type, options=options) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/select/ld6002b_select.cpp b/esphome/components/ld6002b/select/ld6002b_select.cpp new file mode 100644 index 0000000000..a6b524a665 --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_select.h" + +namespace esphome::ld6002b { + +void LD6002BSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_select_value(this->type_, index); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/ld6002b_select.h b/esphome/components/ld6002b/select/ld6002b_select.h new file mode 100644 index 0000000000..f380089a1e --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSelect : public select::Select, public Parented { + public: + explicit LD6002BSelect(SelectType type) : type_(type) {} + + protected: + void control(size_t index) override; + + SelectType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py new file mode 100644 index 0000000000..3aedaf9fdd --- /dev/null +++ b/esphome/components/ld6002b/sensor.py @@ -0,0 +1,188 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, + UNIT_METER, +) + +from . import LD6002BComponent +from .const import ( + AREA_COUNT, + CONF_CLUSTER_ID, + CONF_DOPPLER_INDEX, + CONF_LD6002B_ID, + CONF_POINT_COUNT, + CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, + MAX_TARGETS, +) + +DEPENDENCIES = ["ld6002b"] + +# The ld2450 defaults for a streamed value: hold the last reading for a second so a +# dropped frame does not read as absence, then rate-limit what reaches the frontend. +_VALUE_SENSOR_FILTERS = [ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + +TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( + accuracy_decimals=0, + ), + } +) + +AREA_SCHEMA = cv.Schema( + { + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_count_config := config.get(CONF_TARGET_COUNT): + sens = await sensor.new_sensor(target_count_config) + cg.add(hub.set_target_count_sensor(sens)) + + if point_count_config := config.get(CONF_POINT_COUNT): + sens = await sensor.new_sensor(point_count_config) + cg.add(hub.set_point_count_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + if x_config := target_config.get(CONF_X): + sens = await sensor.new_sensor(x_config) + cg.add(hub.set_target_x_sensor(i, sens)) + if y_config := target_config.get(CONF_Y): + sens = await sensor.new_sensor(y_config) + cg.add(hub.set_target_y_sensor(i, sens)) + if z_config := target_config.get(CONF_Z): + sens = await sensor.new_sensor(z_config) + cg.add(hub.set_target_z_sensor(i, sens)) + if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX): + sens = await sensor.new_sensor(doppler_index_config) + cg.add(hub.set_target_dop_idx_sensor(i, sens)) + if cluster_id_config := target_config.get(CONF_CLUSTER_ID): + sens = await sensor.new_sensor(cluster_id_config) + cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py new file mode 100644 index 0000000000..d27baa87fe --- /dev/null +++ b/esphome/components/ld6002b/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_LD6002B_ID, + CONF_LOW_POWER, + CONF_POINT_CLOUD, + CONF_TARGET_DISPLAY, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch) +SwitchType = ld6002b_ns.enum("SwitchType", is_class=True) + +# None of these three carry an inversion. They name what the module is doing, not +# how something is wired to it, so an inverted one would only report the opposite +# of the truth -- and the boot restore, which applies a state nothing reports back, +# is where that would be hardest to spot. +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_LOW_POWER): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_POINT_CLOUD): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, switch_type, setter in ( + (CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"), + (CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"), + (CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"), + ): + if conf := config.get(key): + s = await switch.new_switch(conf, switch_type) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.cpp b/esphome/components/ld6002b/switch/ld6002b_switch.cpp new file mode 100644 index 0000000000..7542f7b1ff --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_switch.h" + +namespace esphome::ld6002b { + +void LD6002BSwitch::write_state(bool state) { + this->parent_->set_switch_state(this->type_, state); + this->publish_state(state); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.h b/esphome/components/ld6002b/switch/ld6002b_switch.h new file mode 100644 index 0000000000..44773f802f --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSwitch : public switch_::Switch, public Parented { + public: + explicit LD6002BSwitch(SwitchType type) : type_(type) {} + + protected: + void write_state(bool state) override; + + SwitchType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py new file mode 100644 index 0000000000..a18d387437 --- /dev/null +++ b/esphome/components/ld6002b/text_sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC + +from . import LD6002BComponent +from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + if work_mode_config := config.get(CONF_WORK_MODE): + sens = await text_sensor.new_text_sensor(work_mode_config) + cg.add(hub.set_work_mode_text_sensor(sens)) + if ota_config := config.get(CONF_OTA_VERSION): + sens = await text_sensor.new_text_sensor(ota_config) + cg.add(hub.set_ota_version_text_sensor(sens)) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 62cef331fd..c51af373b3 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -26,6 +26,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.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from . import gpio # noqa: F401 @@ -460,6 +461,8 @@ async def component_to_code(config): # setup board config cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_LIBRETINY") + # FlashDB finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag(f"-DUSE_{config[CONF_COMPONENT_ID].upper()}") cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -506,6 +509,7 @@ 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) + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # 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. @@ -580,8 +584,13 @@ async def component_to_code(config): cg.add_platformio_option("custom_fw_name", "esphome") cg.add_platformio_option("custom_fw_version", __version__) - # Apply chip-specific SDK options to save RAM/Flash - if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): + # Apply chip-specific SDK options to save RAM/Flash. + # Skipped when bk72xx_ble is configured: add_platformio_option APPENDS list + # values (it never replaces), so emitting the disable here as well would put + # both CFG_SUPPORT_BLE=0 and =1 into the generated sys_config.h and rely on + # last-wins emission order. Skipping keeps it a single unambiguous define. + ble_requested = "bk72xx_ble" in CORE.config + if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238) and not ble_requested: cg.add_platformio_option( "custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS ) @@ -605,3 +614,4 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_ccache_script() diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 791a2659a9..4997878657 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -65,6 +65,10 @@ def _set_core_data(config): return config +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). CONFIG_SCHEMA = {SCHEMA} PIN_SCHEMA = {PIN_SCHEMA} @@ -117,7 +121,7 @@ VAR_GPIO_PIN = "validate_pin" VAR_GPIO_USAGE = "validate_usage" # lines for code snippets -SCHEMA_BASE = "libretiny.BASE_SCHEMA" +SCHEMA_BASE = "libretiny.BASE_SCHEMA.extend({})" SCHEMA_EXTRA = f"libretiny.BASE_SCHEMA.extend({VAR_SCHEMA})" PIN_SCHEMA_BASE = "libretiny.gpio.BASE_PIN_SCHEMA" PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})" diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 67e902024d..01b276005d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -44,7 +44,7 @@ void arch_init() { void arch_restart() { lt_reboot(); - while (1) { + while (true) { } } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 01a7b5450b..48b94a5214 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -44,6 +44,7 @@ // 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. +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -59,9 +60,11 @@ extern "C" void delayMicroseconds(unsigned int us); // Forward decls from libretiny's family for the inline arch_* // wrappers below. Pulling the full header would drag in the rest of the // LibreTiny C API. +// NOLINTBEGIN(readability-redundant-declaration) extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +// NOLINTEND(readability-redundant-declaration) namespace esphome::libretiny {} diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 9bbbd66be4..0ab064e3e1 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -13,14 +13,14 @@ void LTComponent::dump_config() { "LibreTiny:\n" " Version: %s\n" " Loglevel: %u", - LT_BANNER_STR + 10, LT_LOGLEVEL); + <_BANNER_STR[10], LT_LOGLEVEL); #if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3 ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { - this->version_->publish_state(LT_BANNER_STR + 10); + this->version_->publish_state(<_BANNER_STR[10]); } #endif // USE_TEXT_SENSOR } diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index 66b6847bee..f7f8279ac0 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -5,8 +5,10 @@ #include // Forward declare FlashDB types to avoid pulling in flashdb.h +// NOLINTBEGIN(readability-identifier-naming) struct fdb_kvdb; struct fdb_blob; +// NOLINTEND(readability-identifier-naming) namespace esphome::libretiny { diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 313b36d31e..d0bd3bf26b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -70,12 +70,21 @@ void LibreTinyPreferences::open() { } ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { - auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->db = &this->db; - pref->blob = &this->blob; - pref->key = type; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { + LibreTinyPreferenceBackend backend; + backend.key = type; + backend.db = &this->db; + backend.blob = &this->blob; + return backend; +} + +bool LibreTinyPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + LibreTinyPreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); } bool LibreTinyPreferences::sync() { diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index 8365d590c2..fd86c48b20 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -16,6 +16,8 @@ class LibreTinyPreferences final : public PreferencesMixin return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); @@ -23,6 +25,7 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: + LibreTinyPreferenceBackend make_backend_(uint32_t type); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); }; diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py new file mode 100644 index 0000000000..aadc1f3b2f --- /dev/null +++ b/esphome/components/ln882h_ble/__init__.py @@ -0,0 +1,53 @@ +"""LN882H BLE — BLE controller support for the LN882H LibreTiny chips. + +The platform analog of esp32_ble / rp2040_ble: owns the LN882H BLE stack +bring-up and the controller BLE address. Consumers (ln882h_ble_tracker) build +on this component and contain no SDK calls of their own. + +The BLE stack is compiled and linked by the LibreTiny lightning-ln882h builder +when CFG_SUPPORT_BLE=1 is set via custom_options.proj_config#h, which this +component does (LibreTiny v1.13.0+). +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["ln882x"] +CODEOWNERS = ["@Bl00d-B0b"] + +ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble") +LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLE), + # Default off: on the single-core LN882H, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("LN882H_BLE_SCAN_LISTENER_COUNT") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build. The LibreTiny lightning-ln882h builder + # gates the BLE stack sources and libraries on CFG_SUPPORT_BLE, read from + # the custom option key "proj_config#h" (the '#h' maps to proj_config.h) + # before the stock header's default of 0. A list is used so any other + # component adding to this key concatenates with it rather than replacing + # it (add_platformio_option only merges when both values are lists). + cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) + + cg.add_define("USE_LN882H_BLE") diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp new file mode 100644 index 0000000000..021e138f08 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -0,0 +1,473 @@ +// ln882h_ble.cpp +// +// BLE controller support for the LN882H (LibreTiny lightning-ln882h family) — +// the platform analog of esp32_ble / rp2040_ble. Owns everything that talks to +// the LN882H BLE SDK: +// - one-time stack bring-up (rw_init + the ln_* app init sequence), +// - the controller BLE address (persistent KV entry, WiFi-MAC-derived once), +// - the raw controller scan primitives (ln_ble_scan_start/stop), +// - the scan-report ring: the SDK's rw-task event callback decodes each +// report (including the controller's RSSI sign quirk) into a fixed pool +// and pushes it on a lock-free SPSC queue; loop() drains, dispatches on +// the main task and returns reports to the pool — the same EventPool + +// LockFreeQueue handoff esp32_ble uses, zero allocation at steady state. +// Consumers contain no SDK calls of their own. +// +// BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE +// stack itself is compiled and linked by the LibreTiny lightning-ln882h builder +// (CFG_SUPPORT_BLE=1 via custom_options.proj_config#h; prebuilt +// libln882h_ble_full_stack.a). + +#include "ln882h_ble.h" // pulls esphome/core/defines.h for USE_LN882H_BLE + +#ifdef USE_LN882H_BLE + +#include +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// LN882H BLE SDK — forward declarations +// --------------------------------------------------------------------------- +extern "C" { + +struct ln_bd_addr_v_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t addr[6]; +}; // ABI-identical to ln_bd_addr_t +void ln_kv_ble_app_init(void); +struct ln_bd_addr_v_t *ln_kv_ble_pub_addr_get(void); +int ln_kv_ble_addr_store(struct ln_bd_addr_v_t addr); +void soc_module_clk_gate_enable(uint32_t clk); + +void rw_init(uint8_t mac[6]); +void ln_gap_app_init(void); +void ln_gatt_app_init(void); +void ln_ble_conn_mgr_init(void); +void ln_ble_evt_mgr_init(void); +void ln_ble_smp_init(void); +void ln_ble_scan_mgr_init(void); +void ln_rw_app_task_init(void); +void ln_gap_reset(void); + +void ln_ble_scan_actv_creat(void); +void ln_ble_scan_start(void *scan_param); +void ln_ble_scan_stop(void); + +using ble_evt_cb_t = void (*)(void *param); +void ln_ble_evt_mgr_reg_evt(int evt_id, ble_evt_cb_t cb); + +} // extern "C" + +// ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is +// passed to ln_kv_ble_addr_store() by value, so its size and alignment are part +// of the calling convention. +static_assert(sizeof(struct ln_bd_addr_v_t) == 6, "ln_bd_addr_v_t must match the SDK's ln_bd_addr_t layout"); +static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byte-aligned like the SDK type"); + +// --------------------------------------------------------------------------- +// LN882H SDK constants +// CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block +// BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports +// GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type / +// gapm_dup_filter_pol / gapm_scan_prop / gapm_adv_report_info +// --------------------------------------------------------------------------- +static constexpr uint32_t CLK_G_BLE = 1u << 0; +static constexpr int BLE_EVT_ID_SCAN_REPORT = 3; + +// WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK +// exposes no symbolic name for this register; the address and value replicate +// the SDK reference bring-up. 0x003F sets all six PTI priority bits so the +// arbiter can pre-empt WiFi for BLE traffic. +static constexpr uint32_t BLE_COEX_PTI_REG_ADDR = 0x400121F8; +static constexpr uint32_t BLE_COEX_PTI_ENABLE_ALL = 0x003F; + +// ble_app_default_cfg.h BLE_DEFAULT_PUBLIC_ADDR, in the SDK's ln_bd_addr_t +// array order — least-significant octet first, the BLE/HCI convention (the +// SDK's own AT commands print addr[5]..addr[0]). Printable form: +// 00:FF:03:12:34:56. +static constexpr uint8_t BLE_DEFAULT_ADDR[6] = {0x56, 0x34, 0x12, 0x03, 0xFF, 0x00}; + +// The SDK's KV loader treats an all-zero address as unset and substitutes the +// default; resolve_mac_() applies the same rule to a stored entry. +static bool is_unset_addr(const uint8_t (&addr)[6]) { + return std::all_of(std::begin(addr), std::end(addr), [](uint8_t b) { return b == 0; }); +} + +// gapm_scan_type: GEN_DISC = 0, LIM_DISC = 1, OBSERVER = 2. Observer reports every +// advertisement without filtering — what a tracker wants. +static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2; +static constexpr uint8_t GAPM_DUP_FILT_DIS = 0; +// gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3. +static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0; +static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2; + +// GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info). +// 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement), +// 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv). +static constexpr uint8_t GAPM_REPORT_TYPE_ADV_LEG = 1; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +// Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan +// response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT). +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1u << 5; + +// --------------------------------------------------------------------------- +// SDK struct layouts +// --------------------------------------------------------------------------- + +// Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout, +// with the pad byte explicit so the whole block zero-initialises. +struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t type; + uint8_t prop; + uint8_t dup_filt_pol; + uint8_t pad; + uint16_t scan_intv; + uint16_t scan_wd; +}; +// Pin the compiler's layout decisions for the hand-mirrored SDK struct: it is +// passed to ln_ble_scan_start() as void*, so a padding drift would silently +// feed garbage scan parameters to the controller. +static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout"); +static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t"); + +// Scan report delivered by the BLE_EVT_ID_SCAN_REPORT event. Layout verified on +// hardware against the prebuilt BLE stack LibreTiny links: its report carries no +// PHY fields and stores the advertisement data inline (flexible array), unlike +// the newer upstream SDK header (which adds phy_prim/phy_second and a data pointer). +struct ble_scan_report_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t actv_idx; + uint8_t info; + uint8_t trans_addr_type; + uint8_t trans_addr[6]; + uint8_t target_addr_type; + uint8_t target_addr[6]; + int8_t tx_pwr; + int8_t rssi; // signed dBm, range -127..+20 (ble_evt_scan_report_t from ln_ble_event_manager.h) + uint16_t length; + uint8_t data[0]; +}; +// Pin the layout of the hand-mirrored report struct too: the comment above +// notes a newer SDK header uses a different layout (PHY fields + data pointer), +// so silent drift here would corrupt every decoded advertisement. +static_assert(sizeof(ble_scan_report_t) == 20, "ble_scan_report_t must match the linked BLE stack's layout"); +static_assert(offsetof(ble_scan_report_t, length) == 18, "unexpected padding in ble_scan_report_t"); +static_assert(offsetof(ble_scan_report_t, data) == 20, "advertisement data must follow the header inline"); + +// --------------------------------------------------------------------------- +// __sprintf weak stub +// +// The LN882H BLE SDK objects reference __sprintf (a Beken/LN libc alias) that +// LibreTiny's newlib does not provide. Supply a weak fallback so linking +// succeeds; a real definition, if one is ever provided, takes precedence. +// --------------------------------------------------------------------------- +#include +#include +extern "C" __attribute__((weak)) int +__sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + char *str, const char *format, ...) { + va_list args; + va_start(args, format); + int ret = vsprintf(str, format, args); // NOLINT + va_end(args); + return ret; +} + +namespace esphome::ln882h_ble { + +static const char *const TAG = "ln882h_ble"; + +// The SDK event callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static LN882HBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Scan parameter blocks handed to ln_ble_scan_start(void *). static storage: +// the SDK may retain the pointer past the call (the block travels into a GAPM +// message consumed later by the rw task), so a stack-local would leave the +// controller reading a dead frame. Double-buffered: consecutive starts (the +// enable() probe followed by the first real scan, or a parameter restart) +// alternate blocks, so a rewrite can never race a previous block that is still +// in flight — correct under either reading of SDK retention. All writers run +// on the main task. +static le_scan_parameters_t s_scan_params[2]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t s_scan_params_idx = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static le_scan_parameters_t *next_scan_params() { + s_scan_params_idx ^= 1; + return &s_scan_params[s_scan_params_idx]; +} + +// --------------------------------------------------------------------------- +// Scan-report event callback — runs in the SDK's rw task context. +// Decode the report (hardware-verified struct layout + the RSSI sign fix), +// copy it into the queue and return; all dispatch happens in loop() on the +// main task. +// --------------------------------------------------------------------------- +static void ble_scan_callback(void *param) { + if (s_ble == nullptr || param == nullptr) + return; + const auto *info = reinterpret_cast(param); + + // Only legacy framing is supported (see scan_start(): legacy 1M PHY only): + // an extended report does not fit BLEScanReport::data and would reach + // consumers as a truncated legacy frame. Reject before allocating so these + // do not burn pool slots either. + const uint8_t report_type = info->info & 0x07; + if (report_type != GAPM_REPORT_TYPE_ADV_LEG && report_type != GAPM_REPORT_TYPE_SCAN_RSP_LEG) { + s_ble->count_rejected_report(); + return; + } + + // Fill the pool slot in place (the bk72xx_ble shape): no report on the rw + // task's stack — its size is fixed by the prebuilt stack — one copy of the + // payload instead of two, and only data_len bytes ever leave this frame. + BLEScanReport *slot = s_ble->allocate_scan_report(); + if (slot == nullptr) + return; // no slot — counted as dropped in allocate_scan_report() + + // BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with + // a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented + // -127..+20 dBm maximum. Recover it by negating any value above +20 (verified + // on-device: the out-of-range positives cluster at the magnitude of each + // device's real readings). This is the ONLY LN882H-specific RSSI handling — + // downstream the value is used exactly like on ESP32. + const int8_t raw = info->rssi; + + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); + slot->rssi = (raw > 20) ? static_cast(-raw) : raw; + slot->addr_type = info->trans_addr_type; + slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; + slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0; + slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast(info->length) + : static_cast(sizeof(slot->data)); + memcpy(slot->data, info->data, slot->data_len); + + s_ble->push_scan_report(slot); +} + +BLEScanReport *LN882HBLE::allocate_scan_report() { + BLEScanReport *slot = this->report_pool_.allocate(); + if (slot == nullptr) { + // No slot: pool exhausted (queue full) or the pool's on-demand RAM + // allocation failed; count and drop either way. + this->report_queue_.increment_dropped_count(); + } + return slot; +} + +void LN882HBLE::push_scan_report(BLEScanReport *report) { + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLE::setup() { + s_ble = this; + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up. The KV load also happens here (no stack dependency). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the LN +// SDK's KV subsystem is first touched only once WiFi is up; earlier access +// destabilized the device in hardware testing. +float LN882HBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void LN882HBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; + soc_module_clk_gate_enable(CLK_G_BLE); + + rw_init(this->ble_mac_); + ln_gap_app_init(); + ln_gatt_app_init(); + ln_ble_conn_mgr_init(); + ln_ble_evt_mgr_init(); + ln_ble_smp_init(); + ln_ble_scan_mgr_init(); + ln_rw_app_task_init(); + ln_gap_reset(); + + delay(100); // NOLINT — one-time BLE stack init; SDK requires this settle time + + ln_ble_scan_actv_creat(); + delay(10); + + // Prime the scan activity with a short probe start/stop — the SDK's scan + // manager completes activity creation on the first start. Uses the shared + // static parameter block (see s_scan_params for the lifetime rationale). + le_scan_parameters_t *probe = next_scan_params(); + probe->type = GAPM_SCAN_TYPE_OBSERVER; + probe->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + probe->dup_filt_pol = GAPM_DUP_FILT_DIS; + probe->scan_intv = 160; + probe->scan_wd = 16; + ln_ble_scan_start(probe); + delay(10); + ln_ble_scan_stop(); + + // Register the scan-report event exactly once, after the event manager is up. + // Repeated registration corrupts the SDK's event registry (verified on + // hardware), which is why this lives here and not in scan_start(). + ln_ble_evt_mgr_reg_evt(BLE_EVT_ID_SCAN_REPORT, ble_scan_callback); + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void LN882HBLE::loop() { + // Log dropped reports before the empty-queue return: a drop can also mean + // EventPool::allocate() failed on heap exhaustion, and that can happen with + // the queue empty — from the very first report on. Checking here keeps that + // failure visible instead of producing a scanner that is silently dead. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped); + // Drain the lock-free ring filled by the rw task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report != nullptr) { + this->reject_diagnosis_done_ = true; + do { +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + } + + // Rejected-report accounting AFTER the drain: a stray non-legacy frame + // arriving ahead of the first good one must not latch the dead-scanner + // warning; the threshold keeps one-off boot noise below it while a truly + // dead scanner (~200 reports/s all rejected) crosses it within a second. + // Avoid the sub-word CAS in the common case (LockFreeQueue's dropped-count + // pattern): rejects are rare, the load is cheap. + uint16_t rejected = this->rejected_reports_.load(std::memory_order_relaxed); + if (rejected > 0) { + rejected = this->rejected_reports_.exchange(0, std::memory_order_relaxed); + if (!this->reject_diagnosis_done_) { + this->rejected_before_delivery_ += rejected; + if (this->rejected_before_delivery_ >= REJECTED_DEAD_SCANNER_THRESHOLD) { + this->reject_diagnosis_done_ = true; + ESP_LOGW(TAG, "Rejected %u scan reports before any was delivered - unexpected report encoding?", + static_cast(this->rejected_before_delivery_)); + } + } + ESP_LOGV(TAG, "Rejected %u non-legacy scan reports", rejected); + } +} + +void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } + +void LN882HBLE::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void LN882HBLE::resolve_mac_() { + ln_kv_ble_app_init(); + + // ln_kv_ble_app_init() loads the persistent address from the "2_ble_addr" KV + // entry, falling back to BLE_DEFAULT_ADDR when nothing is stored. A stored + // address is preferred so boot does not write flash. Order is LSB-first + // throughout (BLE/HCI convention); consumers reverse for printable form. + ln_bd_addr_v_t bt_addr{}; + bool have_unique_addr = false; + if (const ln_bd_addr_v_t *stored = ln_kv_ble_pub_addr_get(); stored != nullptr) { + bt_addr = *stored; + // All-zero is "unset", not a unique address: ln_kv_ble_addr_load() itself + // substitutes the default for it, so programming it verbatim would give the + // controller a null address. Treat it like the default and derive instead. + have_unique_addr = + memcmp(bt_addr.addr, BLE_DEFAULT_ADDR, sizeof(bt_addr.addr)) != 0 && !is_unset_addr(bt_addr.addr); + } else { + // KV subsystem down (wrong partition layout, corrupted region): derive + // below instead of dereferencing null and boot-looping. + ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); + } + if (!have_unique_addr) { + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; + get_mac_address_raw(wifi_mac); // MSB-first + // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment + // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the + // Beken/Tuya factory pairing the bk72xx sibling also uses. + for (int i = 0; i < 6; i++) + bt_addr.addr[i] = wifi_mac[5 - i]; + bt_addr.addr[0] = static_cast(bt_addr.addr[0] + 1); + if (int err = ln_kv_ble_addr_store(bt_addr); err != 0) { + ESP_LOGW(TAG, "Failed to persist derived BLE address (err %d); will re-derive next boot", err); + } else { + ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); + } + } + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); +} + +// --------------------------------------------------------------------------- +// Controller scan primitives +// --------------------------------------------------------------------------- + +void LN882HBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + if (this->scanning_) { + // Already scanning - stop first so this call cleanly restarts with the new + // parameters (re-entry guard). Give the GAPM stop the same settle time + // enable() grants between consecutive GAPM operations before restarting. + this->scan_stop(); + delay(10); // NOLINT — restart-only, mirrors enable()'s inter-operation settle + } + + // Double-buffered static block — see s_scan_params for the lifetime rationale. + le_scan_parameters_t *p = next_scan_params(); + p->dup_filt_pol = GAPM_DUP_FILT_DIS; + p->type = GAPM_SCAN_TYPE_OBSERVER; + p->scan_intv = interval; + p->scan_wd = window; + // Legacy 1M PHY only: consumers size their buffers for legacy advertisements + // (62 B); coded/extended PHY (up to 255 B) would be silently truncated. + p->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + if (active) + p->prop |= GAPM_SCAN_PROP_ACTIVE_1M_BIT; + + ln_ble_scan_start(p); + // ln_ble_scan_start() returns void, so this tracks the requested state, not a + // confirmed one — a controller-side failure surfaces as an idle scanner (no + // reports), which the consumer's start retry/backoff owns. + this->scanning_ = true; +} + +void LN882HBLE::scan_stop() { + // No-op when idle, as documented: the guard keeps a redundant SDK stop off + // the GAPM path (scan_start()'s re-entry guard calls this while scanning). + if (!this->scanning_) + return; + ln_ble_scan_stop(); + this->scanning_ = false; +} + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h new file mode 100644 index 0000000000..5b4a67b566 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -0,0 +1,153 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_LN882H_BLE + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include +#include + +namespace esphome::ln882h_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +/// One scan report from the controller, decoded from the SDK's rw-task event +/// (RSSI already sign-corrected). +struct BLEScanReport { + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) + uint8_t addr_type; + bool is_scan_response; // report is a scan response (active scan) + bool scannable; // advertisement may be followed by a scan response + uint8_t data_len; // bytes valid in data[] (<= 62) + // Each report carries ONE frame — a legacy advertisement (<=31 B) or a scan + // response (<=31 B) — delivered split, exactly as the SDK reports them. The + // TRACKER merges the pair into a single frame before any consumer sees it + // (Bluedroid semantics, HubCapabilities::merges_scan_response). 62 is twice + // the legacy maximum: defensive headroom for the data_len clamp, and the + // same width as the merged framing downstream. + uint8_t data[62]; + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the SDK's rw task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the rw task and loop(). Sized from the +// measured worst case, not copied: WiFi/BLE coexistence delays rw-task report +// delivery by up to ~136 ms on this device (see the tracker's pending-adv +// timeout rationale), and a busy 2.4 GHz environment delivers ~200-400 +// reports/s — a stall plus one loop() interval buffers ~30-60 reports, so 63 +// usable slots absorb it with margin. ~4.7 KB at high water, reached only +// during such stalls. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + +// Rejected frames tolerated before the first delivered report without +// declaring the scanner dead (boot-time stray extended frames are normal). +static constexpr uint16_t REJECTED_DEAD_SCANNER_THRESHOLD = 16; + +class LN882HBLE final : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the LN882H BLE stack (one-time; the SDK has no teardown path). + /// Requires setup() to have run: rw_init() is handed the address resolved + /// there. Blocks ~120 ms across the SDK's settle points, so calling it from + /// loop() (enable_on_boot: false) trips the loop-blocking warning once. + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address in the SDK's ln_bd_addr_t order: least-significant + /// octet first (the BLE/HCI convention). Reverse for printable form — the + /// byte order is in the name so platform analogs cannot be confused + /// (the bk72xx sibling exposes the same accessor). + void get_mac_lsb_first(uint8_t out[6]) const; + +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits LN882H_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Start the controller scan. Interval/window are in BLE units (0.625 ms); + /// active enables scan requests on the 1M PHY. Enables the stack first if + /// needed. Scans the legacy 1M PHY only (extended/coded PHY advertisements + /// exceed the legacy 62-byte framing consumers are sized for). + void scan_start(uint16_t interval, uint16_t window, bool active); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + + /// Internal, SDK rw-task event-callback context: allocate a pool slot for a + /// scan report. Returns nullptr (and counts the drop) when the queue is full; + /// the callback fills the slot in place — no intermediate copy. + BLEScanReport *allocate_scan_report(); + /// Internal: hand a filled slot to the main-task queue (cannot fail — the + /// pool is sized to the queue capacity). + void push_scan_report(BLEScanReport *report); + /// Internal, rw-task context: count a report rejected by the legacy-only + /// filter, so a wrong assumption about the stack's report encoding shows up + /// in verbose logs instead of as a scanner that silently reports nothing. + void count_rejected_report() { this->rejected_reports_.fetch_add(1, std::memory_order_relaxed); } + + protected: + void resolve_mac_(); + +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the SDK event callback (rw task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + // Overflow drops the NEWEST report (allocate fails, producer counts and + // returns) — under a coexistence stall the freshest advertisements are lost + // while queued ones drain. Deliberate: matches esp32_ble, and dropping from + // the head would need consumer-side locking this design exists to avoid. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; + // Reports rejected by the legacy-only filter (rw-task producer, main-task + // consumer via exchange in loop()). + std::atomic rejected_reports_{0}; + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + BLEComponentState state_{BLEComponentState::STATE_OFF}; + bool enable_on_boot_{false}; + bool scanning_{false}; // controller scan running (re-entry guard for scan_start) + // Dead-scanner diagnosis: done once a report is delivered or the one-shot + // warning has fired, whichever comes first. + bool reject_diagnosis_done_{false}; + uint32_t rejected_before_delivery_{0}; // drives the dead-scanner warning +}; + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py new file mode 100644 index 0000000000..4bfaa93ab7 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -0,0 +1,172 @@ +"""LN882H BLE scanner implementing the ble_device_base BLEHub contract on +top of the ln882h_ble controller. With continuous: false nothing scans until +an explicit start_scan() call.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import ble_device_base, ln882h_ble, ota +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID +from esphome.types import ConfigType + +CONF_LN882H_BLE_ID = "ln882h_ble_id" + +DEPENDENCIES = ["ln882x"] +AUTO_LOAD = ["ble_device_base", "ln882h_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +ble_device_base.register_hub_provider("ln882h_ble_tracker") + +ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker") +LN882HBLETracker = ln882h_ble_tracker_ns.class_( + "LN882HBLETracker", ble_device_base.BLEHub, cg.Component +) + +StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + + +# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "100ms", window_default="50ms" +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLETracker), + cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +@automation.register_action( + "ln882h_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "ln882h_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + 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: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_LN882H_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + ln882h_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h new file mode 100644 index 0000000000..8b211384a0 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -0,0 +1,47 @@ +// Scan-control actions for ln882h_ble_tracker. The automation triggers are the +// neutral ble_device_base classes (ble_device_base/automation.h). + +#pragma once + +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::ln882h_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp new file mode 100644 index 0000000000..e1083e5fbe --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -0,0 +1,257 @@ +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::ln882h_ble_tracker { + +static const char *const TAG = "ln882h_ble_tracker"; + +static constexpr float BLE_SCAN_UNIT_MS = 0.625f; + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // rw task and delivers here on the main task. + this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); + // scan_running_ check: an on_boot start_scan action (priority 600) runs + // before this setup() (200) and enable_loop() is a no-op pre-setup — parking + // the loop here would strand that already-running scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) { + // Say so once: with continuous: false nothing scans until an explicit + // start_scan() — silence here reads as a broken scanner. + ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()"); + // Nothing to time until then; start_scan_() re-enables the loop. + this->disable_loop(); + } +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core LN882H the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif +} + +#ifdef USE_OTA_STATE_LISTENER +void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_running_before_ota_ = this->scan_running_; + this->stop_scan(); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted + // update. Continuous mode resumes via loop()'s idle branch; a one-shot + // scan that was running is restarted explicitly (bk72xx sibling parity — + // stop_scan() cleared it and nothing else would bring it back). + if (this->scan_continuous_before_ota_) { + this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() disabled it; loop()'s idle branch restarts the scan + } else if (this->scan_running_before_ota_) { + this->start_scan(); + } + this->scan_continuous_before_ota_ = false; + this->scan_running_before_ota_ = false; + } +} +#endif // USE_OTA_STATE_LISTENER + +void LN882HBLETracker::loop() { + if (this->pending_start_) { + // A start_scan latched before the controller's setup(); safe now — loop() + // only runs after every component set up. + this->pending_start_ = false; + if (!this->scan_running_) { + this->start_scan_(); + } + } + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. + const uint32_t now = millis(); + if (!this->merger_.empty()) + this->merger_.sweep(now); + + if (this->scan_continuous_) { + if (!this->scan_running_) { + this->start_scan_(); + // start_scan_() re-anchors scan_period_start_ from a later millis() than + // the cached `now`; resume the period timer next iteration. + return; + } + // Period timer: once per scan_duration_ window, restart the controller scan + // and fire on_scan_end(), mirroring esp32_ble_tracker::cleanup_scan_state_(). + // The restart is the recovery path for the coexistence failure documented in + // the header. scan_start() re-enters cleanly on its own: it stops an + // in-flight scan and grants the controller's 10 ms GAPM settle before + // restarting — an explicit scan_stop() first would clear the controller's + // re-entry guard and skip that settle. + if (now - this->scan_period_start_ >= this->scan_duration_) { + ESP_LOGD(TAG, "Scan period elapsed - restarting scan"); + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + // Keep both clocks anchored to the restart: a runtime switch to + // non-continuous then times out the current period, not the whole run. + this->scan_start_time_ = now; + this->end_scan_period_(now); + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. wifi: on_connect:). + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +bool LN882HBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); + // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and + // no period reset: the scan logically continues, only the mode changes. + if (this->scan_running_) { + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + } + return true; +} + +void LN882HBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Type: %s\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. +// --------------------------------------------------------------------------- + +void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { + if (report.is_scan_response) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; + } + // Stash only while the scan runs: after a one-shot stop the loop is + // disabled and nothing would sweep the merger, so a late report would + // surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && report.scannable) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); +} + +// --------------------------------------------------------------------------- +// Public scan actions +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + if (!this->parent_->is_ready()) { + // An on_boot automation (priority 600) runs before the controller's + // setup() has resolved the BLE MAC; scan_start() now would rw_init() the + // all-zero address and bring BLE up before WiFi. Latch; loop() applies + // the start once every setup() has run. + this->pending_start_ = true; + return; + } + if (!this->scan_running_) { + this->start_scan_(); + } +} + +void LN882HBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = millis(); +} + +void LN882HBLETracker::stop_scan() { + // Cancel a start latched before the controller's setup(); without this an + // on_boot start_scan/stop_scan pair would still start at the first loop(). + this->pending_start_ = false; + this->scan_continuous_ = false; + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan_() { + if (this->scan_running_) + return; + + // The controller enables the stack on first use and owns the report queue; + // this call is all the SDK interaction the tracker ever needs. + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + const uint32_t now = millis(); + this->scan_running_ = true; + this->scan_start_time_ = now; + this->enable_loop(); // an idle non-continuous tracker disabled it in stop_scan_() + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? "active" : "passive", + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); + // Re-anchor the on_scan_end period to every successful start, so a restart + // later than scan_duration (e.g. a failed OTA restoring continuous mode) + // does not fire on_scan_end before an advertisement can arrive. + this->scan_period_start_ = now; +} + +void LN882HBLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + // DEBUG like start_scan_() — a per-period stop at INFO would read as the + // scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan stopped"); + this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire + // scan_running_ re-check: an on_scan_end automation runs synchronously inside + // end_scan_period_() and may have called start_scan() — parking the loop then + // would leave the radio scanning with no period timing or pending-adv sweep. + if (!this->scan_continuous_ && !this->scan_running_) { + // Nothing left to time; start_scan_() re-enables the loop. + this->disable_loop(); + } +} + +// Close a scan period: deliver held advertisements whose scan response never +// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. +void LN882HBLETracker::end_scan_period_(uint32_t now) { + this->merger_.flush(); + this->dispatcher_.on_scan_end(); + this->scan_period_start_ = now; +} + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h new file mode 100644 index 0000000000..dc42aebce9 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -0,0 +1,144 @@ +// BLE scanner for LN882H: implements ble_device_base::BLEHub on top of the +// ln882h_ble controller (which owns all SDK calls and delivers scan reports on +// the main task). Scan policy lives here: parameters, period timers with +// per-period restart, and the adv+scan-response merge. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" +#include "esphome/components/ln882h_ble/ln882h_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::ln882h_ble_tracker { + +// --------------------------------------------------------------------------- +// LN882HBLETracker +// --------------------------------------------------------------------------- + +class LN882HBLETracker : public Component, + public Parented, + public ln882h_ble::BLEScanListener +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } + void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { + this->dispatcher_.register_listener(listener); + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->dispatcher_.set_raw_advertisement_callback(callback); + } + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // The LN882H controller supports active scanning; adv + scan response arrive + // as separate reports and are merged by this tracker (Bluedroid semantics). + // The SDK's GATT client is not exposed. + // scan_mode_switch: request_scan_mode() is implemented (restart-if-running). + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; + } + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); + + // ---- ln882h_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // rw-task → main-task handoff already happened in the controller's queue. + // Demultiplexes advertisements vs scan responses and drives the merge. + void on_scan_report(const ln882h_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + // Close a scan period: flush held advertisements (unmerged) BEFORE + // on_scan_end fires, then re-anchor the period clock to `now`. + void end_scan_period_(uint32_t now); + + bool scan_running_{false}; + bool scan_active_{false}; + // Defaults are the LN882H SDK's recommended scan parameters + // (ln_ble_scan.h: SCAN_INTERVAL_DEF 0xA0, SCAN_WINDOW_DEF 0x50 → 50 % duty). + // uint16_t matches the controller's scan_start() parameters. + uint16_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms (SDK SCAN_INTERVAL_DEF) + uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; + bool pending_start_{false}; // start_scan() latched before the controller's setup() + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure +#endif + uint32_t scan_start_time_{0}; + + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; + + uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 9c91827522..6da5a4969c 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 77a875dd8f..f307f5d5d1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -410,10 +410,16 @@ async def _late_logger_init(config: ConfigType) -> None: from esphome.components.esp8266.const import enable_serial, enable_serial1 hw_uart = config.get(CONF_HARDWARE_UART, UART0) - if has_serial_logging and hw_uart in (UART0, UART0_SWAP): + if not has_serial_logging: + # No serial logging: stub out ROM ets_putc so stray output (newlib + # stdout, lwIP diagnostics) cannot block on a slow or shared UART0. + # ets_putc always writes to the physical UART and cannot be disabled + # through uart_set_debug(); see __wrap_ets_putc in logger_esp8266.cpp. + cg.add_build_flag("-Wl,--wrap=ets_putc") + elif hw_uart in (UART0, UART0_SWAP): cg.add_define("USE_ESP8266_LOGGER_SERIAL") enable_serial() - elif has_serial_logging and hw_uart == UART1: + elif hw_uart == UART1: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5797b03ba7..ac71ba8e3b 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -49,4 +49,17 @@ const LogString *Logger::get_uart_selection_() { } } // namespace esphome::logger + +#if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) +// With serial logging disabled, ROM ets_putc still writes to the physical UART0 +// at whatever baud rate a uart bus configured there; uart_set_debug(UART_NO) +// only silences the installable putc1 hook, not ets_putc itself. Blocking +// writes at a low baud rate (for example 4800 for a power monitoring chip) can +// starve the soft watchdog. All linked callers (newlib stdout, lwIP +// diagnostics, postmortem dumps) are redirected here by -Wl,--wrap=ets_putc. +// IRAM_ATTR because the ROM original is callable with the flash cache +// disabled (for example from newlib's _write_r, which is placed in IRAM). +extern "C" void IRAM_ATTR __wrap_ets_putc(char) {} +#endif + #endif diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index fe094f6e9e..708ab883e7 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -21,6 +21,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) { // Single write for everything fwrite(buffer, 1, pos, stdout); + fflush(stdout); } void Logger::pre_setup() { global_logger = this; } diff --git a/esphome/components/logger/task_log_buffer_libretiny.cpp b/esphome/components/logger/task_log_buffer_libretiny.cpp index b6d6b22ab5..5cde18d19e 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.cpp +++ b/esphome/components/logger/task_log_buffer_libretiny.cpp @@ -20,7 +20,7 @@ TaskLogBuffer::~TaskLogBuffer() { } } -size_t TaskLogBuffer::available_contiguous_space() const { +size_t TaskLogBuffer::available_contiguous_space_() const { if (this->head_ >= this->tail_) { // head is ahead of or equal to tail // Available space is from head to end, plus from start to tail @@ -81,7 +81,7 @@ void TaskLogBuffer::release_message_main_loop() { this->tail_ = 0; } - this->message_count_--; + this->message_count_ = this->message_count_ - 1; this->current_message_size_ = 0; xSemaphoreGive(this->mutex_); @@ -117,7 +117,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Check if we have enough contiguous space - size_t contiguous = this->available_contiguous_space(); + size_t contiguous = this->available_contiguous_space_(); if (contiguous < total_size) { // Not enough contiguous space at end @@ -128,9 +128,9 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Need at least enough space to safely write padding marker (level field is at end of struct) - constexpr size_t PADDING_MARKER_MIN_SPACE = offsetof(LogMessage, level) + 1; + constexpr size_t padding_marker_min_space = offsetof(LogMessage, level) + 1; - if (space_at_start >= total_size && this->head_ > 0 && contiguous >= PADDING_MARKER_MIN_SPACE) { + if (space_at_start >= total_size && this->head_ > 0 && contiguous >= padding_marker_min_space) { // Add padding marker (set level field to indicate this is padding, not a real message) LogMessage *padding = reinterpret_cast(this->storage_ + this->head_); padding->level = PADDING_MARKER_LEVEL; @@ -180,7 +180,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin this->head_ = 0; } - this->message_count_++; + this->message_count_ = this->message_count_ + 1; xSemaphoreGive(this->mutex_); return true; diff --git a/esphome/components/logger/task_log_buffer_libretiny.h b/esphome/components/logger/task_log_buffer_libretiny.h index b42894502a..ce469a1a18 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.h +++ b/esphome/components/logger/task_log_buffer_libretiny.h @@ -84,7 +84,7 @@ class TaskLogBuffer { static inline size_t message_total_size(size_t text_length) { return sizeof(LogMessage) + text_length + 1; } // Calculate available contiguous space at write position - size_t available_contiguous_space() const; + size_t available_contiguous_space_() const; uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) size_t head_{0}; // Write position diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 256bf4bb3a..bfe91eedd5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,4 +1,3 @@ -import functools import importlib from pathlib import Path import pkgutil @@ -86,7 +85,7 @@ from .schemas import ( any_widget_schema, container_schema, container_schema_value, - obj_dict, + theme_schema, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -215,6 +214,18 @@ def multi_conf_validate(configs: list[dict]): raise cv.Invalid( f"'{item}' must have an explicit group set when using multiple LVGL instances" ) + # The hidden styles a `theme:` block creates are tracked in a single map shared + # by all LVGL instances (keyed only by widget type, not by instance), so a + # second instance's `theme:` would silently lose to whichever instance is + # processed first instead of doing what its config implies. + themed_configs = sum( + 1 for config in configs if config.get(df.CONF_THEME) is not None + ) + if themed_configs > 1: + raise cv.Invalid( + "'theme' may only be set on one LVGL instance when using multiple LVGL " + "instances -- combine both themes into a single instance's 'theme:' block" + ) base_config = configs[0] for config in configs[1:]: for item in ( @@ -552,34 +563,6 @@ def add_hello_world(config): return config -@functools.cache -def _build_theme_schema( - widget_types: tuple[tuple[str, widgets.WidgetType], ...], -) -> cv.Schema: - # The theme schema is value-independent: it depends only on the set of - # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so - # that an external component registering a new widget after the first - # validation (legal per any_widget_schema's lazy-evaluation contract) - # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache - # self-heals instead of stale-rejecting valid themes. See obj_dict() in - # schemas.py for why chained .extend() is avoided here. - return cv.Schema( - { - cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, - **{ - cv.Optional(name): cv.Schema( - {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} - ) - for name, w in widget_types - }, - } - ) - - -def _theme_schema(value: dict) -> dict: - return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) - - FINAL_VALIDATE_SCHEMA = final_validation # The options accepted at the top level of an `lvgl:` block, on top of the base @@ -647,7 +630,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_THEME): theme_schema, cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 1e0abce358..26bb433f87 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -21,12 +21,22 @@ class LvAnimationTiming { class LvAnimationTimingRoundTrip : public LvAnimationTiming { public: + // moving_length_ is the fraction of progress spent moving in each direction, in (0, 0.5]. + // Callers must pass pause in [0, 1) -- pause == 1.0 would make moving_length_ zero and divide by zero below. + LvAnimationTimingRoundTrip(float pause) : moving_length_((1.0f - pause) / 2.0f) {} float map_progress(float value) override { - value *= 2.0f; - if (value > 1.0f) - return 2.0f - value; - return value; + if (value < this->moving_length_) { + return value / this->moving_length_; + } + if (value > 1.0f - this->moving_length_) { + return (1.0f - value) / this->moving_length_; + } + // pause in the middle + return 1.0f; } + + protected: + float moving_length_{}; }; class LvAnimationTimingGravity : public LvAnimationTiming { diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 2b1500f2c4..95d45de5ea 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -42,6 +42,7 @@ LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") CONF_BOUNCE = "bounce" +CONF_PAUSE = "pause" def timing_class(name, extras=None): @@ -60,10 +61,20 @@ TIMING_SCHEMA = cv.maybe_simple_value( cv.typed_schema( dict( [ - timing_class("round_trip"), + timing_class( + "round_trip", + { + cv.Optional(CONF_PAUSE, default=0.0): cv.All( + cv.percentage, + cv.float_range( + min=0.0, max=1.0, min_included=True, max_included=False + ), + ) + }, + ), timing_class( "ease_in_out", - {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), timing_class( "gravity", diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b7c90a5c51..cad065adee 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -5,7 +5,14 @@ from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT +from esphome.const import ( + CONF_ACTION, + CONF_GROUP, + CONF_ID, + CONF_POSITION, + CONF_ROTATION, + CONF_TIMEOUT, +) from esphome.core import Lambda from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr @@ -28,6 +35,7 @@ from .defines import ( get_focused_widgets, get_options, get_refreshed_widgets, + literal, ) from .layout import layout_validator from .lv_validation import lv_bool, lv_milliseconds, lv_rotation @@ -36,6 +44,7 @@ from .lvcode import ( UPDATE_EVENT, LambdaContext, LocalVariable, + LvConditional, LvglComponent, ReturnStatement, add_line_marks, @@ -376,6 +385,48 @@ async def obj_show_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_show, action_id, template_arg, args) +SET_Z_INDEX_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.ensure_list( + cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(lv_obj_t)}, + key=CONF_ID, + ) + ), + cv.Required(CONF_POSITION): cv.Any( + cv.one_of("TOP", "BOTTOM", "UP", "DOWN", upper=True), cv.int_ + ), + } +) + + +@automation.register_action( + "lvgl.widget.set_z_index", ObjUpdateAction, SET_Z_INDEX_SCHEMA, synchronous=True +) +async def obj_set_z_index_to_code(config, action_id, template_arg, args): + position = config[CONF_POSITION] + + async def do_set_z_index(widget: Widget): + if position == "TOP": + lv_obj.move_foreground(widget.obj) + elif position == "BOTTOM": + lv_obj.move_background(widget.obj) + elif position == "UP": + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1") + ) + elif position == "DOWN": + with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"): + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1") + ) + else: + lv_obj.move_to_index(widget.obj, position) + + widgets = [widget.outer or widget for widget in await get_widgets(config[CONF_ID])] + return await action_to_code(widgets, do_set_z_index, action_id, template_arg, args) + + def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) get_focused_widgets().add(value) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 4f734fe20c..65e975ad6d 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" +KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" KEY_THEME_WIDGET_MAP = "theme_widget_map" KEY_UPDATED_WIDGETS = "updated_widgets" KEY_WIDGET_MAP = "widget_map" @@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]: return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: + # Values are dicts used as ordered sets (insertion order is deterministic, + # unlike a plain `set` of strings/tuples, whose iteration order depends on + # per-process string hash randomization) so codegen output doesn't churn + # between builds of the same config. + return _get_data(KEY_THEME_UPDATE_REQUESTS, {}) + + def get_styles_used() -> set[str]: return _get_data(KEY_STYLES_USED, set()) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index dd4f71a346..e400dae50f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +import functools from typing import Any from esphome import config_validation as cv @@ -8,6 +9,7 @@ from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, + CONF_DEFAULT, CONF_FORMAT, CONF_GROUP, CONF_ID, @@ -69,7 +71,7 @@ from .types import ( lv_pseudo_button_t, lv_style_t, ) -from .widgets import WidgetType +from .widgets import WidgetType, collect_parts # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} @@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.Schema: return schema +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, WidgetType], ...], + include_dark_mode: bool = True, +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() above + # for why chained .extend() is avoided here. + return cv.Schema( + { + **( + {cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean} + if include_dark_mode + else {} + ), + **{ + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types + }, + } + ) + + +def _reject_theme_styles_key(validated: dict) -> dict: + """ + `styles:` (a list of already-declared named styles) is not allowed inside + `theme:` -- the hidden style objects theme: creates only carry direct + style properties, so a `styles:` reference there would be silently + dropped by `style_set`, which only walks `ALL_STYLES`. + """ + for w_name, style in validated.items(): + if w_name not in WIDGET_TYPES: + continue + for part, states in collect_parts(style).items(): + for state, props in states.items(): + if df.CONF_STYLES not in props: + continue + path = [w_name] + if part != df.CONF_MAIN: + path.append(part) + if state != CONF_DEFAULT: + path.append(state) + path.append(df.CONF_STYLES) + raise cv.Invalid( + "'styles:' is not allowed in LVGL theme styles. " + "Set style properties directly instead.", + path, + ) + return validated + + +def theme_schema(value: dict) -> dict: + return _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) + ) + + +def theme_update_schema(value: dict) -> dict: + """ + Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`. + As a validation side effect, records which (widget type, part, state) + combos are targeted so `theme_to_code` can make sure a hidden style + exists for each -- even ones never mentioned under `theme:` -- and gets + it attached to widgets at the same point real theme styles are. + """ + validated = _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value) + ) + for w_name, style in validated.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds a main/default entry + # even when nothing was set for it (e.g. `{pressed: {...}}` + # alone) -- skip combos with no properties so a request for + # one state doesn't also create an unused, empty main/default + # style that gets attached to every widget of this type. + if not props: + continue + df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = ( + None + ) + return validated + + ALIGN_TO_SCHEMA = { cv.Optional(df.CONF_ALIGN_TO): cv.Schema( { diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 5911505555..ad42028327 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -10,11 +10,18 @@ from .defines import ( LValidator, add_lv_use, get_styles_used, + get_theme_update_requests, get_theme_widget_map, literal, ) from .lvcode import LambdaContext, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property +from .schemas import ( + ALL_STYLES, + FULL_STYLE_SCHEMA, + WIDGET_TYPES, + remap_property, + theme_update_schema, +) from .types import ObjUpdateAction, lv_style_t from .widgets import collect_parts, wait_for_widgets @@ -86,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args): style = await cg.get_variable(config[CONF_ID]) async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens to + # invalidate the affected widgets. + lv.obj_report_style_change(style) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): - if theme := config.get(CONF_THEME): - add_lv_use(CONF_THEME) - for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES): - # Work around Python 3.10 bug with nested async comprehensions - # With Python 3.11 this could be simplified - # TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions - styles = {} - for part, states in collect_parts(style).items(): - styles[part] = { - state: await create_style( + theme = config.get(CONF_THEME) or {} + requests = get_theme_update_requests() + # Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather + # than a set -- a set of strings/tuples iterates in an order that depends on + # per-process hash randomization, which would otherwise churn the order hidden + # style variables are declared in main.cpp between builds of the same config. + widget_names = [ + w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests + ] + if not widget_names: + return + add_lv_use(CONF_THEME) + theme_map = get_theme_widget_map() + for w_name in widget_names: + declared_parts = collect_parts(theme[w_name]) if w_name in theme else {} + parts = {part: dict(states) for part, states in declared_parts.items()} + for part, state in requests.get(w_name, {}): + parts.setdefault(part, {}).setdefault(state, {}) + widget_styles = theme_map.setdefault(w_name, {}) + for part, states in parts.items(): + part_styles = widget_styles.setdefault(part, {}) + declared_states = declared_parts.get(part, {}) + for state, props in states.items(): + if state not in part_styles: + part_styles[state] = await create_style( "_lv_theme_style_" + w_name + "_" + part + "_" + state, props ) - for state, props in states.items() - } - get_theme_widget_map()[w_name] = styles + elif state in declared_states: + # A `theme.update` request for this combo (possibly from + # another LVGL instance) already created the style as an + # empty placeholder before this instance's real `theme:` + # declaration was reached -- apply the real values now + # instead of silently leaving it empty. + await style_set(part_styles[state], props) + + +@automation.register_action( + "lvgl.theme.update", + ObjUpdateAction, + theme_update_schema, + synchronous=True, +) +async def theme_update_to_code(config, action_id, template_arg, args): + await wait_for_widgets() + theme_map = get_theme_widget_map() + # Invariant this relies on: theme_update_schema() records every (widget + # type, part, state) combo this action targets as a request during config + # validation (which completes for the whole config tree before any + # to_code runs), and theme_to_code() -- which runs for every LVGL + # instance before any action's own to_code -- materialises a style for + # each recorded request. If that handshake is ever broken by a future + # change, fail with a diagnosable message rather than a bare KeyError. + to_update = [] + for w_name, style in config.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds an (empty) main/default + # entry even when this action didn't target it -- skip it, both + # because there's nothing to update and because + # theme_update_schema no longer pre-creates a placeholder style + # for combos with no properties. + if not props: + continue + style_var = theme_map.get(w_name, {}).get(part, {}).get(state) + if style_var is None: + raise cv.Invalid( + f"No theme style exists for '{w_name}' {part}/{state}. " + "This is an internal error -- please report it." + ) + to_update.append((style_var, props)) + async with LambdaContext(parameters=args, where=action_id) as context: + for style_var, props in to_update: + await style_set(style_var, props) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens + # to invalidate the affected widgets. + lv.obj_report_style_change(style_var) + + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index e83a6847d6..abc74b9e6d 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -21,7 +21,18 @@ void Mcp4461Component::setup() { auto init_val = this->reg_[i].initial_value; if (init_val.has_value()) { uint16_t initial_state = static_cast(*init_val * 256.0f); - this->write_wiper_level_(i, initial_state); + if (i > 3) { + // NV wiper: an unconditional write would cost one EEPROM erase/write cycle on EVERY + // boot. Only write when the stored value actually differs — and always write when + // the read itself failed (a failed read returns 0, which would silently skip the + // write whenever initial_value is 0). + bool read_ok = false; + if (this->read_wiper_level_(i, &read_ok) != initial_state || !read_ok) { + this->write_wiper_level_(i, initial_state); + } + } else { + this->write_wiper_level_(i, initial_state); + } } if (this->reg_[i].enabled) { this->reg_[i].state = this->read_wiper_level_(i); @@ -34,6 +45,23 @@ void Mcp4461Component::setup() { } } } + // Push the YAML terminal configuration to the TCON registers. TCON is volatile — on POR + // the chip restores wiper levels from the NV registers but resets TCON to "all terminals + // connected", so any terminal_a/b/w disables from the config MUST be written here. + for (uint8_t t = 0; t < 2; t++) { + Mcp4461TerminalIdx terminal_connector = static_cast(t); + uint8_t terminal_byte = this->calc_terminal_connector_byte_(terminal_connector); + this->set_terminal_register_(terminal_connector, terminal_byte); + } +} + +void Mcp4461Component::set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms) { + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + return; // NV channels E-H are the persistence target themselves + } + this->reg_[wiper_idx].nonvolatile = true; + this->reg_[wiper_idx].nonvolatile_write_delay_ms = write_delay_ms; } void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) { @@ -77,9 +105,12 @@ void Mcp4461Component::dump_config() { // so also invalid for nonvolatile. For these, only print current level. // reworked to be a one-line intentionally, as output would not be in order if (i < 4) { - ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i, - this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), - ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w)); + ESP_LOGCONFIG(TAG, + " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, " + "A: %s, B: %s, W: %s, NV: %s", + i, this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), + ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w), + ONOFF(this->reg_[i].nonvolatile)); } else { ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state); } @@ -92,8 +123,10 @@ void Mcp4461Component::loop() { } for (uint8_t i = 0; i < 8; i++) { if (this->reg_[i].update_level) { - // set wiper i state if changed - if (this->reg_[i].state != this->read_wiper_level_(i)) { + // set wiper i state if changed — a failed read (returns 0) must not suppress the + // write when the target state is 0, same hardening as the NV read-compare paths + bool read_ok = false; + if (this->reg_[i].state != this->read_wiper_level_(i, &read_ok) || !read_ok) { this->write_wiper_level_(i, this->reg_[i].state); } } @@ -112,6 +145,67 @@ void Mcp4461Component::loop() { } this->reg_[i].update_terminal = false; } + this->process_nonvolatile_dirty_(); +} + +void Mcp4461Component::process_nonvolatile_dirty_() { + const uint32_t now = millis(); + for (uint8_t i = 0; i < 4; i++) { + if (!this->reg_[i].nonvolatile || !this->reg_[i].nonvolatile_dirty) { + continue; + } + if ((now - this->reg_[i].last_level_change_ms) < this->reg_[i].nonvolatile_write_delay_ms) { + continue; // still settling — debounce window not over yet + } + // Never block the loop on a still-running EEPROM cycle (t_WC up to 10 ms); datasheet: + // during an EEPROM write only volatile commands are accepted. Retry on the next loop. + if (this->is_writing_()) { + continue; + } + // Clear the dirty flag on success — and equally when WP or WiperLock block the write + // permanently, instead of retrying forever. + if (this->store_level_nonvolatile_(static_cast(i)) || this->write_protected_ || + this->reg_[i].wiper_lock_active) { + this->reg_[i].nonvolatile_dirty = false; + } else { + // Transient failure (e.g. I2C error): without this, the retry fires on every single + // loop() iteration, spamming a warning each time. Re-arming the timestamp reuses the + // stability delay as a natural retry backoff. + this->reg_[i].last_level_change_ms = now; + } + } +} + +bool Mcp4461Component::store_level_nonvolatile_(Mcp4461WiperIdx wiper) { + if (this->is_failed()) { + ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_))); + return false; + } + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // E-H ARE the nonvolatile registers — keep this consistent with the other guards + // instead of failing silently (reachable via the store_nonvolatile action). + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } + if (this->reg_[wiper_idx].wiper_lock_active) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); + return false; + } + const uint16_t level = this->reg_[wiper_idx].state; + // Skip the EEPROM cycle entirely when the NV register already holds the value. A failed + // read must NOT count as a match (it returns 0): fall through to the write instead — if + // the bus is really down, the write fails too and the dirty flag stays set for a retry. + bool read_ok = false; + if (this->read_wiper_level_(wiper_idx + 4, &read_ok) == level && read_ok) { + return true; + } + ESP_LOGV(TAG, "Persisting wiper %u level %u to nonvolatile register", wiper_idx, level); + if (!this->mcp4461_write_(this->get_wiper_address_(wiper_idx + 4), level, true)) { + ESP_LOGW(TAG, "Error persisting wiper %u level %u", wiper_idx, level); + return false; + } + return true; } uint8_t Mcp4461Component::get_status_register_() { @@ -210,7 +304,10 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) { return this->read_wiper_level_(wiper_idx); } -uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { +uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx, bool *ok) { + if (ok != nullptr) { + *ok = false; + } uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::READ); if (wiper_idx > 3) { @@ -225,6 +322,9 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); return 0; } + if (ok != nullptr) { + *ok = true; + } return buf; } @@ -265,6 +365,10 @@ bool Mcp4461Component::set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value) { ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value); this->reg_[wiper_idx].state = value; this->reg_[wiper_idx].update_level = true; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -335,6 +439,12 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: increment commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 256) { ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx); return false; @@ -349,6 +459,10 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { return false; } this->reg_[wiper_idx].state++; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -366,6 +480,12 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: decrement commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 0) { ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx); return false; @@ -380,11 +500,18 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { return false; } this->reg_[wiper_idx].state--; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) { - uint8_t i = static_cast(terminal_connector) <= 1 ? 0 : 2; + // TCON0 covers wipers 0/1 (A/B), TCON1 covers wipers 2/3 (C/D). The enum only holds + // 0 and 1, so the old `<= 1 ? 0 : 2` collapsed to always-0 and built TCON1 from + // channels A/B's flags — mirror the (correct) read path in update_terminal_register_(). + uint8_t i = static_cast(terminal_connector) == 0 ? 0 : 2; uint8_t new_value_byte = 0; new_value_byte += static_cast(this->reg_[i].terminal_b); new_value_byte += static_cast(this->reg_[i].terminal_w) << 1; @@ -471,6 +598,12 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // Terminal control only exists for the volatile wipers; loop() would otherwise emit + // an unrelated TCON write and silently drop the request. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': @@ -498,6 +631,10 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index a577a4b482..933d92c1fa 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -17,6 +17,16 @@ struct WiperState { bool wiper_lock_active = false; bool update_level = false; bool update_terminal = false; + // Nonvolatile persistence (volatile wipers 0-3 only): when enabled, every level change is + // mirrored into the chip's NV wiper register after nonvolatile_write_delay of stability, so + // the chip restores it on power-on. The delay both debounces bursts (e.g. light transitions + // writing dozens of levels per second) and protects the EEPROM's limited endurance — + // without it, every intermediate step would cost one of the ~1M erase/write cycles and + // stall the bus for up to t_WC (10 ms) each. + bool nonvolatile = false; + uint32_t nonvolatile_write_delay_ms = 1000; + bool nonvolatile_dirty = false; + uint32_t last_level_change_ms = 0; }; // default wiper state is 128 / 0x80h @@ -86,6 +96,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { /// @param[in] wiper - the wiper to set the value for /// @param[in] initial_value - the initial value in range 0-1.0 as float void set_initial_value(Mcp4461WiperIdx wiper, float initial_value); + /// @brief enable nonvolatile persistence for a volatile wiper (0-3): every level change is + /// mirrored to the corresponding NV wiper register after the given stability delay + /// @param[in] wiper - the (volatile) wiper to persist + /// @param[in] write_delay_ms - stability delay before the NV write (debounce / EEPROM wear) + void set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms); /// @brief public function used to set disable terminal config /// @param[in] wiper - the wiper to set the value for /// @param[in] terminal - the terminal to disable, one of ['a','b','w','h'] @@ -98,7 +113,10 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { bool read_16_(uint8_t address, uint16_t *buf); void update_write_protection_status_(); uint8_t get_wiper_address_(uint8_t wiper); - uint16_t read_wiper_level_(uint8_t wiper); + /// Read a wiper register. On I2C failure returns 0 — callers that must distinguish + /// a real 0 from a failed read pass `ok` (added for the NV read-compare paths, where + /// acting on a failed read would skip a required write or drop a pending persist). + uint16_t read_wiper_level_(uint8_t wiper, bool *ok = nullptr); uint8_t get_status_register_(); uint16_t get_wiper_level_(Mcp4461WiperIdx wiper); bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value); @@ -110,6 +128,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { void enable_terminal_(Mcp4461WiperIdx wiper, char terminal); void disable_terminal_(Mcp4461WiperIdx, char terminal); bool is_writing_(); + /// Copy the current volatile level of wiper 0-3 into its NV register (immediate, blocking + /// only for a pending previous EEPROM cycle). Returns false while WP is active or on error. + bool store_level_nonvolatile_(Mcp4461WiperIdx wiper); + /// Deferred NV mirroring driven from loop() — see WiperState::nonvolatile. + void process_nonvolatile_dirty_(); bool is_eeprom_ready_for_writing_(bool wait_if_not_ready); void write_wiper_level_(uint8_t wiper, uint16_t value); bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false); @@ -139,6 +162,9 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice { return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited."); case MCP4461_STATUS_OK: return LOG_STR("Status OK"); + case MCP4461_PROHIBITED_FOR_NONVOLATILE: + return LOG_STR( + "Increment/decrement, store, and terminal control are prohibited on the nonvolatile wipers (E-H)."); default: return LOG_STR("Unknown"); } diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 0d145d81d3..1642f6149a 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -1,3 +1,4 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv @@ -26,6 +27,43 @@ CHANNEL_OPTIONS = { CONF_TERMINAL_A = "terminal_a" CONF_TERMINAL_B = "terminal_b" CONF_TERMINAL_W = "terminal_w" +CONF_NONVOLATILE = "nonvolatile" +CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" + +# Volatile wiper channels that have a nonvolatile shadow register on the chip +VOLATILE_CHANNELS = ("A", "B", "C", "D") + + +def _validate_nonvolatile(config): + channel = str(config[CONF_CHANNEL]) + + # Channels E-H address the nonvolatile registers directly — the mirroring options only + # make sense for the volatile channels A-D. + if channel not in VOLATILE_CHANNELS: + # Only reject what the user EXPLICITLY asked for and cannot have: enabling the + # mirroring or tuning its delay on E-H. An explicit `nonvolatile: false` is a + # harmless no-op and stays valid; bare configs (no key at all) must keep working. + # NOTE: FINAL_VALIDATE_SCHEMA intentionally mutates `config` in-place (uses setdefault) to apply defaults for callers. + if config.get(CONF_NONVOLATILE) or CONF_NONVOLATILE_WRITE_DELAY in config: + raise cv.Invalid( + f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " + f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" + ) + return config + + config.setdefault(CONF_NONVOLATILE, True) + if config[CONF_NONVOLATILE]: + config.setdefault( + CONF_NONVOLATILE_WRITE_DELAY, + cv.positive_time_period_milliseconds("1s"), + ) + elif CONF_NONVOLATILE_WRITE_DELAY in config: + # Same consistency as the E-H rejection above: never silently ignore user input. + raise cv.Invalid( + f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" + ) + return config + CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -36,9 +74,21 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean, cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean, cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0), + # No schema defaults here: a default would materialize the keys on EVERY channel, + # making existing bare E-H configs fail final validation. The effective defaults + # (nonvolatile: true, delay 1s) are applied for the volatile channels A-D inside + # _validate_nonvolatile instead. Default-on rationale: the chip restores the + # nonvolatile wiper levels at power-on, so persisting every settled level change is + # the least surprising behavior — the pot simply comes back where it was. The write + # is deferred by nonvolatile_write_delay to debounce transitions and protect the + # EEPROM's endurance. + cv.Optional(CONF_NONVOLATILE): cv.boolean, + cv.Optional(CONF_NONVOLATILE_WRITE_DELAY): cv.positive_time_period_milliseconds, } ) +FINAL_VALIDATE_SCHEMA = _validate_nonvolatile + async def to_code(config): parent = await cg.get_variable(config[CONF_MCP4461_ID]) @@ -57,5 +107,71 @@ async def to_code(config): cg.add( parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) ) + if str(config[CONF_CHANNEL]) in VOLATILE_CHANNELS and config[CONF_NONVOLATILE]: + cg.add( + parent.set_nonvolatile( + config[CONF_CHANNEL], + config[CONF_NONVOLATILE_WRITE_DELAY], + ) + ) await output.register_output(var, config) await cg.register_parented(var, config[CONF_MCP4461_ID]) + + +# ---- Actions ---- +WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) +WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) +WiperStoreNonvolatileAction = mcp4461_ns.class_( + "WiperStoreNonvolatileAction", automation.Action +) +WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) + +WIPER_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} +) + +CONF_TERMINAL = "terminal" +CONF_ENABLE = "enable" + +TERMINAL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper), + cv.Required(CONF_TERMINAL): cv.one_of("a", "b", "w", "h", lower=True), + cv.Required(CONF_ENABLE): cv.boolean, + } +) + + +@automation.register_action( + "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.store_nonvolatile", + WiperStoreNonvolatileAction, + WIPER_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.set_terminal", + WiperSetTerminalAction, + TERMINAL_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable( + action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] + ) diff --git a/esphome/components/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h new file mode 100644 index 0000000000..4be317b2f8 --- /dev/null +++ b/esphome/components/mcp4461/output/automation.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "mcp4461_output.h" + +namespace esphome::mcp4461 { + +template class WiperIncreaseAction : public Action { + public: + explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->increase_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperDecreaseAction : public Action { + public: + explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->decrease_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +// Persist the current level to the chip's nonvolatile register immediately — useful with +// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to +// bypass the stability delay of the automatic mirroring. +template class WiperStoreNonvolatileAction : public Action { + public: + explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->store_nonvolatile(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperSetTerminalAction : public Action { + public: + WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) + : wiper_(wiper), terminal_(terminal), enable_(enable) {} + void play(Ts... x) override { + if (this->enable_) { + this->wiper_->enable_terminal(this->terminal_); + } else { + this->wiper_->disable_terminal(this->terminal_); + } + } + + protected: + Mcp4461Wiper *wiper_; + char terminal_; + bool enable_; +}; + +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 3892372cab..5c373ddc7d 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -66,6 +66,12 @@ void Mcp4461Wiper::decrease_wiper() { } } +void Mcp4461Wiper::store_nonvolatile() { + if (this->parent_->store_level_nonvolatile_(this->wiper_)) { + ESP_LOGV(TAG, "Stored wiper %u level to nonvolatile register", static_cast(this->wiper_)); + } +} + void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_terminal_(this->wiper_, terminal); } void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 20d81d825a..c8d1ef1ec5 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -36,6 +36,9 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) @@ -432,7 +430,7 @@ CONFIG_SCHEMA = cv.All( min_channels=1, max_channels=1, ), - cv.Required(CONF_MODELS): cv.ensure_list( + cv.Optional(CONF_MODELS, default=[]): cv.ensure_list( cv.maybe_simple_value(MODEL_SCHEMA, key=CONF_MODEL) ), cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation( @@ -555,6 +553,9 @@ async def to_code(config): # Use the general model loading code for the VAD codegen config[CONF_MODELS].append(vad_model) + # Default feature step size for runtime models + feature_step_size = 10 + for i, model_parameters in enumerate(config[CONF_MODELS]): model_config = model_parameters.get(CONF_MODEL) data = [] @@ -573,6 +574,9 @@ async def to_code(config): manifest[KEY_MICRO][CONF_SLIDING_WINDOW_SIZE], ) + # Update feature step size from manifest + feature_step_size = manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE] + if manifest[KEY_WAKE_WORD] == "vad": cg.add( var.add_vad_model( @@ -602,7 +606,7 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) - cg.add(var.set_features_step_size(manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE])) + cg.add(var.set_features_step_size(feature_step_size)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 237d72229d..3dadb78077 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -9,6 +9,8 @@ #include "esphome/components/audio/audio_transfer_buffer.h" +#include + #ifdef USE_OTA #include "esphome/components/ota/ota_backend.h" #endif @@ -35,21 +37,34 @@ static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // Signals the inference task should stop COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio + COMMAND_PAUSE_MODELS = (1 << 2), // Asks the inference task to pause at a safe point so the model lists can be + // mutated from the main loop TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), TASK_STOPPING = (1 << 5), TASK_STOPPED = (1 << 6), + MODELS_PAUSED = (1 << 7), // Inference task acknowledges it is paused and holds no iterators + COMMAND_RESUME_MODELS = (1 << 8), // Main loop signals the inference task it may resume iterating + ERROR_MEMORY = (1 << 9), ERROR_INFERENCE = (1 << 10), WARNING_FULL_RING_BUFFER = (1 << 13), + WARNING_MODELS_RESUME_TIMEOUT = (1 << 14), // The paused inference task gave up waiting to be released ERROR_BITS = ERROR_MEMORY | ERROR_INFERENCE, ALL_BITS = 0xfffff, // 24 total bits available in an event group }; +// How long the main loop waits for the inference task to acknowledge a pause request before giving up. +// The task checks for the command at the top of its loop, which runs at least every DATA_TIMEOUT_MS. +static const uint32_t MODELS_PAUSE_TIMEOUT_MS = 500; +// How long the paused inference task waits to be resumed before rechecking on its own. Only reached if +// the main loop abandoned the handshake (e.g. it timed out first), so recovery just needs to be bounded. +static const uint32_t MODELS_RESUME_TIMEOUT_MS = 1000; + float MicroWakeWord::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } static const LogString *micro_wake_word_state_to_string(State state) { @@ -176,6 +191,20 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_PAUSE_MODELS) { + // Safe point: no iterators into wake_word_models_ are held here. Acknowledge the pause and wait for the + // main loop to finish mutating the model lists before resuming. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::MODELS_PAUSED); + EventBits_t resume_bits = xEventGroupWaitBits(this_mww->event_group_, EventGroupBits::COMMAND_RESUME_MODELS, + pdTRUE, pdTRUE, pdMS_TO_TICKS(MODELS_RESUME_TIMEOUT_MS)); + if (!(resume_bits & EventGroupBits::COMMAND_RESUME_MODELS)) { + // Nobody released us, so the main loop abandoned the handshake and did not mutate the lists. + // Rechecking the pause command below is safe, but the wait cost a second of detection, so report it. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + } + continue; + } + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { // Producer asked us to drain; run the consumer-side reset from this thread. audio_source->clear_buffered_data(); @@ -232,6 +261,130 @@ std::vector MicroWakeWord::get_wake_words() { void MicroWakeWord::add_wake_word_model(WakeWordModel *model) { this->wake_word_models_.push_back(model); } +bool MicroWakeWord::try_lock_models_() { + // When the inference task isn't running it holds no iterators into wake_word_models_, so the lists can be + // mutated without a handshake. The main loop is the only caller, so this state cannot change between here + // and the matching unlock_models_() call. + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return true; + } + + // The task is running and iterates wake_word_models_. Ask it to pause at a safe point before we mutate. + // Clear any stale acknowledgement from an abandoned handshake first. + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + + EventBits_t bits = xEventGroupWaitBits(this->event_group_, EventGroupBits::MODELS_PAUSED, pdFALSE, pdTRUE, + pdMS_TO_TICKS(MODELS_PAUSE_TIMEOUT_MS)); + + if (!(bits & EventGroupBits::MODELS_PAUSED)) { + // The task never acknowledged (e.g. it is busy stopping). Withdraw the request and refuse to mutate a + // list it might be iterating. + xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + return false; + } + return true; +} + +void MicroWakeWord::unlock_models_() { + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return; // Nothing was paused + } + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED | EventGroupBits::COMMAND_PAUSE_MODELS); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_RESUME_MODELS); +} + +bool MicroWakeWord::add_runtime_model(std::unique_ptr model) { + if (!model) { + ESP_LOGE(TAG, "Cannot add null runtime model"); + return false; + } + + const std::string model_id = model->get_id(); + + // A model without usable data can never load, so keep it out of the lists entirely. Otherwise it would be + // advertised to Home Assistant as selectable and the inference task would silently disable it again every + // time it was enabled. + if (!model->has_model_data()) { + ESP_LOGE(TAG, "Runtime model '%s' has no valid data", model_id.c_str()); + return false; + } + + // Reject a duplicate id against every model (compiled or runtime). The inference task only ever reads + // wake_word_models_, so scanning it here (on the main loop) needs no synchronization. + for (auto *existing : this->wake_word_models_) { + if (existing->get_id() == model_id) { + ESP_LOGW(TAG, "Wake word model '%s' already exists", model_id.c_str()); + return false; + } + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not adding runtime model '%s'", model_id.c_str()); + return false; + } + + this->wake_word_models_.push_back(model.get()); + this->runtime_models_.push_back(std::move(model)); + + this->unlock_models_(); + ESP_LOGD(TAG, "Added runtime model '%s'", model_id.c_str()); + return true; +} + +bool MicroWakeWord::remove_runtime_model(const std::string &model_id) { + // Only runtime-downloaded models can be removed; compiled-in models never appear in runtime_models_. + auto runtime_it = + std::find_if(this->runtime_models_.begin(), this->runtime_models_.end(), + [&model_id](const std::unique_ptr &m) { return m->get_id() == model_id; }); + if (runtime_it == this->runtime_models_.end()) { + return false; + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not removing runtime model '%s'", model_id.c_str()); + return false; + } + + WakeWordModel *raw = runtime_it->get(); + auto models_it = std::find(this->wake_word_models_.begin(), this->wake_word_models_.end(), raw); + if (models_it != this->wake_word_models_.end()) { + this->wake_word_models_.erase(models_it); + } + + // Queued detection events hold a pointer into the model being destroyed, so drop them. The inference task + // is parked, so no new events can be queued concurrently. Losing an undelivered detection from another + // model is acceptable for this rare operation. + xQueueReset(this->detection_queue_); + + // Free the interpreter and arenas (safe: the task is parked, not mid-inference), then destroy the model. + // Its ModelData releases the PSRAM model buffer once the last shared_ptr reference drops. + raw->unload_model(); + this->runtime_models_.erase(runtime_it); + + this->unlock_models_(); + ESP_LOGI(TAG, "Removed runtime model '%s'", model_id.c_str()); + return true; +} + +std::vector MicroWakeWord::get_runtime_model_ids() { + std::vector ids; + ids.reserve(this->runtime_models_.size()); + for (const auto &model : this->runtime_models_) { + ids.push_back(model->get_id()); + } + return ids; +} + +WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { + for (auto *model : this->wake_word_models_) { + if (model->get_id() == model_id) { + return model; + } + } + return nullptr; +} + #ifdef USE_MICRO_WAKE_WORD_VAD void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size) { @@ -270,6 +423,12 @@ void MicroWakeWord::loop() { "word detection accuracy will temporarily be reduced."); } + if (event_group_bits & EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT) { + xEventGroupClearBits(this->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + ESP_LOGW(TAG, "Inference task paused for %" PRIu32 " ms without being released, so it resumed on its own", + MODELS_RESUME_TIMEOUT_MS); + } + if (event_group_bits & EventGroupBits::TASK_STARTING) { ESP_LOGD(TAG, "Inference task has started, attempting to allocate memory for buffers"); xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STARTING); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index aebb5b2595..03f4a86fd4 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -66,6 +66,32 @@ class MicroWakeWord final : public Component void add_wake_word_model(WakeWordModel *model); + /// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop. + /// If the inference task is running it is paused at a safe point before the model lists are mutated, + /// so the task never observes a half-updated vector. + /// Callers should check get_model_by_id() before constructing the model: a WakeWordModel permanently + /// claims a preference backend that is not released when the model is destroyed, so building one only to + /// have it rejected here costs internal RAM that never comes back. + /// @return True if the model was added, false if it has no valid data, on a duplicate id, or if the task + /// could not be paused + bool add_runtime_model(std::unique_ptr model); + + /// @brief Removes a runtime-downloaded wake word model and frees its interpreter, arenas, and model buffer. + /// Must be called from the main loop. If the inference task is running it is paused at a safe point first, + /// and any queued detection events are dropped (they hold pointers into the model being destroyed). + /// @return True if the model was removed, false if the id is not a runtime model or the task could not be paused + bool remove_runtime_model(const std::string &model_id); + + /// @brief Returns the wake word model with the given id, or nullptr if none matches (compiled or runtime). + /// Must be called from the main loop, as the returned pointer is invalidated by remove_runtime_model(). + WakeWordModel *get_model_by_id(const std::string &model_id); + + /// @brief Returns the ids of all runtime-downloaded models. Must be called from the main loop. + std::vector get_runtime_model_ids(); + + /// @brief Returns the feature step size (ms) the frontend is configured for. Runtime models must match it. + uint8_t get_features_step_size() const { return this->features_step_size_; } + #ifdef USE_MICRO_WAKE_WORD_VAD void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size); @@ -85,6 +111,7 @@ class MicroWakeWord final : public Component std::weak_ptr ring_buffer_; std::vector wake_word_models_; + std::vector> runtime_models_; #ifdef USE_MICRO_WAKE_WORD_VAD std::unique_ptr vad_model_; @@ -119,6 +146,13 @@ class MicroWakeWord final : public Component /// @brief Resumes the inference task void resume_task_(); + /// @brief Parks the inference task at a safe point (or verifies it isn't running) so the model lists may be + /// mutated from the main loop. Every successful call must be paired with unlock_models_(). + /// @return True if the lists may be mutated, false if the running task never acknowledged the pause request + bool try_lock_models_(); + /// @brief Releases the inference task parked by a successful try_lock_models_() call + void unlock_models_(); + void set_state_(State state); /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp new file mode 100644 index 0000000000..a7326ab77a --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -0,0 +1,100 @@ +#include "model_data.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::micro_wake_word { + +static const char *const TAG = "micro_wake_word"; + +ModelData::~ModelData() { this->deallocate_(); } + +bool ModelData::allocate(size_t size) { + // Reject up front: reallocating to zero frees the buffer and returns null, which would leave data_ pointing at + // freed memory. A zero-length model is never usable anyway. + if (size == 0) { + ESP_LOGE(TAG, "Refusing to allocate a zero-length model"); + return false; + } + + // Already allocated, so reallocate to the new size + if (this->data_) { + uint8_t *new_allocation = this->allocator_.reallocate(this->data_, size); + if (new_allocation == nullptr) { + ESP_LOGE(TAG, "Failed to reallocate %zu bytes", size); + return false; + } + this->data_ = new_allocation; + this->size_ = size; + this->valid_ = false; // Need to revalidate with new data + return true; + } + + // Try to allocate in PSRAM first + this->data_ = this->allocator_.allocate(size); + if (this->data_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes", size); + return false; + } + + this->size_ = size; + this->valid_ = false; + return true; +} + +void ModelData::deallocate_() { + if (this->data_ != nullptr) { + this->allocator_.deallocate(this->data_, this->size_); + this->data_ = nullptr; + this->size_ = 0; + this->valid_ = false; + } +} + +const uint8_t *ModelData::get_model_pointer() const { return this->valid_ ? this->data_ : nullptr; } + +uint8_t *ModelData::get_write_pointer() { + this->valid_ = false; // Mark invalid while writing + return this->data_; +} + +bool ModelData::validate_and_mark_ready() { + // The magic number lives in bytes 4-7, so we need at least 8 bytes to read it. + if (!this->data_ || this->size_ < 8) { + ESP_LOGE(TAG, "Model data is null or too small"); + return false; + } + + // Check TFLite magic number "TFL3" in bytes 4-7 + if (memcmp(this->data_ + 4, "TFL3", 4) != 0) { + ESP_LOGE(TAG, "Invalid TFLite model magic number"); + return false; + } + + // Bytes 0-3 hold the offset of the root table. tflite::GetModel only adds that offset to the start of the + // buffer, so check it lands inside the buffer before reading through it. + uint32_t root_offset; + memcpy(&root_offset, this->data_, sizeof(root_offset)); + if (root_offset >= this->size_) { + ESP_LOGE(TAG, "TFLite model root offset is out of bounds"); + return false; + } + + const tflite::Model *model = tflite::GetModel(this->data_); + if (model->version() != TFLITE_SCHEMA_VERSION) { + ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version()); + return false; + } + + this->valid_ = true; + return true; +} + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/model_data.h b/esphome/components/micro_wake_word/model_data.h new file mode 100644 index 0000000000..0f0e08c718 --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.h @@ -0,0 +1,60 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome::micro_wake_word { + +// Owns the buffer holding a runtime-downloaded TFLite model. The buffer prefers PSRAM but falls back to +// internal RAM, so a device without PSRAM can still hold a single model. It is filled over HTTP, checked +// for integrity by the caller (SHA256) and for a usable TFLite header here, then kept alive for the +// lifetime of the WakeWordModel that uses it. Only ever held behind a std::shared_ptr, so copies and +// moves are disabled. +class ModelData { + public: + ModelData() = default; + ~ModelData(); + + // Non-copyable, non-movable + ModelData(const ModelData &) = delete; + ModelData &operator=(const ModelData &) = delete; + ModelData(ModelData &&) = delete; + ModelData &operator=(ModelData &&) = delete; + + // Allocate memory for model + bool allocate(size_t size); + + // Get stable pointer for TFLite (only valid after validate_and_mark_ready()) + const uint8_t *get_model_pointer() const; + + // Get writable pointer for downloading (invalidates the model) + uint8_t *get_write_pointer(); + + // Validate TFLite model and mark as ready for use + bool validate_and_mark_ready(); + + // Check if model is valid and ready for use + bool is_valid() const { return this->valid_; } + + // Get size of model data + size_t size() const { return this->size_; } + + // Check if memory is allocated + bool is_allocated() const { return this->data_ != nullptr; } + + protected: + // Deallocate memory + void deallocate_(); + + uint8_t *data_{nullptr}; + size_t size_{0}; + bool valid_{false}; + RAMAllocator allocator_{RAMAllocator::NONE}; +}; + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 1cdc06b352..72984f04fb 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -26,6 +26,11 @@ void VADModel::log_model_config() { } bool StreamingModel::load_model_() { + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Streaming model has no data to load"); + return false; + } + RAMAllocator arena_allocator; if (this->var_arena_ == nullptr) { @@ -188,6 +193,13 @@ void StreamingModel::unload_model() { } bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE]) { + if (this->model_start_ == nullptr) { + // No usable model data, and that cannot change for this object. Skip the model instead of reporting a + // failure, because a false return here stops the inference task for every other model too. + this->enabled_ = false; + return true; + } + if (this->enabled_ && !this->loaded_) { // Model is enabled but isn't loaded if (!this->load_model_()) { @@ -269,6 +281,41 @@ WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, } }; +WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr model_data, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, std::vector trained_languages, + size_t tensor_arena_size) { + this->id_ = id; + this->model_data_ = std::move(model_data); + // Callers are expected to pass a validated buffer, so this is normally the stable model pointer. Tolerate a + // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is + // never loaded. + this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); + } + this->default_probability_cutoff_ = default_probability_cutoff; + this->probability_cutoff_ = default_probability_cutoff; + this->sliding_window_size_ = sliding_window_average_size; + this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0); + this->wake_word_ = wake_word; + this->trained_languages_ = std::move(trained_languages); + this->tensor_arena_size_ = tensor_arena_size; + this->register_streaming_ops_(this->streaming_op_resolver_); + this->current_stride_step_ = 0; + this->internal_only_ = false; // Runtime models are always exposed to Home Assistant + + this->pref_ = global_preferences->make_preference(fnv1_hash(id)); + bool enabled; + if (this->pref_.load(&enabled)) { + // Use the enabled state loaded from flash + this->enabled_ = enabled; + } else { + // No saved state: stay disabled. The activation flow calls enable() explicitly after adding. + this->enabled_ = false; + } +}; + void WakeWordModel::enable() { this->enabled_ = true; if (!this->internal_only_) { diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 07ba78d1f4..1cb9d6eba5 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -3,9 +3,11 @@ #ifdef USE_ESP32 #include "preprocessor_settings.h" +#include "model_data.h" #include "esphome/core/preferences.h" +#include #include #include #include @@ -27,6 +29,10 @@ struct DetectionEvent { class StreamingModel { public: + // Runtime models are heap owned and destroyed while the device is running, so freeing the arenas cannot + // depend on the owner calling unload_model() first. unload_model() is not virtual and is safe to repeat. + virtual ~StreamingModel() { this->unload_model(); } + virtual void log_model_config() = 0; virtual DetectionEvent determine_detected() = 0; @@ -51,6 +57,9 @@ class StreamingModel { /// @brief Return true if the model is enabled. bool is_enabled() const { return this->enabled_; } + /// @brief Return true if the model has usable data. A model without it can never be loaded or run. + bool has_model_data() const { return this->model_start_ != nullptr; } + bool get_unprocessed_probability_status() const { return this->unprocessed_probability_status_; } // Quantized probability cutoffs mapping 0.0 - 1.0 to 0 - 255 @@ -86,7 +95,7 @@ class StreamingModel { size_t tensor_arena_size_; std::vector recent_streaming_probabilities_; - const uint8_t *model_start_; + const uint8_t *model_start_{nullptr}; uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -96,7 +105,7 @@ class StreamingModel { class WakeWordModel final : public StreamingModel { public: - /// @brief Constructs a wake word model object + /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said @@ -110,6 +119,23 @@ class WakeWordModel final : public StreamingModel { size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, bool internal_only); + /// @brief Constructs a wake word model object with a runtime-downloaded model + /// @param id (std::string) identifier for this model + /// @param model_data (std::shared_ptr) owning handle to the downloaded model buffer; must be valid + /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said + /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling + /// probability + /// @param wake_word (std::string) Friendly name of the wake word + /// @param trained_languages (std::vector) Languages the model was trained on + /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena + WakeWordModel(const std::string &id, std::shared_ptr model_data, uint8_t default_probability_cutoff, + size_t sliding_window_average_size, const std::string &wake_word, + std::vector trained_languages, size_t tensor_arena_size); + + // model_data_ is a member of this class, so it is destroyed before ~StreamingModel() runs. Unload here, while + // the buffer is still alive, so the interpreter is never torn down over freed model data. + ~WakeWordModel() override { this->unload_model(); } + void log_model_config() override; /// @brief Checks for the wake word by comparing the mean probability in the sliding window with the probability @@ -132,6 +158,10 @@ class WakeWordModel final : public StreamingModel { bool get_internal_only() { return this->internal_only_; } protected: + // Kept for runtime-downloaded models so the model buffer stays alive for the model's lifetime. + // Null for compiled-in models (their data lives in flash). + std::shared_ptr model_data_; + std::string id_; std::string wake_word_; std::vector trained_languages_; diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 2f4ef5c948..30771d25ca 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/log.h" #include "ac_adapter.h" @@ -172,4 +172,4 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index a7924ae51e..4a888ee8ff 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -44,4 +44,4 @@ class Converters { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index acd9191916..9572ec6c65 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/automation.h" #include "air_conditioner.h" @@ -63,4 +63,4 @@ template class PowerToggleAction : public MideaActionBase } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 7603dd5254..24bbfe76b0 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -197,4 +197,4 @@ void AirConditioner::do_display_toggle() { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index bea6c2eadb..9977d2088f 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -61,4 +61,4 @@ class AirConditioner final : public ApplianceBase #include +#include // Include global defines #include "esphome/core/defines.h" @@ -17,6 +18,13 @@ namespace esphome::midea { +// Mirrors the ARDUINO switch in MideaUART Helpers/Platform.h: these types +// exist in the dudanov namespace exactly when the library is not on Arduino +#ifndef ARDUINO +using dudanov::Stream; +using dudanov::String; +#endif + /* Stream from UART component */ class UARTStream : public Stream { public: @@ -99,4 +107,4 @@ template class ApplianceBase : public Component { } // namespace esphome::midea -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 4a75464b90..0e03bca233 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,6 +25,8 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, + PLATFORM_ESP32, + PLATFORM_ESP8266, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -151,7 +153,12 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_with_arduino, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + ] + ), ) # Actions @@ -297,7 +304,14 @@ async def to_code(config): if CONF_HUMIDITY_SETPOINT in config: sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) cg.add(var.set_humidity_setpoint_sensor(sens)) - # MideaUART library requires WiFi (WiFi auto-enables Network via dependency mapping) - if CORE.is_esp32: + # MideaUART uses the Arduino WiFi API for the network-notify frame + # (WiFi auto-enables Network via dependency mapping). On ESP-IDF the + # library talks to esp_wifi directly, so no library entry is needed. + if CORE.is_esp32 and CORE.using_arduino: cg.add_library("WiFi", None) - cg.add_library("dudanov/MideaUART", "1.1.9") + # Using the repository until a release containing ESP-IDF support is published + cg.add_library( + name="MideaUART", + version=None, + repository="https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815", + ) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index f11682230d..e54df1fd70 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" @@ -85,5 +85,5 @@ class IrTransmitter { } // namespace esphome::midea -#endif -#endif // USE_ARDUINO +#endif // USE_REMOTE_TRANSMITTER +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 31a2b0ce1a..914361a4ac 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -318,4 +318,232 @@ DsiDriverChip( (0xE0, 0x00), ] ) + +# JC8012P4A1 V2 Driver Configuration (jd9365) +# Some units of this model have a different LCD panel but still use the same JD9365 driver chip. +# Using parameters from esp_lcd_jd9365.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 800x1280 +# * PCLK Frequency: 70 MHz +# * DSI Lane Bit Rate: 1.5 Gbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) +# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=10, vsync_front_porch=20) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC8012P4A1-V2", + width=800, + height=1280, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=10, + vsync_pulse_width=4, + vsync_front_porch=20, + pclk_frequency="70MHz", + lane_bit_rate="1500Mbps", + color_order="RGB", + reset_pin=27, + initsequence=[ + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + (0xE0, 0x01), + (0x00, 0x00), + (0x01, 0x44), + (0x03, 0x10), + (0x04, 0x38), + (0x0C, 0x74), + (0x17, 0x00), + (0x18, 0xAF), + (0x19, 0x00), + (0x1A, 0x00), + (0x1B, 0xAF), + (0x1C, 0x00), + (0x35, 0x26), + (0x37, 0x09), + (0x38, 0x04), + (0x39, 0x00), + (0x3A, 0x01), + (0x3C, 0x78), + (0x3D, 0xFF), + (0x3E, 0xFF), + (0x3F, 0x7F), + (0x40, 0x06), + (0x41, 0xA0), + (0x42, 0x81), + (0x43, 0x1E), + (0x44, 0x0D), + (0x45, 0x28), + (0x55, 0x02), + (0x57, 0x69), + (0x59, 0x0A), + (0x5A, 0x2A), + (0x5B, 0x17), + (0x5D, 0x7F), + (0x5E, 0x6B), + (0x5F, 0x5C), + (0x60, 0x50), + (0x61, 0x4C), + (0x62, 0x3E), + (0x63, 0x41), + (0x64, 0x2B), + (0x65, 0x43), + (0x66, 0x42), + (0x67, 0x43), + (0x68, 0x62), + (0x69, 0x52), + (0x6A, 0x5A), + (0x6B, 0x4C), + (0x6C, 0x48), + (0x6D, 0x3A), + (0x6E, 0x28), + (0x6F, 0x10), + (0x70, 0x7F), + (0x71, 0x6B), + (0x72, 0x5C), + (0x73, 0x50), + (0x74, 0x4C), + (0x75, 0x3E), + (0x76, 0x41), + (0x77, 0x2B), + (0x78, 0x43), + (0x79, 0x42), + (0x7A, 0x43), + (0x7B, 0x62), + (0x7C, 0x52), + (0x7D, 0x5A), + (0x7E, 0x4C), + (0x7F, 0x48), + (0x80, 0x3A), + (0x81, 0x28), + (0x82, 0x10), + (0xE0, 0x02), + (0x00, 0x42), + (0x01, 0x42), + (0x02, 0x40), + (0x03, 0x40), + (0x04, 0x5E), + (0x05, 0x5E), + (0x06, 0x5F), + (0x07, 0x5F), + (0x08, 0x5F), + (0x09, 0x57), + (0x0A, 0x57), + (0x0B, 0x77), + (0x0C, 0x77), + (0x0D, 0x47), + (0x0E, 0x47), + (0x0F, 0x45), + (0x10, 0x45), + (0x11, 0x4B), + (0x12, 0x4B), + (0x13, 0x49), + (0x14, 0x49), + (0x15, 0x5F), + (0x16, 0x41), + (0x17, 0x41), + (0x18, 0x40), + (0x19, 0x40), + (0x1A, 0x5E), + (0x1B, 0x5E), + (0x1C, 0x5F), + (0x1D, 0x5F), + (0x1E, 0x5F), + (0x1F, 0x57), + (0x20, 0x57), + (0x21, 0x77), + (0x22, 0x77), + (0x23, 0x46), + (0x24, 0x46), + (0x25, 0x44), + (0x26, 0x44), + (0x27, 0x4A), + (0x28, 0x4A), + (0x29, 0x48), + (0x2A, 0x48), + (0x2B, 0x5F), + (0x2C, 0x01), + (0x2D, 0x01), + (0x2E, 0x00), + (0x2F, 0x00), + (0x30, 0x1F), + (0x31, 0x1F), + (0x32, 0x1E), + (0x33, 0x1E), + (0x34, 0x1F), + (0x35, 0x17), + (0x36, 0x17), + (0x37, 0x37), + (0x38, 0x37), + (0x39, 0x08), + (0x3A, 0x08), + (0x3B, 0x0A), + (0x3C, 0x0A), + (0x3D, 0x04), + (0x3E, 0x04), + (0x3F, 0x06), + (0x40, 0x06), + (0x41, 0x1F), + (0x42, 0x02), + (0x43, 0x02), + (0x44, 0x00), + (0x45, 0x00), + (0x46, 0x1F), + (0x47, 0x1F), + (0x48, 0x1E), + (0x49, 0x1E), + (0x4A, 0x1F), + (0x4B, 0x17), + (0x4C, 0x17), + (0x4D, 0x37), + (0x4E, 0x37), + (0x4F, 0x09), + (0x50, 0x09), + (0x51, 0x0B), + (0x52, 0x0B), + (0x53, 0x05), + (0x54, 0x05), + (0x55, 0x07), + (0x56, 0x07), + (0x57, 0x1F), + (0x58, 0x40), + (0x5B, 0x30), + (0x5C, 0x00), + (0x5D, 0x34), + (0x5E, 0x05), + (0x5F, 0x02), + (0x63, 0x00), + (0x64, 0x6A), + (0x67, 0x73), + (0x68, 0x07), + (0x69, 0x08), + (0x6A, 0x6A), + (0x6B, 0x08), + (0x6C, 0x00), + (0x6D, 0x00), + (0x6E, 0x00), + (0x6F, 0x88), + (0x75, 0xFF), + (0x77, 0xDD), + (0x78, 0x2C), + (0x79, 0x15), + (0x7A, 0x17), + (0x7D, 0x14), + (0x7E, 0x82), + (0xE0, 0x04), + (0x00, 0x0E), + (0x02, 0xB3), + (0x09, 0x60), + (0x0E, 0x48), + (0x37, 0x58), + (0x2B, 0x0F), + (0xE0, 0x05), + (0x15, 0x1D), + (0xE0, 0x00), + (0xE6, 0x02), + (0xE7, 0x0C) + ] +) # fmt: on diff --git a/esphome/components/mipi_spi/models/st77916.py b/esphome/components/mipi_spi/models/st77916.py new file mode 100644 index 0000000000..38852f135f --- /dev/null +++ b/esphome/components/mipi_spi/models/st77916.py @@ -0,0 +1,269 @@ +# Init sequence sourced from Espressif's esp-bsp repository: +# https://github.com/espressif/esp-bsp/blob/master/bsp/esp_vocat/priv_include/disp_init_data.h +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from esphome.components.mipi import MODE_RGB, DriverChip +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_INVERTED, CONF_NUMBER + +# Init sequence for the ST77916 QSPI display on the ESP-VoCat v1.2 board. +# Source: disp_init_data.h from espressif/esp-bsp (bsp/esp_vocat/priv_include). +# The standard INVON/INVOFF and SLPOUT+DISPON commands (and required delays) are omitted because +# the mipi_spi framework appends them automatically based on the invert_colors and no_slpout settings. +# (So this sequence only contains panel-specific setup commands.) +_ESP_VOCAT_INIT = ( + # Page 1a — startup unlock + (0xF0, 0x28), + (0xF2, 0x28), + (0x73, 0xF0), + (0x7C, 0xD1), + (0x83, 0xE0), + (0x84, 0x61), + (0xF2, 0x82), + # Switch to page 0 → page 1 + (0xF0, 0x00), + (0xF0, 0x01), + (0xF1, 0x01), + # Power settings (Bx) + (0xB0, 0x56), + (0xB1, 0x4D), + (0xB2, 0x24), + (0xB4, 0x87), + (0xB5, 0x44), + (0xB6, 0x8B), + (0xB7, 0x40), + (0xB8, 0x86), + (0xBA, 0x00), + (0xBB, 0x08), + (0xBC, 0x08), + (0xBD, 0x00), + # VCOM / gate settings (Cx) + (0xC0, 0x80), + (0xC1, 0x10), + (0xC2, 0x37), + (0xC3, 0x80), + (0xC4, 0x10), + (0xC5, 0x37), + (0xC6, 0xA9), + (0xC7, 0x41), + (0xC8, 0x01), + (0xC9, 0xA9), + (0xCA, 0x41), + (0xCB, 0x01), + # Source settings (Dx) + (0xD0, 0x91), + (0xD1, 0x68), + (0xD2, 0x68), + # Misc + (0xF5, 0x00, 0xA5), + (0xDD, 0x4F), + (0xDE, 0x4F), + (0xF1, 0x10), + (0xF0, 0x00), + # Switch to page 2 — gamma + (0xF0, 0x02), + ( + 0xE0, + 0xF0, + 0x0A, + 0x10, + 0x09, + 0x09, + 0x36, + 0x35, + 0x33, + 0x4A, + 0x29, + 0x15, + 0x15, + 0x2E, + 0x34, + ), + ( + 0xE1, + 0xF0, + 0x0A, + 0x0F, + 0x08, + 0x08, + 0x05, + 0x34, + 0x33, + 0x4A, + 0x39, + 0x15, + 0x15, + 0x2D, + 0x33, + ), + # Switch to page 10 — GIP / timing + (0xF0, 0x10), + (0xF3, 0x10), + # Page 10: Exxx + (0xE0, 0x07), + (0xE1, 0x00), + (0xE2, 0x00), + (0xE3, 0x00), + (0xE4, 0xE0), + (0xE5, 0x06), + (0xE6, 0x21), + (0xE7, 0x01), + (0xE8, 0x05), + (0xE9, 0x02), + (0xEA, 0xDA), + (0xEB, 0x00), + (0xEC, 0x00), + (0xED, 0x0F), + (0xEE, 0x00), + (0xEF, 0x00), + # Page 10: Fxxx + (0xF8, 0x00), + (0xF9, 0x00), + (0xFA, 0x00), + (0xFB, 0x00), + (0xFC, 0x00), + (0xFD, 0x00), + (0xFE, 0x00), + (0xFF, 0x00), + # GIP section A (0x60–0x6B) + (0x60, 0x40), + (0x61, 0x04), + (0x62, 0x00), + (0x63, 0x42), + (0x64, 0xD9), + (0x65, 0x00), + (0x66, 0x00), + (0x67, 0x00), + (0x68, 0x00), + (0x69, 0x00), + (0x6A, 0x00), + (0x6B, 0x00), + # GIP section B (0x70–0x7B) + (0x70, 0x40), + (0x71, 0x03), + (0x72, 0x00), + (0x73, 0x42), + (0x74, 0xD8), + (0x75, 0x00), + (0x76, 0x00), + (0x77, 0x00), + (0x78, 0x00), + (0x79, 0x00), + (0x7A, 0x00), + (0x7B, 0x00), + # GIP timing (0x80–0x9F) + (0x80, 0x48), + (0x81, 0x00), + (0x82, 0x06), + (0x83, 0x02), + (0x84, 0xD6), + (0x85, 0x04), + (0x86, 0x00), + (0x87, 0x00), + (0x88, 0x48), + (0x89, 0x00), + (0x8A, 0x08), + (0x8B, 0x02), + (0x8C, 0xD8), + (0x8D, 0x04), + (0x8E, 0x00), + (0x8F, 0x00), + (0x90, 0x48), + (0x91, 0x00), + (0x92, 0x0A), + (0x93, 0x02), + (0x94, 0xDA), + (0x95, 0x04), + (0x96, 0x00), + (0x97, 0x00), + (0x98, 0x48), + (0x99, 0x00), + (0x9A, 0x0C), + (0x9B, 0x02), + (0x9C, 0xDC), + (0x9D, 0x04), + (0x9E, 0x00), + (0x9F, 0x00), + # GIP timing (0xA0–0xBF) + (0xA0, 0x48), + (0xA1, 0x00), + (0xA2, 0x05), + (0xA3, 0x02), + (0xA4, 0xD5), + (0xA5, 0x04), + (0xA6, 0x00), + (0xA7, 0x00), + (0xA8, 0x48), + (0xA9, 0x00), + (0xAA, 0x07), + (0xAB, 0x02), + (0xAC, 0xD7), + (0xAD, 0x04), + (0xAE, 0x00), + (0xAF, 0x00), + (0xB0, 0x48), + (0xB1, 0x00), + (0xB2, 0x09), + (0xB3, 0x02), + (0xB4, 0xD9), + (0xB5, 0x04), + (0xB6, 0x00), + (0xB7, 0x00), + (0xB8, 0x48), + (0xB9, 0x00), + (0xBA, 0x0B), + (0xBB, 0x02), + (0xBC, 0xDB), + (0xBD, 0x04), + (0xBE, 0x00), + (0xBF, 0x00), + # Source timing (0xC0–0xC9) + (0xC0, 0x10), + (0xC1, 0x47), + (0xC2, 0x56), + (0xC3, 0x65), + (0xC4, 0x74), + (0xC5, 0x88), + (0xC6, 0x99), + (0xC7, 0x01), + (0xC8, 0xBB), + (0xC9, 0xAA), + # Source timing (0xD0–0xD9) + (0xD0, 0x10), + (0xD1, 0x47), + (0xD2, 0x56), + (0xD3, 0x65), + (0xD4, 0x74), + (0xD5, 0x88), + (0xD6, 0x99), + (0xD7, 0x01), + (0xD8, 0xBB), + (0xD9, 0xAA), + # Finalise page 10, return to page 0 + (0xF3, 0x01), + (0xF0, 0x00), + # INVON (0x21) and SLPOUT (0x11) are appended by the framework. +) + +DriverChip( + "ESP-VOCAT", + width=360, + height=360, + # SPI pins for the ESP-VoCat v1.2 board. + # PCLK (GPIO18) and data pins (GPIO46/13/11/12) are configured on the spi: bus. + cs_pin=14, + # RST is active-HIGH on this panel; invert the ESPHome pin so the framework's + # active-low reset pulse (HIGH→LOW→HIGH) maps to the correct physical sequence + # (LOW→HIGH→LOW) on the wire. + reset_pin={CONF_NUMBER: 47, CONF_INVERTED: True}, + # Note: GPIO9 behaviour varies by board revision (may be POWER_CTRL, not LCD_EN). + # Do not set a default enable_pin — manage LCD power in on_boot if needed. + # GPIO44 is the backlight; manage it separately via an output: or light:. + bus_mode=TYPE_QUAD, + data_rate="80MHz", + invert_colors=True, + color_order=MODE_RGB, + requires={"psram"}, + initsequence=_ESP_VOCAT_INIT, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 0caae5b939..bdd0c3c90b 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -203,6 +203,54 @@ AXS15231.extend( requires={"psram"}, ) +# Init sequence and pins taken from the vendor demo: +# "ESP32-S3-Touch-LCD-3.5B-Demo/Arduino/examples/09_lvgl_arduino_v8" +# The panel has no reset line (demo passes RST = -1); the sleep-out, display-on +# and first RAM write are issued by the framework, so they are omitted here. +# fmt: off +AXS15231.extend( + "WAVESHARE-ESP32-S3-TOUCH-LCD-3.5B", + width=320, + height=480, + # Vendor demo runs the AXS15231B at 32MHz; the ESP32 SPI clock cannot hit + # that exactly, data sheet says max 50MHz, so use 40MHz (proven on the same controller, JC3248W535) + data_rate="40MHz", + cs_pin=12, + requires={"psram"}, + initsequence=( + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), + (0xA0, 0xC0, 0x10, 0x00, 0x02, 0x00, 0x00, 0x04, 0x3F, 0x20, 0x05, 0x3F, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xA2, 0x30, 0x3C, 0x24, 0x14, 0xD0, 0x20, 0xFF, 0xE0, 0x40, 0x19, 0x80, 0x80, 0x80, 0x20, 0xF9, 0x10, 0x02, 0xFF, 0xFF, 0xF0, 0x90, 0x01, 0x32, 0xA0, 0x91, 0xE0, 0x20, 0x7F, 0xFF, 0x00, 0x5A), + (0xD0, 0xE0, 0x40, 0x51, 0x24, 0x08, 0x05, 0x10, 0x01, 0x20, 0x15, 0xC2, 0x42, 0x22, 0x22, 0xAA, 0x03, 0x10, 0x12, 0x60, 0x14, 0x1E, 0x51, 0x15, 0x00, 0x8A, 0x20, 0x00, 0x03, 0x3A, 0x12), + (0xA3, 0xA0, 0x06, 0xAA, 0x00, 0x08, 0x02, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x55, 0x55), + (0xC1, 0x31, 0x04, 0x02, 0x02, 0x71, 0x05, 0x24, 0x55, 0x02, 0x00, 0x41, 0x00, 0x53, 0xFF, 0xFF, 0xFF, 0x4F, 0x52, 0x00, 0x4F, 0x52, 0x00, 0x45, 0x3B, 0x0B, 0x02, 0x0D, 0x00, 0xFF, 0x40), + (0xC3, 0x00, 0x00, 0x00, 0x50, 0x03, 0x00, 0x00, 0x00, 0x01, 0x80, 0x01), + (0xC4, 0x00, 0x24, 0x33, 0x80, 0x00, 0xEA, 0x64, 0x32, 0xC8, 0x64, 0xC8, 0x32, 0x90, 0x90, 0x11, 0x06, 0xDC, 0xFA, 0x00, 0x00, 0x80, 0xFE, 0x10, 0x10, 0x00, 0x0A, 0x0A, 0x44, 0x50), + (0xC5, 0x18, 0x00, 0x00, 0x03, 0xFE, 0x3A, 0x4A, 0x20, 0x30, 0x10, 0x88, 0xDE, 0x0D, 0x08, 0x0F, 0x0F, 0x01, 0x3A, 0x4A, 0x20, 0x10, 0x10, 0x00), + (0xC6, 0x05, 0x0A, 0x05, 0x0A, 0x00, 0xE0, 0x2E, 0x0B, 0x12, 0x22, 0x12, 0x22, 0x01, 0x03, 0x00, 0x3F, 0x6A, 0x18, 0xC8, 0x22), + (0xC7, 0x50, 0x32, 0x28, 0x00, 0xA2, 0x80, 0x8F, 0x00, 0x80, 0xFF, 0x07, 0x11, 0x9C, 0x67, 0xFF, 0x24, 0x0C, 0x0D, 0x0E, 0x0F), + (0xC9, 0x33, 0x44, 0x44, 0x01), + (0xCF, 0x2C, 0x1E, 0x88, 0x58, 0x13, 0x18, 0x56, 0x18, 0x1E, 0x68, 0x88, 0x00, 0x65, 0x09, 0x22, 0xC4, 0x0C, 0x77, 0x22, 0x44, 0xAA, 0x55, 0x08, 0x08, 0x12, 0xA0, 0x08), + (0xD5, 0x40, 0x8E, 0x8D, 0x01, 0x35, 0x04, 0x92, 0x74, 0x04, 0x92, 0x74, 0x04, 0x08, 0x6A, 0x04, 0x46, 0x03, 0x03, 0x03, 0x03, 0x82, 0x01, 0x03, 0x00, 0xE0, 0x51, 0xA1, 0x00, 0x00, 0x00), + (0xD6, 0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x93, 0x00, 0x01, 0x83, 0x07, 0x07, 0x00, 0x07, 0x07, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0x84, 0x00, 0x20, 0x01, 0x00), + (0xD7, 0x03, 0x01, 0x0B, 0x09, 0x0F, 0x0D, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19, 0x40, 0x8E, 0x04, 0x00, 0x20, 0xA0, 0x1F), + (0xD8, 0x02, 0x00, 0x0A, 0x08, 0x0E, 0x0C, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19), + (0xD9, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDD, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDF, 0x44, 0x73, 0x4B, 0x69, 0x00, 0x0A, 0x02, 0x90), + (0xE0, 0x3B, 0x28, 0x10, 0x16, 0x0C, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x13, 0x2C, 0x33, 0x28, 0x0D), + (0xE1, 0x37, 0x28, 0x10, 0x16, 0x0B, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x14, 0x2C, 0x33, 0x28, 0x0F), + (0xE2, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE3, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x32, 0x2F, 0x0F), + (0xE4, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE5, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0F), + (0xA4, 0x85, 0x85, 0x95, 0x82, 0xAF, 0xAA, 0xAA, 0x80, 0x10, 0x30, 0x40, 0x40, 0x20, 0xFF, 0x60, 0x30), + (0xA4, 0x85, 0x85, 0x95, 0x85), + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + ), +) +# fmt: on + # Waveshare 1.83-v2 # # Do not use on 1.83-v1: Vendor warning on different chip! diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index e69de29bb2..450d1cd222 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -0,0 +1,247 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_ON_STATE, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) +from esphome.core import ID, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@crnjan"] +DEPENDENCIES = ["uart"] +DOMAIN = "mitsubishi_cn105" + +CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" +CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" +CONF_VANE = "vane" +CONF_VERTICAL = "vertical" + +mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) + +MitsubishiCN105Component = mitsubishi_ns.class_( + "MitsubishiCN105Component", + cg.Component, + uart.UARTDevice, +) + +VaneState = mitsubishi_ns.struct("VaneState") +VaneCall = mitsubishi_ns.class_("VaneCall") +VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") + +# The insertion order must match VALUES in +# select/mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = { + "AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO, + "1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1, + "2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2, + "3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3, + "4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4, + "5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5, + "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, +} + +SetRemoteTemperatureAction = mitsubishi_ns.class_( + "SetRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +ClearRemoteTemperatureAction = mitsubishi_ns.class_( + "ClearRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +VaneControlAction = mitsubishi_ns.class_( + "VaneControlAction", + automation.Action, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MitsubishiCN105Component), + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" + ): cv.update_interval, + cv.Optional(CONF_VANE): cv.Schema( + { + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component), + } +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + DOMAIN, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) + cg.add(var.set_parent(parent)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add( + var.set_telemetry_request_min_interval( + config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] + ) + ) + if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): + cg.add_global(mitsubishi_ns.using) + for conf in on_state: + await automation.build_callback_automation( + var, + "add_on_vane_state_callback", + [(VaneState.operator("const").operator("ref"), "x")], + conf, + ) + + +REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + } +) + + +@automation.register_action( + f"{DOMAIN}.set_remote_temperature", + SetRemoteTemperatureAction, + REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def remote_temperature_action_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]) + temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) + cg.add(var.set_temperature(temperature)) + return var + + +@automation.register_action( + f"{DOMAIN}.clear_remote_temperature", + ClearRemoteTemperatureAction, + CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def clear_temperature_action_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 + + +VANE_CONTROL_FIELDS = ( + ( + (CONF_VERTICAL, CONF_DIRECTION), + "vertical.set_direction", + VerticalVaneMode, + ), +) + +VANE_CONTROL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Optional(CONF_VERTICAL): cv.Schema( + { + cv.Optional(CONF_DIRECTION): cv.templatable( + cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True) + ), + } + ), + } +) + + +@automation.register_action( + f"{DOMAIN}.vane.control", + VaneControlAction, + VANE_CONTROL_ACTION_SCHEMA, + synchronous=True, +) +async def vane_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_global(mitsubishi_ns.using) + parent = await cg.get_variable(config[CONF_ID]) + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) + for t, name in args + ] + forwarded_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for path, setter, type_ in VANE_CONTROL_FIELDS: + if (section := config.get(path[0])) is None: + continue + if (value := section.get(path[1])) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, + normalized_args, + return_type=type_, + ) + body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + [(VaneCall.operator("ref"), "call"), *normalized_args], + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h new file mode 100644 index 0000000000..2fc6ba3c32 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -0,0 +1,42 @@ +#pragma once + +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/automation.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +template +class SetRemoteTemperatureAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, temperature) + + void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } +}; + +template +class ClearRemoteTemperatureAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } +}; + +template class VaneControlAction : public Action { + public: + using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); + + VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} + + void play(const Ts &...x) override { + auto call = this->parent_->make_vane_call(); + this->apply_(call, x...); + call.perform(); + } + + protected: + MitsubishiCN105Component *parent_; + ApplyFn apply_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 522b9218fc..64475d0e32 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart @@ -7,126 +9,248 @@ from esphome.const import ( CONF_ID, CONF_SUPPORTED_SWING_MODES, CONF_TEMPERATURE, + CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType +from . import ( + CONF_MITSUBISHI_CN105_ID, + DOMAIN, + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] -CODEOWNERS = ["@crnjan"] +_LOGGER = logging.getLogger(__name__) + +# Deprecated legacy climate-owned hub option. Remove in 2027.2.0. CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" - -mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id" MitsubishiCN105Climate = mitsubishi_ns.class_( "MitsubishiCN105Climate", climate.Climate, cg.Component, - uart.UARTDevice, + cg.Parented.template(MitsubishiCN105Component), ) -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacySetRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacyClearRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -CONFIG_SCHEMA = ( - climate.climate_schema(MitsubishiCN105Climate) - .extend(uart.UART_DEVICE_SCHEMA) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _has_top_level_hub_config() -> bool: + return DOMAIN in (CORE.raw_config or {}) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is " + "deprecated. Declare '%s:' and reference it with '%s:' instead. Will " + "be removed in ESPHome 2027.2.0.", + DOMAIN, + DOMAIN, + CONF_MITSUBISHI_CN105_ID, + ) + + # Add the hidden hub declaration only for legacy climate-owned configs, + # so normal auto-ID resolution does not see it as a top-level hub. + config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)( + None + ) + return config + + +_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend( + { + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, + } +) + +_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + +# Hub options accepted in the legacy climate-owned configuration. When a +# top-level hub exists, leaving these on the climate is always a migration +# mistake and the generic schema error does not explain where they belong. +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_HUB_KEYS = ( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, + CONF_UART_ID, + CONF_UPDATE_INTERVAL, +) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType: + legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config] + if not legacy_keys: + return config + + keys = ", ".join(f"'{key}'" for key in legacy_keys) + message = f"{keys} must be moved under the top-level '{DOMAIN}:' block" + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys: + message += ( + f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to " + "'telemetry_request_min_interval' there" + ) + raise cv.Invalid(message) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_SCHEMA = ( + _BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA) .extend( { - cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, - cv.Optional( - CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" - ): cv.update_interval, - cv.Optional( - CONF_SUPPORTED_SWING_MODES, default="OFF" - ): validate_climate_swing_mode, + cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval, + cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval, } ) + .add_extra(_prepare_legacy_hub_config) ) -FINAL_VALIDATE_SCHEMA = cv.All( - uart.final_validate_device_schema( - "mitsubishi_cn105", + +@schema_extractor("schema") +def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + return _HUB_SCHEMA + if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config(): + return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config)) + return _LEGACY_SCHEMA(config) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _legacy_final_validate(config: ConfigType) -> ConfigType: + if CONF_MITSUBISHI_CN105_ID in config: + return config + + return uart.final_validate_device_schema( + DOMAIN, require_rx=True, require_tx=True, data_bits=8, parity="EVEN", stop_bits=1, - ) -) + )(config) + + +FINAL_VALIDATE_SCHEMA = _legacy_final_validate async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) - cg.add( - var.set_current_temperature_min_interval( - config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] - ) - ) - - -@automation.register_action( - "climate.mitsubishi_cn105.set_remote_temperature", - SetRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - cv.Required(CONF_TEMPERATURE): cv.templatable( - cv.All( - cv.temperature, - cv.Range(min=8.0, max=39.5), + climate_config = config.copy() + # update_interval configures the protocol hub, not the climate entity. + climate_config.pop(CONF_UPDATE_INTERVAL, None) + await cg.register_component(var, climate_config) + if CONF_MITSUBISHI_CN105_ID in config: + await register_mitsubishi_cn105_device(var, config) + else: + # Legacy climate-owned hub compatibility. Remove in 2027.2.0. + parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID]) + await cg.register_component(parent, config) + await uart.register_uart_device(parent, config) + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config: + cg.add( + parent.set_telemetry_request_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] ) - ), - } - ), + ) + cg.add(var.set_parent(parent)) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + } +) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +@automation.register_action( + f"climate.{DOMAIN}.set_remote_temperature", + LegacySetRemoteTemperatureAction, + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def set_remote_temperature_action_to_code( +async def legacy_remote_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.set_remote_temperature' action is deprecated. Use " + "'%s.set_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) cg.add(var.set_temperature(temperature)) - return var +# Legacy climate action compatibility. Remove in 2027.2.0. @automation.register_action( - "climate.mitsubishi_cn105.clear_remote_temperature", - ClearRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - } - ), + f"climate.{DOMAIN}.clear_remote_temperature", + LegacyClearRemoteTemperatureAction, + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_remote_temperature_action_to_code( +async def legacy_clear_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " + "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 4782a2ef93..6683a9a25b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,8 +1,10 @@ +#include "mitsubishi_cn105.h" + #include #include #include #include -#include "mitsubishi_cn105.h" +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -10,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -25,91 +25,11 @@ static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; -static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; +static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_MAP = make_map({ - MitsubishiCN105::Mode::UNKNOWN, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - MitsubishiCN105::Mode::UNKNOWN, // 0x04 - MitsubishiCN105::Mode::UNKNOWN, // 0x05 - MitsubishiCN105::Mode::UNKNOWN, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}); - -static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - MitsubishiCN105::FanMode::UNKNOWN, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}); - -static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map({ - MitsubishiCN105::VaneMode::AUTO, // 0x00 - MitsubishiCN105::VaneMode::POSITION_1, // 0x01 - MitsubishiCN105::VaneMode::POSITION_2, // 0x02 - MitsubishiCN105::VaneMode::POSITION_3, // 0x03 - MitsubishiCN105::VaneMode::POSITION_4, // 0x04 - MitsubishiCN105::VaneMode::POSITION_5, // 0x05 - MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::VaneMode::SWING // 0x07 -}); - -static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 - MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 - MitsubishiCN105::WideVaneMode::LEFT, // 0x02 - MitsubishiCN105::WideVaneMode::CENTER, // 0x03 - MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 - MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 - MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B - MitsubishiCN105::WideVaneMode::SWING // 0x0C -}); - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -123,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } @@ -229,8 +145,8 @@ void MitsubishiCN105::did_transition_(State to) { case State::STATUS_UPDATED: { if (this->pending_updates_.any() && this->is_status_initialized()) { this->set_state_(State::APPLYING_SETTINGS); - } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { - this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; + } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) { + this->current_status_msg_type_ = STATUS_MSG_TELEMETRY; this->set_state_(State::UPDATING_STATUS); } else { this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); @@ -264,26 +180,26 @@ void MitsubishiCN105::did_transition_(State to) { } } -bool MitsubishiCN105::should_request_room_temperature_() const { - if (!this->is_room_temperature_enabled()) { +bool MitsubishiCN105::should_request_telemetry_() const { + if (!this->is_telemetry_polling_enabled()) { return false; } - if (!this->last_room_temperature_update_ms_.has_value()) { + if (!this->last_telemetry_update_ms_.has_value()) { return true; } - return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; + return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -327,7 +243,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature || previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode; - if (this->is_room_temperature_enabled()) { + if (this->is_telemetry_polling_enabled()) { changed |= previous.room_temperature != this->status_.room_temperature; } @@ -335,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; - case STATUS_MSG_ROOM_TEMP: - return this->parse_status_room_temperature_(payload, len); + case STATUS_MSG_TELEMETRY: + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -348,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - return true; -} - -bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX room temperature payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_room_temperature_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -414,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -428,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 742d8e18a9..b6b11b4820 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,9 +1,11 @@ #pragma once +#include "esphome/components/uart/uart.h" +#include "esphome/core/finite_set_mask.h" + #include #include -#include "esphome/components/uart/uart.h" -#include "esphome/core/finite_set_mask.h" +#include namespace esphome::mitsubishi_cn105 { @@ -70,16 +72,16 @@ class MitsubishiCN105 { uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } - uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } - bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } - void set_room_temperature_min_interval(uint32_t interval_ms) { - this->room_temperature_min_interval_ms_ = interval_ms; + uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; } + bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_telemetry_request_min_interval(uint32_t interval_ms) { + this->telemetry_request_min_interval_ms_ = interval_ms; } const Status &status() const { return this->status_; } bool is_status_initialized() const { - return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) - : !std::isnan(this->status_.target_temperature); + return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); } void set_power(bool power_on); @@ -120,58 +122,64 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_room_temperature_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); - bool should_request_room_temperature_() const; + bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); static const LogString *state_to_string(State state); uart::UARTDevice &device_; + // Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted. + // Remove legacy note in 2027.2.0. uint32_t update_interval_ms_{1000}; uint32_t status_update_wait_credit_ms_{0}; uint32_t operation_start_ms_{0}; - uint32_t room_temperature_min_interval_ms_{60000}; - std::optional last_room_temperature_update_ms_; + // Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is + // omitted. Remove legacy note in 2027.2.0. + uint32_t telemetry_request_min_interval_ms_{60000}; + std::optional last_telemetry_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; - bool set_wide_vane_high_bit_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index afffe7ea5e..197e1e1bb5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,5 +1,5 @@ -#include #include "mitsubishi_cn105_climate.h" + #include "esphome/core/log.h" namespace esphome::mitsubishi_cn105 { @@ -50,25 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayhp_.is_room_temperature_enabled()) { - ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", - this->hp_.get_room_temperature_min_interval()); - } else { - ESP_LOGCONFIG(TAG, " Current temperature: DISABLED"); - } - ESP_LOGCONFIG(TAG, - " Update interval: %" PRIu32 " ms\n" - " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", - this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), - LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); -} +void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); } -void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } - -void MitsubishiCN105Climate::loop() { - if (this->hp_.update()) { +void MitsubishiCN105Climate::setup() { + this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } @@ -90,7 +76,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); traits.set_visual_current_temperature_step(0.5f); } @@ -100,20 +86,20 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->hp_.set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(*target_temperature); } if (const auto mode = call.get_mode()) { if (*mode == climate::CLIMATE_MODE_OFF) { - this->hp_.set_power(false); + this->parent_->set_power(false); } else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) { - this->hp_.set_power(true); - this->hp_.set_mode(*mapped); + this->parent_->set_power(true); + this->parent_->set_mode(*mapped); } } if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) { - this->hp_.set_fan_mode(*fan_mode); + this->parent_->set_fan_mode(*fan_mode); } if (const auto swing_mode = call.get_swing_mode()) { @@ -140,24 +126,22 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { - this->hp_.set_vane_mode(vane); + this->parent_->set_vane_mode(vane); } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { - this->hp_.set_wide_vane_mode(wide); + this->parent_->set_wide_vane_mode(wide); } } - if (this->hp_.is_status_initialized()) { - this->apply_values_(); - } + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { - const auto &status = this->hp_.status(); + const auto &status = this->parent_->status(); this->target_temperature = status.target_temperature; - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { this->current_temperature = status.room_temperature; } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index c83a5519c1..5341c2d2d9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,51 +1,47 @@ #pragma once +#include "mitsubishi_cn105_component.h" +#include "mitsubishi_cn105.h" + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" -#include "esphome/components/uart/uart.h" -#include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { -class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { +class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented { public: - explicit MitsubishiCN105Climate() : hp_(*this) {} - void setup() override; - void loop() override; void dump_config() override; climate::ClimateTraits traits() override; void control(const climate::ClimateCall &call) override; - void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } - void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); } - - void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } - void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } - void set_supported_swing_mode(climate::ClimateSwingMode mode); + // Legacy climate action compatibility. Remove in 2027.2.0. + void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); } + void clear_remote_temperature() { this->parent_->clear_remote_temperature(); } protected: void apply_values_(); - MitsubishiCN105 hp_; climate::ClimateSwingModeMask supported_swing_modes_{}; MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class SetRemoteTemperatureAction : public Action, public Parented { +class LegacySetRemoteTemperatureAction : public Action, public Parented { public: TEMPLATABLE_VALUE(float, temperature) void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class ClearRemoteTemperatureAction : public Action, public Parented { +class LegacyClearRemoteTemperatureAction : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp new file mode 100644 index 0000000000..8e9e954645 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -0,0 +1,41 @@ +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/log.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105"; + +void MitsubishiCN105Component::dump_config() { + ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); + if (this->hp_.is_telemetry_polling_enabled()) { + ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms", + this->hp_.get_telemetry_request_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED"); + } + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Component::setup() { this->hp_.initialize(); } + +void MitsubishiCN105Component::loop() { + if (this->hp_.update()) { + this->notify_status_listeners_(); + } +} + +void VaneCall::perform() { + if (const auto &direction = this->vertical.get_direction(); direction.has_value()) { + this->parent_->set_vane_mode(static_cast(*direction)); + } + this->parent_->publish_status(); +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h new file mode 100644 index 0000000000..6461fb464b --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -0,0 +1,106 @@ +#pragma once + +#include "mitsubishi_cn105.h" + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +#include +#include + +namespace esphome::mitsubishi_cn105 { + +enum VerticalVaneMode : uint8_t { + VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), + VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), + VERTICAL_VANE_MODE_POSITION_2 = static_cast(MitsubishiCN105::VaneMode::POSITION_2), + VERTICAL_VANE_MODE_POSITION_3 = static_cast(MitsubishiCN105::VaneMode::POSITION_3), + VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), + VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), + VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), + VERTICAL_VANE_MODE_UNKNOWN = static_cast(MitsubishiCN105::VaneMode::UNKNOWN), +}; + +struct VaneState { + struct Vertical { + VerticalVaneMode direction; + }; + + Vertical vertical; +}; + +class MitsubishiCN105Component; + +struct VaneCall { + struct Vertical { + void set_direction(VerticalVaneMode direction) { this->direction_ = direction; } + const std::optional &get_direction() const { return this->direction_; } + + protected: + std::optional direction_; + }; + + explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {} + + Vertical vertical; + + void perform(); + + protected: + MitsubishiCN105Component *parent_; +}; + +class MitsubishiCN105Component : public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Component() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } + void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + + void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } + void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + + void set_power(bool power_on) { this->hp_.set_power(power_on); } + void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); } + void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); } + void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } + void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } + void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + VaneCall make_vane_call() { return VaneCall(this); } + + const MitsubishiCN105::Status &status() const { return this->hp_.status(); } + bool is_status_initialized() const { return this->hp_.is_status_initialized(); } + bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + + template void add_on_vane_state_callback(F &&callback) { + this->vane_state_callback_.add(std::forward(callback)); + } + + void publish_status() { + if (this->is_status_initialized()) { + this->notify_status_listeners_(); + } + } + + protected: + void notify_status_listeners_() { + this->status_callback_.call(); + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); + } + + MitsubishiCN105 hp_; + CallbackManager status_callback_; + LazyCallbackManager vane_state_callback_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..4ca12edbb4 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + VERTICAL_VANE_DIRECTIONS, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS], + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..d703ddbb02 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..76977d59d7 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 7048302124..2288e8ff84 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -1,3 +1,5 @@ +#ifndef USE_BK72XX + #include "sensor_mlx90393.h" #include "esphome/core/log.h" @@ -270,3 +272,5 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { } } // namespace esphome::mlx90393 + +#endif // USE_BK72XX diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index e3b7ae5d93..03e78f51cc 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -1,5 +1,7 @@ #pragma once +#ifndef USE_BK72XX + #include #include #include "esphome/components/i2c/i2c.h" @@ -76,3 +78,5 @@ class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public }; } // namespace esphome::mlx90393 + +#endif // USE_BK72XX diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 9e64540382..58bd0f65dc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Literal +from typing import Any, Literal from esphome import pins import esphome.codegen as cg @@ -14,6 +14,21 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +# Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is +# generated unless a config uses one). +AUTO_LOAD = ["modbus_client"] + +# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC. +MAX_PDU_SIZE = 253 + +# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the +# C++ constants of the same name; the spec sets a different ceiling for each function code. +MAX_NUM_OF_COILS_TO_READ = 2000 +MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 +MAX_NUM_OF_COILS_TO_WRITE = 1968 +MAX_NUM_OF_REGISTERS_TO_READ = 125 +MAX_NUM_OF_REGISTERS_TO_WRITE = 123 +MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) @@ -85,15 +100,28 @@ async def to_code(config): cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) +def _validate_server_address(value: Any) -> int: + address = cv.hex_uint8_t(value) + # The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1), + # so it cannot identify an individual server device. + if address == 0: + raise cv.Invalid( + "Address 0 is the Modbus broadcast address and cannot be used as a " + "server device address. Assign a unique unit address instead." + ) + return address + + def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): hub_type = ModbusClient if role == "client" else ModbusServer + address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: - schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t + schema[cv.Required(CONF_ADDRESS)] = address_validator else: - schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t + schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator return cv.Schema(schema) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 9d7dc71547..e7eaacee0c 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -3,33 +3,34 @@ import esphome.codegen as cg modbus_ns = cg.esphome_ns.namespace("modbus") modbus_helpers_ns = modbus_ns.namespace("helpers") -ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") -ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") +FunctionCode_ns = modbus_ns.namespace("FunctionCode") +FunctionCode = FunctionCode_ns.enum("FunctionCode") MODBUS_FUNCTION_CODE = { - "read_coils": ModbusFunctionCode.READ_COILS, - "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, - "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, + "read_coils": FunctionCode.READ_COILS, + "read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS, + "read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS, + "read_input_registers": FunctionCode.READ_INPUT_REGISTERS, + "write_single_coil": FunctionCode.WRITE_SINGLE_COIL, + "write_single_register": FunctionCode.WRITE_SINGLE_REGISTER, + "write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS, + "write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS, } -ModbusRegisterType_ns = modbus_ns.namespace("ModbusRegisterType") -ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") +EntityType_ns = modbus_ns.namespace("EntityType") +EntityType = EntityType_ns.enum("EntityType") MODBUS_WRITE_REGISTER_TYPE = { - "custom": ModbusRegisterType.CUSTOM, - "coil": ModbusRegisterType.COIL, - "holding": ModbusRegisterType.HOLDING, + "custom": EntityType.CUSTOM, + "coil": EntityType.COIL, + "holding": EntityType.HOLDING, } MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, - "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.INPUT_REGISTER, + "discrete_input": EntityType.DISCRETE_INPUT, + "read": EntityType.INPUT_REGISTER, + "input": EntityType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") @@ -37,7 +38,9 @@ SensorValueType = SensorValueType_ns.enum("SensorValueType") SENSOR_VALUE_TYPE = { "RAW": SensorValueType.RAW, "U_WORD": SensorValueType.U_WORD, + "U_WORD_S": SensorValueType.U_WORD_S, "S_WORD": SensorValueType.S_WORD, + "S_WORD_S": SensorValueType.S_WORD_S, "U_DWORD": SensorValueType.U_DWORD, "U_DWORD_R": SensorValueType.U_DWORD_R, "S_DWORD": SensorValueType.S_DWORD, @@ -53,7 +56,9 @@ SENSOR_VALUE_TYPE = { TYPE_REGISTER_MAP = { "RAW": 1, "U_WORD": 1, + "U_WORD_S": 1, "S_WORD": 1, + "S_WORD_S": 1, "U_DWORD": 2, "U_DWORD_R": 2, "S_DWORD": 2, @@ -69,7 +74,9 @@ TYPE_REGISTER_MAP = { CPP_TYPE_REGISTER_MAP = { "RAW": cg.uint16, "U_WORD": cg.uint16, + "U_WORD_S": cg.uint16, "S_WORD": cg.int16, + "S_WORD_S": cg.int16, "U_DWORD": cg.uint32, "U_DWORD_R": cg.uint32, "S_DWORD": cg.int32, diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index ecb2e4461c..5305f6313f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1,4 +1,7 @@ #include "modbus.h" + +#include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,6 +18,9 @@ static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; // Milliseconds per second static constexpr uint32_t MS_PER_SEC = 1000; +// Shortest gap between two "no device accepted broadcast" warnings +static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC; + void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); @@ -45,26 +51,46 @@ void Modbus::loop() { } void ModbusClientHub::loop() { - // Call base class to receive bytes and parse frames - this->Modbus::loop(); + // Drain anything owed since the last loop (e.g. an external clear) before the watchdog runs, so it + // never times out an entry whose pending count has not been drained. No-op when nothing is owed. + this->sweep_(); - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_.has_value()) { - ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.data()[0]; - if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, - this->last_receive_check_ - this->last_send_); - this->notify_no_response_(wfr); - this->waiting_for_response_.reset(); - } + this->Modbus::loop(); // receive bytes and parse frames + + // Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the + // entry up and holds off if the response has started arriving. + if (this->waiting_for_response_ && + this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) { + this->expire_waiting_(); } - // If there's no response pending and there's commands in the buffer + this->sweep_(); // deliver owed callbacks with the hub quiescent this->send_next_frame_(); } +void ModbusClientHub::expire_waiting_() { + ModbusDeviceCommand *cmd = this->find_waiting_(); + if (cmd == nullptr) { + this->waiting_for_response_ = false; + return; + } + if (!this->rx_buffer_.empty() && this->rx_buffer_[0] == cmd->frame.address()) { + // The start of the response is in the buffer: let the frame finish arriving. + return; + } + // Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected). + if (cmd->state == FrameState::WAITING) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(), + this->last_receive_check_ - this->last_send_); + } + // Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry + // lands in TIMED_OUT and the following sweep reschedules a retry or erases it. Free the + // wire first so a resend from inside the callback sees it available. + this->waiting_for_response_ = false; + this->sweep_needed_ = true; + cmd->timed_out(); +} + bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts @@ -110,12 +136,21 @@ bool Modbus::tx_blocked() { bool ModbusClientHub::tx_blocked() { // We block transmission in any of these case: - // 1. We're waiting for a response + // 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED) // 2. Any of the base class tx_blocked conditions - return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); + return this->waiting_for_response_ || this->Modbus::tx_blocked(); } -bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_buffer_empty() { + // "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in + // other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous + // poll does not count either, since it ranks below every one-shot, so a new send goes out first. + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY && !cmd.continuous) + return false; + } + return true; +} void Modbus::receive_bytes_() { this->last_receive_check_ = millis(); @@ -154,6 +189,10 @@ void ModbusServerHub::parse_modbus_frames() { size_t size = this->rx_buffer_.size(); ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); bool retry_as_client = false; + // A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex). + const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS; + if (is_broadcast) + this->expecting_peer_response_ = 0; if (this->expecting_peer_response_ != 0) { if (!this->parse_modbus_server_frame_()) { ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", @@ -214,11 +253,10 @@ bool Modbus::parse_modbus_server_frame_() { // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. - uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); - const uint8_t *data = this->rx_buffer_.data() + data_offset; - uint16_t data_len = frame_length - 2 - data_offset; + // The PDU is the frame without the leading address and the trailing CRC. + std::span pdu(this->rx_buffer_.data() + 1, frame_length - 3); - this->process_modbus_server_frame(address, function_code, data, data_len); + this->process_modbus_server_frame(address, pdu); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; @@ -249,72 +287,76 @@ bool ModbusServerHub::parse_modbus_client_frame_() { // This requires copying the frame data to a local buffer beforehand. uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); uint16_t data_len = frame_length - 2 - data_offset; - uint8_t data[MAX_FRAME_SIZE] = {}; - std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + uint8_t data_buffer[MAX_FRAME_SIZE] = {}; + std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len); + std::span data(data_buffer, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data); + if (address == BROADCAST_ADDRESS) { + // Keep the unicast response buffers out of the broadcast call chain. + this->process_broadcast_frame_(function_code, data); + } else { + this->process_modbus_client_frame_(address, function_code, data); + } return true; } -void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) { - if (!this->waiting_for_response_.has_value()) { +// The parser (parse_modbus_server_frame_) guarantees the bounds relied on here: pdu is never empty, +// and an exception-flagged pdu is at least 2 bytes. Keep that in mind when changing server_pdu_length(). +void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span pdu) { + const uint8_t function_code = pdu[0]; + ModbusDeviceCommand *cmd = this->waiting_for_response_ ? this->find_waiting_() : nullptr; + if (cmd == nullptr) { ESP_LOGW(TAG, "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", address, function_code, this->last_modbus_byte_ - this->last_send_); return; - } else { // We are waiting for a response - // Check if the response matches the expected address and function code + } - ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.data()[0]; - uint8_t expected_function_code = wfr.frame.data.data()[1]; - if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { - ESP_LOGW(TAG, - "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 - "ms after last send", - address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, - this->last_modbus_byte_ - this->last_send_); - // Invalidate the device; the entry survives as an interrupted shell so the late response is ignored. - // A retry requested here stays queued behind the shell until the send-wait timeout clears it. - this->notify_no_response_(wfr); - wfr.interrupted = true; - return; - } + // Check if the response matches the expected address and function code + const uint8_t expected_address = cmd->frame.address(); + const uint8_t expected_function_code = cmd->frame.pdu()[0]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this + // transaction and blocks tx until the send-wait timeout, where it gets its on_no_response. + cmd->interrupt(); + return; + } - if (wfr.interrupted) { - ESP_LOGW(TAG, - "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 - "ms after last send", - address, this->last_modbus_byte_ - this->last_send_); - return; - } else { // We have a valid device waiting for this response + if (cmd->state == FrameState::INTERRUPTED || cmd->state == FrameState::INTERRUPTED_RETIRED) { + // An interrupted shell keeps blocking until the send-wait timeout; a late response for it is + // ignored and does NOT free the wire. The distrust survives a clear (INTERRUPTED_RETIRED), so a + // cleared-interrupted frame still ends in on_no_response rather than delivering a late response. + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } - ModbusClientDevice *device = wfr.device; - this->waiting_for_response_.reset(); - // Is it an error response? - if (helpers::is_function_code_exception(function_code)) { - uint8_t exception = len > 0 ? data[0] : 0; - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", - function_code, exception, address, this->last_modbus_byte_ - this->last_send_); - if (device) - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); - - } else if (device) { // Not an error response - // on_modbus_data is existing public API taking const std::vector& - device->on_modbus_data(std::vector(data, data + len)); - } else { // Not an error response, but no device to respond to - ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", - address, this->last_modbus_byte_ - this->last_send_); - } - } + // Deliver at parse time so the response span can point into the rx buffer (zero copy). error()/ + // response() set the state and consume the request BEFORE the callback, so a clear from inside it + // ("stop polling now") wins. A device-less shell runs no callback and the sweep erases it. + this->waiting_for_response_ = false; + this->sweep_needed_ = true; + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present + ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + cmd->error(static_cast(exception)); + } else if (!cmd->response(pdu)) { + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address, + this->last_modbus_byte_ - this->last_send_); } } -void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { +void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span) { if (this->find_device_(address) != nullptr) { ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); } @@ -339,18 +381,223 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { return nullptr; } -bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { - if ((uint32_t) start_address + number_of_registers > 0x10000u) { - ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, - number_of_registers); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { + if (!helpers::address_range_fits(start_address, count)) { + ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + return std::nullopt; +} + +// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values. +// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the +// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces. +static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2; +static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5; +// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1). +static constexpr size_t READ_WRITE_VALUES_OFFSET = 9; +// A coil write (FC 0x0F) is function(1) + start(2) + quantity(2) + byte count(1) + packed bits. The largest +// one (MAX_NUM_OF_COILS_TO_WRITE coils) must fit the received request PDU, so the value subspan taken at +// WRITE_MULTIPLE_VALUES_OFFSET can never run past it. +static_assert(1 + WRITE_MULTIPLE_VALUES_OFFSET + packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE) <= MAX_PDU_SIZE, + "the largest FC 0x0F coil write must fit within MAX_PDU_SIZE"); + +ResponseStatus ModbusServerHub::parse_write_single_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + // No range check needed: one register can never push start_address + 1 past the address space. + this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers); + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_address_range_(start_address, number_of_registers); status.has_value()) { + return status; + } + this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers); + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_read_request_(std::span data, uint16_t max_entities, + const LogString *entity_name, uint16_t &start_address, + uint16_t &count) { + // Every read request is start address(2) + quantity(2); only the protocol ceiling differs per function + // code, so registers and coils/discrete inputs validate through here and cannot drift apart. + start_address = helpers::get_data(data.data(), 0); + count = helpers::get_data(data.data(), 2); + if (count == 0 || count > max_entities) { + ESP_LOGW(TAG, "Invalid number of %s %" PRIu16, LOG_STR_ARG(entity_name), count); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + return this->check_address_range_(start_address, count); +} + +ResponseStatus ModbusServerHub::parse_write_single_coil_(std::span data, uint16_t &start_address, + bool &value) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t raw_value = helpers::get_data(data.data(), WRITE_SINGLE_VALUES_OFFSET); + if (raw_value != 0xFF00 && raw_value != 0x0000) { + ESP_LOGW(TAG, "Invalid coil value 0x%04X", raw_value); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + // No range check needed: one coil can never push start_address + 1 past the address space. + value = raw_value == 0xFF00; + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_coils_(std::span data, uint16_t &start_address, + uint16_t &count, std::span &packed_bytes) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t number_of_bits = helpers::get_data(data.data(), 2); + const uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_bits == 0 || number_of_bits > MAX_NUM_OF_COILS_TO_WRITE || + packed_bit_bytes(number_of_bits) != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of coils %" PRIu16 " or bytes %" PRIu8, number_of_bits, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_address_range_(start_address, number_of_bits); status.has_value()) { + return status; + } + count = number_of_bits; + // coil values follow start(2) + quantity(2) + byte count(1) + packed_bytes = data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes); + return std::nullopt; +} + +void ModbusServerHub::assemble_registers_(std::span values, RegisterValues ®isters) { + for (size_t offset = 0; offset + 1 < values.size(); offset += 2) { + registers.push_back(helpers::get_data(values.data(), offset)); + } +} + +void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span data) { + // Broadcasts are only meaningful for writes and are never answered (Modbus 4.1 / 6.12), so an unsupported + // function code or a validation failure is silently dropped instead of replying with an exception. Both + // register writes (FC 0x06/0x10) and coil writes (FC 0x05/0x0F) are broadcastable by spec, and each shares + // its parser with the addressed path so a broadcast is validated exactly as the unicast form would be. + uint16_t start_address; + RegisterValues registers; + uint16_t coil_count = 0; + std::span packed_bytes; + uint8_t single_bit = 0; // backs packed_bytes for a single-coil write, so it must outlive the loop below + bool coils = false; + ResponseStatus status; + switch (static_cast(function_code)) { + case FunctionCode::WRITE_SINGLE_REGISTER: + status = this->parse_write_single_(data, start_address, registers); + break; + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + status = this->parse_write_multiple_(data, start_address, registers); + break; + case FunctionCode::WRITE_SINGLE_COIL: { + coils = true; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + single_bit = value ? 0x01 : 0x00; + coil_count = 1; + packed_bytes = std::span(&single_bit, 1); + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: + coils = true; + status = this->parse_write_multiple_coils_(data, start_address, coil_count, packed_bytes); + break; + default: + // Reads and read/write require a reply, so they are not valid as broadcasts. + ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code); + return; + } + if (status.has_value()) { + return; + } + // A broadcast is never answered, so a rejecting device has no other feedback channel: report the + // per-device outcome at V, and warn if the write reached nobody at all. + bool accepted = false; + for (auto *device : this->devices_) { + // Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need + // to: the hub owns the difference, which is only that no reply is ever sent. + const ResponseStatus device_status = + coils ? device->on_write_coils(start_address, PackedBits(packed_bytes, coil_count)) + : device->on_write_registers(start_address, registers); + if (device_status.has_value()) { + ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), + static_cast(device_status.value())); + } else { + accepted = true; + } + } + if (!accepted && !this->devices_.empty()) { + const uint16_t entity_count = coils ? coil_count : static_cast(registers.size()); + const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers"); + // Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes + // repeats forever, so warning per frame would flood the log. + const uint32_t now = millis(); + if (this->last_unaccepted_broadcast_warn_ == 0 || + now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) { + this->last_unaccepted_broadcast_warn_ = now; + ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); + } else { + ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); + } + } +} + +bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len) { + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (this->rejected_(address, function_code, status)) { return false; } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // The byte count is a single byte, so the count must stay within the protocol read limit; above it the + // static_cast(number_of_registers * 2) below would silently truncate the byte count. + if (number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "Read response of %" PRIu16 " registers exceeds the limit of %" PRIu16, number_of_registers, + MAX_NUM_OF_REGISTERS_TO_READ); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // Byte count(1) + two bytes per register. Checked here rather than at the call sites so the bound travels with + // the write itself: a future caller starting at a non-zero response_len, or passing a smaller buffer, is + // rejected instead of overrunning it before send_response_'s size guard can fire. + const size_t required = static_cast(response_len) + 1 + static_cast(number_of_registers) * 2; + if (required > response_buffer.size()) { + ESP_LOGE(TAG, "Read response needs %zu bytes but only %zu are available", required, response_buffer.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } return true; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, + std::span data) { ModbusServerDevice *device = this->find_device_(address); if (device == nullptr) { this->expecting_peer_response_ = address; @@ -363,109 +610,174 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func const uint8_t *response_data = response_buffer; uint16_t response_len = 0; - switch (static_cast(function_code)) { - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: { - // PDU data: start address(2) + quantity(2). - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = helpers::get_data(data, 2); - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + switch (static_cast(function_code)) { + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: { + uint16_t start_address; + uint16_t number_of_registers; + status = this->parse_read_request_(data, MAX_NUM_OF_REGISTERS_TO_READ, LOG_STR("registers"), start_address, + number_of_registers); + if (this->rejected_(address, function_code, status)) { return; } RegisterValues registers; - if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + if (static_cast(function_code) == FunctionCode::READ_HOLDING_REGISTERS) { status = device->on_read_holding_registers(start_address, number_of_registers, registers); } else { status = device->on_read_input_registers(start_address, number_of_registers, registers); } - // A handler that returns an exception leaves registers partially filled, so check the exception - // first and forward it before validating the register count on the success path. - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { return; } - - if (registers.size() != number_of_registers) { - ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); - this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count - for (auto r : registers) { - auto register_bytes = decode_value(r); - response_buffer[response_len++] = register_bytes[0]; - response_buffer[response_len++] = register_bytes[1]; - } break; } - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { - // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. - // A single-register write always targets one register; for a multiple-register write the - // quantity is in the frame and its byte count must equal quantity * 2. The register values are - // assembled into registers below so the handler doesn't have to know the request framing. - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = 1; - uint16_t values_offset = 2; // single write: values follow the 2-byte start address - if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - number_of_registers = helpers::get_data(data, 2); - uint8_t number_of_bytes = helpers::get_data(data, 4); - values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || - number_of_registers * 2 != number_of_bytes) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, - number_of_bytes); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { - return; - } - } - // Assemble the register values (host byte order) so the handler never sees wire framing. + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + // Parse and validate the write PDU into host-order register values; reply with an exception on failure. + uint16_t start_address; RegisterValues registers; - for (uint16_t i = 0; i < number_of_registers; i++) { - registers.push_back(helpers::get_data(data, values_offset + i * 2)); + if (static_cast(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) { + status = this->parse_write_single_(data, start_address, registers); + } else { + status = this->parse_write_multiple_(data, start_address, registers); + } + if (this->rejected_(address, function_code, status)) { + return; } status = device->on_write_registers(start_address, registers); - response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_data = data.data(); // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; } + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: { + uint16_t start_address; + uint16_t number_of_bits; + status = + this->parse_read_request_(data, MAX_NUM_OF_COILS_TO_READ, LOG_STR("bits"), start_address, number_of_bits); + if (this->rejected_(address, function_code, status)) { + return; + } + // Response: byte count(1) + packed bytes, written straight into the pre-zeroed response buffer. It + // always fits: the parse above caps the count, and a static_assert bounds that against MAX_RAW_SIZE. + const uint8_t byte_count = static_cast(packed_bit_bytes(number_of_bits)); + response_buffer[response_len++] = byte_count; + // Take the packed-bytes span off a span that knows response_buffer's real size, so a future non-zero + // response_len (e.g. a prefix written before the packed data) is a bounds error, not a silent overrun. + std::span packed_out = std::span(response_buffer).subspan(response_len, byte_count); + std::fill(packed_out.begin(), packed_out.end(), 0); + MutablePackedBits bits(packed_out, number_of_bits); + if (static_cast(function_code) == FunctionCode::READ_COILS) { + status = device->on_read_coils(start_address, bits); + } else { + status = device->on_read_discrete_inputs(start_address, bits); + } + if (this->rejected_(address, function_code, status)) { + return; + } + response_len += byte_count; + break; + } + case FunctionCode::WRITE_SINGLE_COIL: { + // A single coil is handed to the device as a one-bit packed view, the same form a multiple-coil + // write takes, so a device only ever implements one coil write handler. + uint16_t start_address; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + if (this->rejected_(address, function_code, status)) { + return; + } + const uint8_t single_bit = value ? 0x01 : 0x00; + status = device->on_write_coils(start_address, PackedBits(std::span(&single_bit, 1), 1)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: { + // Parse and validate the coil write PDU into a packed-bit view; reply with an exception on failure. + uint16_t start_address; + uint16_t count; + std::span packed_bytes; + status = this->parse_write_multiple_coils_(data, start_address, count, packed_bytes); + if (this->rejected_(address, function_code, status)) { + return; + } + status = device->on_write_coils(start_address, PackedBits(packed_bytes, count)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. + uint16_t read_start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint16_t write_start_address = helpers::get_data(data.data(), 4); + uint16_t number_of_write_registers = helpers::get_data(data.data(), 6); + uint8_t number_of_bytes = helpers::get_data(data.data(), 8); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ || + number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW || + number_of_write_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers (read %" PRIu16 ", write %" PRIu16 ") or bytes %" PRIu8, + number_of_registers, number_of_write_registers, number_of_bytes); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + status = this->check_address_range_(read_start_address, number_of_registers); + if (!status.has_value()) { + status = this->check_address_range_(write_start_address, number_of_write_registers); + } + if (this->rejected_(address, function_code, status)) { + return; + } + // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read + // values are allocated, keeping only one RegisterValues buffer live at a time. + { + RegisterValues write_registers; + this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers); + // Dispatch to the standalone write and read handlers so any device implementing those supports 0x17 + // without a dedicated handler; a device that maps registers by address reconstructs the read response + // from the values it just stored. + status = device->on_write_registers(write_start_address, write_registers); + } + if (this->rejected_(address, function_code, status)) { + return; + } + RegisterValues registers; + status = device->on_read_holding_registers(read_start_address, number_of_registers, registers); + + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { + return; + } + break; + } default: ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); - this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); return; } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); - } else { + if (!this->rejected_(address, function_code, status)) { this->send_response_(address, function_code, response_data, response_len); } } +// Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check +// after it and refuse (return false) if a byte arrived in that window rather than transmit over it. bool Modbus::send_frame_(const ModbusFrame &frame) { - if (this->tx_blocked()) { - ESP_LOGE(TAG, "Attempted to send while transmission blocked"); - return false; - } - if (frame.size() > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); - return false; - } - const int32_t tx_delay_remaining = this->tx_delay_remaining(); if (tx_delay_remaining > 0) { delay(tx_delay_remaining); } + // The delay above can span several ms; a byte arriving in that window blocks transmission after the + // caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry. + if (this->tx_blocked()) { + return false; + } + if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->digital_write(true); this->write_array(frame.data.data(), frame.size()); @@ -489,28 +801,31 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { } void ModbusClientHub::send_next_frame_() { - if (this->tx_buffer_.empty()) { + if (this->tx_blocked()) + return; + + ModbusDeviceCommand *cmd = this->select_next_ready_(); + if (cmd == nullptr) + return; + + if (!this->send_frame_(cmd->frame)) { + ESP_LOGV(TAG, "Send deferred for %" PRIu8 ": a frame arrived during the send delay, will retry", + cmd->frame.address()); return; } - if (this->tx_blocked()) { + cmd->sent(); + if (cmd->frame.address() == BROADCAST_ADDRESS) { + // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above + // reports the transmission, and the entry then retires with no terminal callback instead of + // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already + // spaces the next frame; the following sweep erases the entry. + ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)"); + cmd->complete_broadcast(); + this->sweep_needed_ = true; return; } - - ModbusDeviceCommand &command = this->tx_buffer_.front(); - - if (this->send_frame_(command.frame)) { - this->waiting_for_response_ = std::move(command); - } else { - if (command.device) - command.device->on_modbus_not_sent(); - } - - this->tx_buffer_.pop_front(); - - if (!this->tx_buffer_.empty()) { - ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); - } + this->waiting_for_response_ = true; } void ModbusClientHub::dump_config() { @@ -553,7 +868,20 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con this->send_raw_(raw_frame, payload_len + 2); } -void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { +bool ModbusServerHub::rejected_(uint8_t address, uint8_t function_code, ResponseStatus status) { + if (!status.has_value()) + return false; + // The one place a rejection becomes an exception reply, so the log carries the transaction context a + // device handler never has: which client-facing address and function code drew which exception. DEBUG + // rather than WARN because an exception reply is a normal protocol outcome and arrives per frame - a + // probing or broken client would otherwise flood the log. The parse helpers still WARN with specifics. + ESP_LOGD(TAG, "Exception %" PRIu8 " replied to function 0x%02X for address %" PRIu8, + static_cast(status.value()), function_code, address); + this->send_exception_(address, function_code, status.value()); + return true; +} + +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) { uint8_t raw_frame[3]; raw_frame[0] = address; raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; @@ -561,93 +889,277 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo this->send_raw_(raw_frame, 3); } +ModbusDeviceCommand *ModbusClientHub::find_waiting_() { + for (auto &cmd : this->tx_buffer_) { + if (cmd.waiting_state()) + return &cmd; + } + return nullptr; +} + +ModbusDeviceCommand *ModbusClientHub::select_next_ready_() { + // Class first (WRITE, then one-shot READ, then CONTINUOUS), oldest within a class. seq is a + // free-running counter, so compare each entry's AGE against it (correct across the full range). + const uint16_t now = this->next_seq_; + const auto age = [now](const ModbusDeviceCommand &cmd) -> uint16_t { return now - cmd.seq; }; + const auto older = [&age](const ModbusDeviceCommand &a, const ModbusDeviceCommand &b) { return age(a) > age(b); }; + ModbusDeviceCommand *best = nullptr; + for (auto &cmd : this->tx_buffer_) { + if (cmd.state != FrameState::READY) + continue; + if (best == nullptr || cmd.priority() > best->priority() || + (cmd.priority() == best->priority() && older(cmd, *best))) { + best = &cmd; + } + } + return best; +} + +bool ModbusDeviceCommand::sent() { + this->state = FrameState::WAITING; + // on_sent() is not a terminal, so nothing is consumed. + if (this->device == nullptr) + return false; + this->device->on_sent(this->frame.pdu()); + return true; +} + +bool ModbusDeviceCommand::notify_retired() { + if (!this->decrement_pending()) + return false; // nothing owed - stop the sweep draining this entry + if (this->device != nullptr) + this->device->on_not_sent(this->frame.pdu()); + return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero +} + +bool ModbusDeviceCommand::response(std::span response_pdu) { + this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE; + // A continuous poll is never consumed by its own response; a one-shot consumes one request here. + if (!this->continuous) + this->decrement_pending(); + if (this->device == nullptr) + return false; + this->device->on_response(this->frame.pdu(), response_pdu); + return true; +} + +bool ModbusDeviceCommand::error(ExceptionCode exception_code) { + this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_EXCEPTION; + // An exception ends a continuous poll too, so decrement unconditionally. + this->decrement_pending(); + if (this->device == nullptr) + return false; + this->device->on_error(this->frame.pdu(), exception_code); + return true; +} + +bool ModbusDeviceCommand::interrupt() { + // An unexpected frame distrusts the transaction. A cleared-but-still-waiting shell distrusts too, so + // the interrupt survives the clear in either order (WAITING_RETIRED -> INTERRUPTED_RETIRED). + if (this->state == FrameState::WAITING) { + this->state = FrameState::INTERRUPTED; + return true; + } + if (this->state == FrameState::WAITING_RETIRED) { + this->state = FrameState::INTERRUPTED_RETIRED; + return true; + } + return false; +} + +bool ModbusDeviceCommand::timed_out() { + this->state = FrameState::TIMED_OUT; // advance BEFORE the callback so a clear from inside it wins + this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1) + if (this->device == nullptr) + return false; // resolved, no one to tell + if (this->device->on_no_response(this->frame.pdu())) + this->increment_pending(); // granted retry = re-request (capped) + return true; +} + +void ModbusClientHub::sweep_() { + if (!this->sweep_needed_) + return; + this->sweep_needed_ = false; + // Serve only the entries present now: a callback may append (a re-send), but those sit beyond + // work_set and are left for the next sweep, which bounds the work and is the termination argument. + // Entries leave the container only in the erase pass below, so indices/references stay valid. + const size_t work_set = this->tx_buffer_.size(); + // Restart the walk after every callback: a handler may have moved any entry to any state. + bool callback_ran = true; + while (callback_ran) { + callback_ran = false; + for (size_t i = 0; i != work_set && !callback_ran; i++) { + ModbusDeviceCommand &cmd = this->tx_buffer_[i]; + switch (cmd.state) { + case FrameState::RECEIVED_RESPONSE: + case FrameState::RECEIVED_EXCEPTION: + case FrameState::TIMED_OUT: + // Off the wire, callback already delivered: reschedule what is still pending, else erase. + if (cmd.pending) + cmd.requeue(this->next_seq_++); + break; + case FrameState::RETIRED: + // Owes one on_not_sent() per accepted request; notify_retired() consumes one and reports + // whether a debt remained, so the restart loop drains the entry to zero - even a device-less + // shell with pending > 1 (no callback fires, but it still drains rather than stranding). + callback_ran = cmd.notify_retired(); + break; + case FrameState::WAITING_RETIRED: + case FrameState::INTERRUPTED_RETIRED: + // Cleared shell: drain only the un-run duplicates; the request in flight keeps pending 1 + // and gets its usual callback when it resolves. + if (cmd.pending > 1) + callback_ran = cmd.notify_retired(); + break; + default: // READY / WAITING / INTERRUPTED: idle or waiting for a response, nothing owed until the timeout + break; + } + } + } + // Erase pass: the only place entries leave the container. Storage order carries no meaning, so a + // finished entry is swap-and-popped; walking backwards means a moved-down entry is already seen. + for (size_t i = this->tx_buffer_.size(); i-- > 0;) { + const ModbusDeviceCommand &cmd = this->tx_buffer_[i]; + // pending == 0 is erasable, but shells still waiting for a response are exempt until it resolves. + if (cmd.pending != 0 || cmd.waiting_state()) + continue; + if (i + 1 != this->tx_buffer_.size()) + this->tx_buffer_[i] = std::move(this->tx_buffer_.back()); + this->tx_buffer_.pop_back(); + } +} + // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { - if (wfr.device == nullptr) - return; - const bool retry = wfr.device->on_modbus_no_response(); - // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach - // over the retry request rather than re-queueing a frame that can no longer be routed. - if (retry && wfr.device != nullptr) - this->requeue_waiting_frame_(wfr); - // The old transaction is over either way; never deliver anything else to the device through it. - wfr.device = nullptr; -} +bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { + // Requests refused here never enter the machine and get no callback - the false return is it. + if (pdu.empty()) { + ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); + return false; + } + // Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit. + if (pdu.size() > MAX_PDU_SIZE) { + ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); + return false; + } + // classify() drives both the broadcast guard and the continuous check below; compute it once. + const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]); -void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { - const ModbusFrame &frame = wfr.frame; + // A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that + // changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - + // as it could never deliver a result, so the caller learns via the false return (and on_not_sent). + // 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half + // lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom + // code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly + // here to match classify()'s exception-first handling of the write side. + if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE && + (!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + + // continuous is ignored for every mutating code (re-writing a value forever is never intended). + const bool mutates = priority == CommandPriority::WRITE; + bool continuous = false; + if (options.continuous) { + if (mutates) { + ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); + } else { + continuous = true; + } + } + + // A duplicate of a live entry with the same owner is not queued twice; it resolves against that + // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a + // poll -> downgrade the poll to one-shot; both one-shots -> pending++ below the cap, else refused. + for (auto &item : this->tx_buffer_) { + if (item.state == FrameState::RETIRED || item.state == FrameState::WAITING_RETIRED || + item.state == FrameState::INTERRUPTED_RETIRED) + continue; // cleared, on their way out: a new identical send queues fresh, never absorbs + if (item.device != device || !item.same_frame(address, pdu)) + continue; + if (device == nullptr) { + // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). + const bool requeueable = + !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]); + if (requeueable) { + ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); + } else { + ESP_LOGW(TAG, + "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped - register a " + "device for delivery accounting", + address, pdu[0]); + } + return false; // dropped: no entry, no callbacks - the refusal is the return value + } + if (continuous) { + item.make_continuous(true); + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address); + } else if (item.continuous) { + // A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this + // request, then stops (mirrors continuous incoming converting a one-shot the other way). + item.make_continuous(false); + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", downgraded from continuous to one-shot", address); + } else if (!item.increment_pending()) { + // At the servable cap, so refused. (An absorbed duplicate leaves seq alone - the entry keeps + // its place in line, held by its oldest outstanding request.) + ESP_LOGD(TAG, "Frame already active for %" PRIu8 " with %" PRIu8 " requests pending, refused", address, + item.pending); + return false; + } else { + ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, + item.pending); + } + return true; + } + + // Backstop counts every entry; dead ones are gone by the sweep's end, so at worst they cost one + // refusal at the very cap for one loop. if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { - ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); - if (wfr.device != nullptr) - wfr.device->on_modbus_not_sent(); - return; - } - // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. - this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); -} - -void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { - if (pdu_len == 0) { - if (device) - device->on_modbus_not_sent(); - return; - } - - if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); - this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); - } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); - if (device) - device->on_modbus_not_sent(); + ESP_LOGE(TAG, "Write buffer full, refused: %" PRIu8 ":%s", address, + format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + return false; + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, + format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++); + return true; +} + +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address) { + // A clear is a pure state flip; the sweep delivers every owed on_not_sent() from a quiescent hub. + for (auto &cmd : this->tx_buffer_) { + if (cmd.frame.address() != address) + continue; + cmd.retire(); + this->sweep_needed_ = true; } } -void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { - // Remove any pending commands for this address from the tx buffer - auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase( - std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }), - tx_buffer.end()); - - if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.data.data()[0] == address) { - ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); - // Invalidate the waiting device so it won't process a response. - this->waiting_for_response_.value().device = nullptr; - } - } -} void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { - // Remove any pending commands for this address from the tx buffer - auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), - tx_buffer.end()); - - if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().device == device) { - ESP_LOGV(TAG, "Clearing waiting for response"); - // Invalidate the waiting device so it won't process a response. - this->waiting_for_response_.value().device = nullptr; - } + // Silent teardown (supersede semantics): the caller's own frames vanish without callbacks; see + // the lifecycle note on ModbusClientDevice. + for (auto &cmd : this->tx_buffer_) { + if (cmd.device != device) + continue; + cmd.silent_retire(); + this->sweep_needed_ = true; } } void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { if (payload.size() < 2) { - if (device) - device->on_modbus_not_sent(); + ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } - this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); + this->queue_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload @@ -660,23 +1172,26 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { return; } - // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. - // This should only happen at low baud rates with long frame delays. + // If blocked now (frame delay not elapsed at low baud, or a frame arriving), defer rather than + // busy-waiting the loop; send_frame_ itself re-checks after its delay, so the deferred callback + // just reports whatever it returns. if (this->tx_blocked()) { // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame - // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures - // only one deferred send is pending, so a single buffer is sufficient. + // without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices. std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); - this->send_frame_(frame); + if (!this->send_frame_(frame)) + ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); }); - } else { - ModbusFrame frame(payload[0], payload + 1, len - 1); - this->send_frame_(frame); + return; } + + ModbusFrame frame(payload[0], payload + 1, len - 1); + if (!this->send_frame_(frame)) + ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay"); } void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { @@ -699,4 +1214,162 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t } } +void ModbusClientDevice::dispatch_response_(std::span request_pdu, std::span response_pdu, + ResponseStatus status) { + if (request_pdu.empty()) + return; + auto function_code = static_cast(request_pdu[0]); + // All standard requests handled below are function code + start address + count/value (5 bytes); + // anything shorter cannot be parsed and is handed to the catch-all. + if (request_pdu.size() < READ_PDU_SIZE) { + this->on_custom_response(request_pdu, response_pdu, status); + return; + } + const uint16_t start_address = helpers::get_data(request_pdu.data(), 1); + // count for reads/multi-writes, value for single writes + const uint16_t count_or_value = helpers::get_data(request_pdu.data(), 3); + + // Gatekeeper for the typed dispatch below: anything that is not a standard-conformant transaction is + // handed to on_custom_response() with the raw PDUs, so the decode cases can trust every length, byte + // count, and quantity field without re-clamping. + // - The REQUEST must be standard: nothing upstream validates a caller-built request PDU, so its + // internal byte count, quantity, and address range are checked here (is_client_pdu_standard()). + // - On success, the RESPONSE must be standard (self-consistent; the frame parser already guarantees + // most of this, but the check keeps the safety proof local), and a read response's length must also + // match the REQUESTED count - the per-PDU checks cannot see that relationship, and a short but + // self-consistent response must be diverted, never silently clamped and delivered as complete. + // - On failure (status engaged) the response is empty by design (see on_error()), so only the request + // is validated. + bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); + if (!custom && succeeded(status)) { + custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); + if (!custom && helpers::is_function_code_read(static_cast(function_code))) { + const bool bits = + function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; + const size_t expected_data_size = + bits ? packed_bit_bytes(count_or_value) : static_cast(count_or_value) * 2; + if (response_pdu.size() != expected_data_size + 2) { + ESP_LOGD(TAG, "Response length %zu does not match request (expected %zu) for function code 0x%X", + response_pdu.size(), expected_data_size + 2, static_cast(function_code)); + custom = true; + } + } + } + if (custom) { + this->on_custom_response(request_pdu, response_pdu, status); + return; + } + + switch (function_code) { + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + // FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a + // plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its + // response carries only that read data, and the write half is confirmed by the response arriving at all. + // An exception routes here as well (the gate only validates the request when status is set), delivering + // empty registers with the error in status - so a 0x17 subclass handles success and failure in the one + // on_read_holding_registers() callback and never needs to also override on_error(). + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + // Decode the big-endian register words into host byte order. The gate guarantees a success response + // carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the + // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On + // failure the registers span is empty. + RegisterValues registers; + if (succeeded(status)) { + for (size_t i = 0; i != count_or_value; i++) { + registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); + } + } + std::span register_span(registers.data(), registers.size()); + if (function_code == FunctionCode::READ_INPUT_REGISTERS) { + this->on_read_input_registers(start_address, register_span, status); + } else if (function_code == FunctionCode::READ_HOLDING_REGISTERS || + function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { + this->on_read_holding_registers(start_address, register_span, status); + } else { + // Unreachable for the current case labels; match explicitly so a function code added to this group + // later is diverted to on_custom_response() rather than silently delivered as a holding read. + this->on_custom_response(request_pdu, response_pdu, status); + } + break; + } + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: { + // Deliver the bits packed as on the wire; the gate guarantees a success response carries exactly + // (count_or_value + 7) / 8 data bytes. On failure the view is empty AND the count is zero - + // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. + std::span packed_bytes; + uint16_t count = 0; + if (succeeded(status)) { + packed_bytes = response_pdu.subspan(2); + count = count_or_value; + } + PackedBits bits(packed_bytes, count); + if (function_code == FunctionCode::READ_COILS) { + this->on_read_coils(start_address, bits, status); + } else { + this->on_read_discrete_inputs(start_address, bits, status); + } + break; + } + // Single-write acks echo the value: on success that echo is device-confirmed state - the one + // write whose acknowledgement carries a real read-back - so it is preferred over the request + // copy. On an exception the response has no value and the request copy is the only one. + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_SINGLE_COIL: { + const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + ? helpers::get_data(response_pdu.data(), 3) + : count_or_value; + if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { + this->on_write_single_register(start_address, value, status); + } else { + this->on_write_single_coil(start_address, value == 0xFF00, status); + } + break; + } + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + // Request layout: [0] function code, [1..2] start address, [3..4] register count, [5] byte count, + // [6..] register data. The gate guarantees the request carries exactly count_or_value registers + // (<= MAX_NUM_OF_REGISTERS_TO_WRITE, within RegisterValues capacity). Decoded from the request and + // delivered regardless of status - see the write-acknowledgement note in modbus.h. + RegisterValues registers; + for (size_t i = 0; i != count_or_value; i++) { + registers.push_back(helpers::get_data(request_pdu.data(), 6 + 2 * i)); + } + std::span register_span(registers.data(), registers.size()); + this->on_write_multiple_registers(start_address, register_span, status); + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: { + // Request layout: [0] function code, [1..2] start address, [3..4] coil count, [5] byte count, + // [6..] packed bits. The gate guarantees the request carries exactly (count_or_value + 7) / 8 packed + // bytes. Decoded from the request and delivered regardless of status - see the write-acknowledgement + // note in modbus.h. + std::span packed_bytes = request_pdu.subspan(6); + PackedBits bits(packed_bytes, count_or_value); + this->on_write_multiple_coils(start_address, bits, status); + break; + } + default: + this->on_custom_response(request_pdu, response_pdu, status); + break; + } +} + +// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response +void ModbusClientDevice::on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) { + // The dispatcher never calls this with an empty request, but this is a public virtual - stay safe. + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + // Warn once per device, then drop to VERBOSE: a mildly non-conformant peer answers every poll, + // and an unhandled-response warning per transaction would flood the log permanently. + if (!this->custom_response_warned_) { + this->custom_response_warned_ = true; + ESP_LOGW(TAG, "Non-standard request or response for function code 0x%X. No on_custom_response handler declared", + function_code); + } else { + ESP_LOGV(TAG, "Non-standard request or response for function code 0x%X (unhandled)", function_code); + } +} + } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index eeba00f6b1..dfe4a4872d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -9,13 +9,20 @@ #include #include #include +#include #include #include #include namespace esphome::modbus { -static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a +// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a +// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing. +// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus +// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one +// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266. +static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; // Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes @@ -39,6 +46,13 @@ struct ModbusFrame { } uint16_t size() const { return static_cast(this->data.size()); } + + // A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout + uint8_t address() const { return this->data.data()[0]; } + /// The PDU: function code + data, without address or CRC. Only valid while the frame is alive. + /// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the + /// subtraction would wrap on anything shorter. + std::span pdu() const { return std::span(this->data.data() + 1, this->size() - 3u); } }; class Modbus : public uart::UARTDevice, public Component { @@ -59,9 +73,12 @@ class Modbus : public uart::UARTDevice, public Component { virtual int32_t tx_delay_remaining(); virtual void parse_modbus_frames() = 0; bool parse_modbus_server_frame_(); - virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) = 0; + // pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code. + virtual void process_modbus_server_frame(uint8_t address, std::span pdu) = 0; void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + // Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms, + // so this re-checks after the delay and returns false without transmitting if a byte arrived in that + // window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted. bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. @@ -82,13 +99,165 @@ class Modbus : public uart::UARTDevice, public Component { class ModbusClientDevice; class ModbusServerDevice; +// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived +// at selection time, never caller-chosen or stored. +enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE }; + +// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed +// callbacks from a quiescent hub, and an entry is erased once pending == 0 && !waiting_state(). +enum class FrameState : uint8_t { + READY = 0, + WAITING, + RECEIVED_RESPONSE, + RECEIVED_EXCEPTION, + TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase + INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout + WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal + INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response + RETIRED, // cleared, off the wire +}; + +// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). +struct CommandOptions { + // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. + bool continuous{false}; +}; + struct ModbusDeviceCommand { ModbusClientDevice *device; ModbusFrame frame; - bool interrupted{false}; + FrameState state{FrameState::READY}; + // A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure. + bool continuous{false}; + // Accepted requests this entry stands for, capped at max_pending(); drains one terminal each. + uint8_t pending{1}; + // Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin + // fairness within a class. Meant to wrap. + uint16_t seq{0}; - ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) - : device(device), frame(address, src, len) {} + // Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here. + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span pdu, + bool continuous = false, uint16_t seq = 0) + : device(device), + frame(address, pdu.data(), static_cast(pdu.size())), + continuous(continuous), + seq(seq) {} + + // Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot. + CommandPriority priority() const { + return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]); + } + // Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded. + static CommandPriority classify(uint8_t function_code) { + if (helpers::is_function_code_exception(function_code)) + return CommandPriority::READ; + if (helpers::is_function_code_write(function_code)) { + return CommandPriority::WRITE; + } + return CommandPriority::READ; + } + + // Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. + uint8_t max_pending() const { + const uint8_t fc = this->frame.pdu()[0]; + const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc); + return (requeueable && !this->continuous) ? 2 : 1; + } + // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for + // a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED. + void silent_retire() { + if (!this->waiting_state()) + this->state = FrameState::RETIRED; + this->pending = 0; + this->device = nullptr; + } + // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already + // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal + // callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing. + // A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such + // code caps pending at 1, so pending is always 1 here - clear it. + void complete_broadcast() { + this->state = FrameState::RETIRED; + this->pending = 0; + } + // Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). + void requeue(uint16_t seq) { + this->state = FrameState::READY; + this->seq = seq; + } + // Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to + // a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter - + // to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed. + // On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the + // single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run + // once to serve that request - so restore one first. While the flag is still set max_pending() is 1, + // so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op + // on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without + // retroactively inflating that no-op. + void make_continuous(bool continuous) { + if (continuous) { + this->continuous = true; + this->pending = 1; + } else { + this->increment_pending(); + this->continuous = false; + } + } + // Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run + // request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is + // still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED -> + // INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other + // state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry: + // the clear is address-scoped (any device may call it) while the retry is the owning device's call + // via on_no_response - the bus obeys the owner. + void retire() { + if (this->state == FrameState::WAITING) { + this->state = FrameState::WAITING_RETIRED; + } else if (this->state == FrameState::INTERRUPTED) { + this->state = FrameState::INTERRUPTED_RETIRED; + } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED + this->state = FrameState::RETIRED; + } + this->continuous = false; + } + + // True while the entry is still waiting for a response; the erase pass exempts these even at pending 0. + bool waiting_state() const { + return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED || + this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED; + } + + bool decrement_pending() { + if (this->pending > 0) { + this->pending--; + return true; + } + return false; + } + // Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry). + bool increment_pending() { + if (this->pending < this->max_pending()) { + this->pending++; + return true; + } + return false; + } + + // Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and + // returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here. + bool sent(); + bool response(std::span response_pdu); + bool error(ExceptionCode exception_code); + bool interrupt(); + bool timed_out(); + bool notify_retired(); + + /// True if this command carries the same wire frame (address + PDU) as the given one. + bool same_frame(uint8_t address, std::span pdu) const { + const auto own_pdu = this->frame.pdu(); + return own_pdu.size() == pdu.size() && this->frame.address() == address && + memcmp(own_pdu.data(), pdu.data(), pdu.size()) == 0; + } }; class ModbusClientHub : public Modbus { @@ -100,43 +269,83 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->send_pdu(address, - helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, - payload, payload_len), - device); + this->queue_pdu(address, + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), + device); }; + /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and + /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in + /// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets + /// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means + /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap + /// duplicate) and no callback of any kind will follow; the false return is the whole story. + bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); + // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: + // the bool return and the options argument arrived after that release, so nothing external can be + // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { - this->queue_raw_(address, pdu.data(), pdu.size(), device); + this->queue_pdu(address, pdu, device); } + ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); - void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the + // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. + void clear_tx_queue_for_address(uint8_t address); void clear_tx_queue_for_device(ModbusClientDevice *device); protected: int32_t tx_delay_remaining() override; void parse_modbus_frames() override; - // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. - void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_server_frame(uint8_t address, std::span pdu) override; void send_next_frame_(); - // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. - // wfr is the caller's checked reference to waiting_for_response_. - void notify_no_response_(ModbusDeviceCommand &wfr); - void requeue_waiting_frame_(ModbusDeviceCommand &wfr); - void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); + // Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState. + void sweep_(); + // The selection function: best READY entry (WRITE class first, then one-shot reads, then the + // least-recently-served continuous; FIFO by seq within each group), or nullptr. + ModbusDeviceCommand *select_next_ready_(); + // Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED). + ModbusDeviceCommand *find_waiting_(); + // End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState. + void expire_waiting_(); uint16_t send_wait_time_{2000}; uint16_t turnaround_delay_ms_{0}; - std::optional waiting_for_response_; - // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many - // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling - // may change at run time. + // Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select + // while it is set, so at most one frame is awaiting a response. + bool waiting_for_response_{false}; + + // Set whenever a transition leaves owed callbacks behind; quiet loop() passes skip the sweep. + bool sweep_needed_{false}; + // Monotonic stamp source for ModbusDeviceCommand::seq. + uint16_t next_seq_{0}; + + // Plain append-order container; ordering lives in select_next_ready_(), lifecycle in FrameState. std::deque tx_buffer_; }; +// Transaction status: std::nullopt on success, otherwise a Modbus exception code +using ResponseStatus = std::optional; + +/// True when a transaction carried no exception. The optional holds the exception, so has_value() means +/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the +/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code +/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer. +inline bool succeeded(ResponseStatus status) { return !status.has_value(); } + +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; @@ -146,26 +355,88 @@ class ModbusServerHub : public Modbus { protected: void parse_modbus_frames() override; bool parse_modbus_client_frame_(); - // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. - void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + void process_modbus_server_frame(uint8_t address, std::span pdu) override; + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span data); + // Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered. + void process_broadcast_frame_(uint8_t function_code, std::span data); + // Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register + // values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus + // exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast + // writes (which silently drop invalid frames). + ResponseStatus parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters); + ResponseStatus parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters); + // Appends the big-endian register values in values to registers, in host byte order. + void assemble_registers_(std::span values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. - // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. - bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers); + // Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space, + // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast + // write is never answered, so the check cannot send it itself. Shared by the register and + // coil/discrete-input handlers, which all address the same 16-bit space. + ResponseStatus check_address_range_(uint16_t start_address, uint16_t count); + + // Parses a read request PDU (start address(2) + quantity(2)), shared by the register and + // coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the + // function code; entity_name only labels the rejection log. + ResponseStatus parse_read_request_(std::span data, uint16_t max_entities, const LogString *entity_name, + uint16_t &start_address, uint16_t &count); + + // Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed + // bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take. + ResponseStatus parse_write_single_coil_(std::span data, uint16_t &start_address, bool &value); + + // Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive + // buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and + // broadcast paths so the two validate identically. + ResponseStatus parse_write_multiple_coils_(std::span data, uint16_t &start_address, uint16_t &count, + std::span &packed_bytes); + + // Builds the body of a register read response (byte count followed by the big-endian register values) into + // response_buffer. Shared by every function code that answers with register values, so the read reply stays + // identical across them. Returns false once an exception has been sent: the one the handler reported via + // status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the + // protocol read limit, or the body does not fit. + bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len); void send_raw_(const uint8_t *payload, uint16_t len); - void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + // Sends and logs the exception reply when status holds one; returns true if the request was rejected. + // Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart. + bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status); + void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; std::vector devices_; + // Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting + // on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries. + uint32_t last_unaccepted_broadcast_warn_{0}; + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. - // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. std::array deferred_payload_; uint16_t deferred_payload_len_{0}; }; +/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), +/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by +/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) +/// gets none, and a broadcast (address 0) gets on_sent() with NO terminal, since a broadcast is never +/// answered (Modbus 4.1). on_sent() is additional, once per transmission, never for an on_not_sent() +/// request. on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, +/// all from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from +/// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal": +/// a broadcast is fire-and-forget (on_sent, no terminal); clear_tx_queue_for_device() drops the caller's +/// own frames silently; a continuous poll's cycles are its own accounting (a one-shot duplicate +/// downgrades the poll to a one-shot; a continuous duplicate merges into it). +/// +/// Invariants: +/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// entry through its callback-free transition methods. +/// - Public entry points can never trigger a callback synchronously. +/// - Callbacks are delivered only from within loop(). +/// - At most one callback is ever issued between calls to sweep_(): +/// sweep_ -> parse (response OR error) OR timeout (no_response) -> sweep_ -> send (sent) -> sweep_ (next loop) class ModbusClientDevice { public: ModbusClientDevice() = default; @@ -180,25 +451,171 @@ class ModbusClientDevice { ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - virtual void on_modbus_data(const std::vector &data) {} - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + /// Low-level response hook: called with the request PDU this device sent and the response PDU received + /// The spans are only valid for the duration of the call - copy the bytes if they must outlive it. + /// The default implementation decodes standard responses and dispatches to on_read_* / on_write_* callbacks below. + /// Override it to handle raw PDUs directly. + virtual void on_response(std::span request_pdu, std::span response_pdu) { + this->dispatch_response_(request_pdu, response_pdu, std::nullopt); + } + /// Low-level error hook: called with the request PDU and the modbus exception code from the error response. + /// The default implementation dispatches to the same typed callbacks with the exception code as status. + /// Devices implementing the High-level typed callbacks see success and failure through one interface. + virtual void on_error(std::span request_pdu, ExceptionCode exception_code) { + this->dispatch_response_(request_pdu, {}, exception_code); + } + /// Called when an accepted request was dropped before transmission by clear_tx_queue_for_address(). + /// (on_modbus_* below are deprecated pre-rename spellings; the defaults forward during deprecation.) + virtual void on_not_sent(std::span request_pdu) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->on_modbus_not_sent(); +#pragma GCC diagnostic pop + } + /// Called when this device's frame is actually written to the wire + virtual void on_sent(std::span request_pdu) {} + /// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a + /// retry. The hub does not bound retries: the device is responsible for limiting them. + virtual bool on_no_response(std::span request_pdu) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return this->on_modbus_no_response(); +#pragma GCC diagnostic pop + } + // Remove before 2027.2.0 + ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") virtual void on_modbus_not_sent() {} - /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. - /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and - /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + // Remove before 2027.2.0 + ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0") virtual bool on_modbus_no_response() { return false; } + + /// High-level typed response callbacks, fired by the default on_response()/on_error() with arguments + /// parsed from the request and response PDUs. + /// Status is std::nullopt on success; holds the exception code on failure. + /// Register values are in host byte order; spans are only valid for the duration of the call. + virtual void on_read_registers(EntityType entity_type, uint16_t start_address, std::span registers, + ResponseStatus status) {} + virtual void on_read_holding_registers(uint16_t start_address, std::span registers, + ResponseStatus status) { + this->on_read_registers(EntityType::HOLDING, start_address, registers, status); + } + virtual void on_read_input_registers(uint16_t start_address, std::span registers, + ResponseStatus status) { + this->on_read_registers(EntityType::INPUT_REGISTER, start_address, registers, status); + } + /// Coil/discrete-input reads are delivered as a PackedBits view (bit 0 = the bit at start_address, + /// bits.size() = the count requested). The view points into the hub's receive buffer and is only + /// valid during the call. + virtual void on_read_bits(EntityType entity_type, uint16_t start_address, PackedBits bits, ResponseStatus status) {} + virtual void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) { + this->on_read_bits(EntityType::COIL, start_address, bits, status); + } + virtual void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) { + this->on_read_bits(EntityType::DISCRETE_INPUT, start_address, bits, status); + } + /// Write acknowledgements. These deliberately mirror the read callbacks' shapes, so a write ack can be fed + /// through the same handler as a read (registers.size() / bits.size() gives the count) + /// + /// IMPORTANT - for the multi-writes these are the values that were REQUESTED, not device-confirmed + /// state: a multi-write ack only echoes the start address and count, so the values are decoded from + /// the request PDU, and they are delivered even when status holds an exception code. Always check + /// status, and treat publishing them as an optimistic update rather than a read-back. The single + /// writes are the exception: their successful ack echoes the value, so on success the delivered + /// value is the device's echo (on an exception it falls back to the request copy). + virtual void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) {} + virtual void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) {} + virtual void on_write_multiple_registers(uint16_t start_address, std::span registers, + ResponseStatus status) {} + virtual void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) {} + /// Catch-all for custom function codes and anything that is not a standard-conformant transaction + /// (see dispatch_response_()); on failure the response is empty and the exception code is in status. + /// The default implementation only logs a warning that the response is going unhandled - override it + /// to handle custom traffic (which also silences the warning). + virtual void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status); + ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu(this->address_, - helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, - payload, payload_len), - this); + this->parent_->queue_pdu( + this->address_, + helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), + this); } - void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } - inline void clear_tx_queue_for_address(bool clear_sent = true) { - this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will + /// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()), + /// false = refused at the door and nothing further happens. Neither means the frame is on the wire; + /// on_sent() reports that. + bool queue_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->queue_pdu(this->address_, pdu, this, options); } + // Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options. + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(std::span pdu) { this->queue_pdu(pdu); } + ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { + if (payload.empty()) + return; // too short to contain a PDU; refused at the door like any invalid send + this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); + } + // The typed request builders below all queue through queue_pdu(), so they share its contract: true + // means the request is queued and will resolve in exactly one terminal callback (except a broadcast + // (address 0), which is never answered and so gets only on_sent()), false means it was refused outright + // with no callback. Neither says the frame has been transmitted - on_sent() does. + // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which + // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. + bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, + CommandOptions options = {}) { + return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); + } + bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); + } + bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); + } + bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); + } + bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); + } + bool write_single_register(uint16_t start_address, uint16_t value) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + } + bool write_single_coil(uint16_t address, bool value) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + } + bool write_multiple_registers(uint16_t start_address, std::span values) { + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + } + /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed + /// overload. + bool write_multiple_coils(uint16_t start_address, std::span values) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + } + /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so + /// read-modify-write needs no unpack/repack. + bool write_multiple_coils(uint16_t start_address, PackedBits bits) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + } + /// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the + /// read registers, the same wire shape as a holding-register read). A device exception - typically a + /// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, + /// exactly as success does, so a subclass overriding that one callback handles both outcomes and never + /// needs to also override on_error(). + bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, + std::span write_values) { + return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count, + write_start_address, write_values)); + } + inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } // If more than one device is connected block sending a new command before a response is received @@ -207,22 +624,41 @@ class ModbusClientDevice { bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } protected: + /// Parses the request/response PDU pair and dispatches to the matching high-level typed callback + void dispatch_response_(std::span request_pdu, std::span response_pdu, + ResponseStatus status); + ModbusClientHub *parent_{nullptr}; uint8_t address_{0}; + bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE }; -// This is for compatibility with external components using the former class name -// Remove before 2026.12.0 -using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", - "2026.6.0") = ModbusClientDevice; +// Compatibility shim for external components written against the pre-2026.8 API, which subclassed +// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree +// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old +// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy +// exists only on this deprecated path) and on_modbus_error() the function code and exception code. +// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0) +class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0", + "2026.8.0") ModbusDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} -// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; -// (future) client response callbacks receive it. Named without a side prefix so both directions share it. -using ResponseStatus = std::optional; -// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol -// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by -// the capacity of this type. -using RegisterValues = StaticVector; + void on_response(std::span request_pdu, std::span response_pdu) override { + // Custom (user-defined) function codes historically delivered the payload starting AT the function + // code byte (frame data_offset 1). server_pdu_payload() drops that byte, so pass the whole PDU for + // them - external components match the first byte against the code they sent (issue #17994). + auto payload = !response_pdu.empty() && helpers::is_function_code_custom(response_pdu[0]) + ? response_pdu + : helpers::server_pdu_payload(response_pdu); + this->on_modbus_data(std::vector(payload.begin(), payload.end())); + } + void on_error(std::span request_pdu, ExceptionCode exception_code) override { + this->on_modbus_error(request_pdu.empty() ? 0 : request_pdu[0], static_cast(exception_code)); + } +}; class ModbusServerDevice { public: @@ -237,7 +673,7 @@ class ModbusServerDevice { uint8_t get_address() const { return this->address_; } virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, RegisterValues ®isters) { - return ModbusExceptionCode::ILLEGAL_FUNCTION; + return ExceptionCode::ILLEGAL_FUNCTION; }; virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, RegisterValues ®isters) { @@ -248,7 +684,24 @@ class ModbusServerDevice { return this->on_read_registers(start_address, number_of_registers, registers); }; virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { - return ModbusExceptionCode::ILLEGAL_FUNCTION; + return ExceptionCode::ILLEGAL_FUNCTION; + }; + /// Coil/discrete-input reads: set the requested bits (bit 0 = the coil at start_address) with + /// bits.set(). The view covers bits.size() pre-zeroed bits and writes land directly in the hub's + /// response buffer (no copy); it is only valid during the call. + virtual ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + virtual ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + /// Coil writes deliver the values as a PackedBits view over the hub's receive buffer (only valid + /// during the call). A single-coil write (FC 0x05) arrives as bits.size() == 1. + virtual ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; }; protected: diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index d11748bcd9..64f7210585 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -14,7 +18,7 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_1_END = 72; // 0x48 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E -enum class ModbusFunctionCode : uint8_t { +enum class FunctionCode : uint8_t { INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, @@ -29,22 +33,28 @@ enum class ModbusFunctionCode : uint8_t { GET_COMM_EVENT_LOG = 0x0C, // not implemented WRITE_MULTIPLE_COILS = 0x0F, WRITE_MULTIPLE_REGISTERS = 0x10, - REPORT_SERVER_ID = 0x11, // not implemented - READ_FILE_RECORD = 0x14, // not implemented - WRITE_FILE_RECORD = 0x15, // not implemented - MASK_WRITE_REGISTER = 0x16, // not implemented - READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented - READ_FIFO_QUEUE = 0x18, // not implemented + REPORT_SERVER_ID = 0x11, // not implemented + READ_FILE_RECORD = 0x14, // not implemented + WRITE_FILE_RECORD = 0x15, // not implemented + MASK_WRITE_REGISTER = 0x16, // not implemented + READ_WRITE_MULTIPLE_REGISTERS = 0x17, + READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ -inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } -inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } -inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } -inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } +// Remove before 2027.2.0 +using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0", + "2026.8.0") = FunctionCode; -// 4.3 MODBUS Data model -enum class ModbusRegisterType : uint8_t { +/*Allow direct comparison operators between FunctionCode and uint8_t*/ +inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } +inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast(rhs); } +inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } +inline bool operator!=(uint8_t lhs, FunctionCode rhs) { return !(lhs == static_cast(rhs)); } + +// 4.3 MODBUS Data model. "Entity" is the spec's umbrella for the four primary tables; only the +// 16-bit tables are registers (coils and discrete inputs are bits), so the enum is not named +// RegisterType. +enum class EntityType : uint8_t { CUSTOM = 0x00, COIL = 0x01, DISCRETE_INPUT = 0x02, @@ -52,15 +62,17 @@ enum class ModbusRegisterType : uint8_t { // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. INPUT_REGISTER = 0x04, // Remove before 2027.2.0 - READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = - INPUT_REGISTER, + READ ESPDEPRECATED("Use EntityType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = INPUT_REGISTER, }; +// Remove before 2027.2.0 +using ModbusRegisterType ESPDEPRECATED("Use modbus::EntityType instead. Removed in 2027.2.0", "2026.8.0") = EntityType; + // 7 MODBUS Exception Responses: const uint8_t FUNCTION_CODE_MASK = 0x7F; const uint8_t FUNCTION_CODE_EXCEPTION_MASK = 0x80; -enum class ModbusExceptionCode : uint8_t { +enum class ExceptionCode : uint8_t { ILLEGAL_FUNCTION = 0x01, ILLEGAL_DATA_ADDRESS = 0x02, ILLEGAL_DATA_VALUE = 0x03, @@ -72,9 +84,19 @@ enum class ModbusExceptionCode : uint8_t { GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = 0x0B, }; +// Remove before 2027.2.0 +using ModbusExceptionCode ESPDEPRECATED("Use modbus::ExceptionCode instead. Removed in 2027.2.0", + "2026.8.0") = ExceptionCode; + +// 6.11 15 (0x0F) Write Multiple Coils +static constexpr uint16_t MAX_NUM_OF_COILS_TO_WRITE = 1968; // 0x7B0 + // 6.12 16 (0x10) Write Multiple registers: static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +// 6.17 23 (0x17) Read/Write Multiple Registers: +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121; // 0x79 + // 6.1 01 (0x01) Read Coils // 6.2 02 (0x02) Read Discrete Inputs static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 @@ -86,8 +108,90 @@ static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MIN_PDU_SIZE = 1; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 +// A read request PDU is always function code(1) + start address(2) + quantity(2) +static constexpr uint16_t READ_PDU_SIZE = 5; +// A single-write PDU is always function code(1) + address(2) + value(2) +static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t MAX_FRAME_SIZE = 256; + +// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered. +static constexpr uint8_t BROADCAST_ADDRESS = 0; + +// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client +// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never +// has to check the framed size - it cannot be exceeded. +static_assert(MAX_PDU_SIZE + 3 == MAX_FRAME_SIZE, "a framed client PDU must fill the RTU frame limit"); +static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload must fill the RTU frame limit"); +/// Bits pack 8 per data byte, rounded up to whole bytes. +constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } + +// A coil/discrete-input read answers with byte count(1) + packed_bit_bytes(count) bytes, which has to fit +// the raw frame body. The runtime check on that path catches a caller entering with bytes already written; +// this catches the other way in, raising the ceiling past what a frame can carry. +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_COILS_TO_READ yields a read response larger than MAX_RAW_SIZE"); +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_DISCRETE_INPUTS_TO_READ yields a read response larger than MAX_RAW_SIZE"); + +// The coil and discrete-input ceilings are separate limits in the spec but hold the same value, so the +// read paths validate both against MAX_NUM_OF_COILS_TO_READ. Should the spec ever split them, this fires. +static_assert(MAX_NUM_OF_COILS_TO_READ == MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, + "the coil and discrete-input read ceilings must match"); + +/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout + * coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the + * two cannot desynchronize. The view does not own the bytes - it is only valid while they are. + * Reads (operator[]) are unchecked by design - the caller owns the bit < size() precondition, as + * with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and + * bytes() clamps to the real span, because those paths touch buffers and the wire directly. + */ +class PackedBits { + public: + PackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} + /// Value of the given bit; bit must be < size(). + bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; } + /// Number of bits in the view. + uint16_t size() const { return this->count_; } + /// The underlying packed bytes: exactly ceil(size() / 8) bytes, even when the view was constructed + /// over a larger buffer - forwarding this span onto the wire can never leak trailing buffer content. + /// Clamped to the actual span so a view over a too-short buffer stays detectable instead of UB. + std::span bytes() const { + return this->data_.first(std::min(packed_bit_bytes(this->count_), this->data_.size())); + } + + private: + std::span data_; // must cover ceil(count_ / 8) bytes + uint16_t count_; +}; + +/** Mutable counterpart of PackedBits: set() writes bits in place (deliberately no proxy operator[]=). + * Converts implicitly to PackedBits for read access. + */ +class MutablePackedBits { + public: + MutablePackedBits(std::span data, uint16_t count) : data_(data), count_(count) {} + bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; } + /// Set or clear the given bit. Out-of-range bits are dropped: on the server read path the span wraps a + /// stack response buffer, so a handler looping past size() must not be able to smash the frame. + void set(size_t bit, bool value) { + if (bit >= this->count_ || bit / 8 >= this->data_.size()) + return; + if (value) { + this->data_[bit / 8] |= (1 << (bit % 8)); + } else { + this->data_[bit / 8] &= ~(1 << (bit % 8)); + } + } + uint16_t size() const { return this->count_; } + operator PackedBits() const { return PackedBits(this->data_, this->count_); } + + private: + std::span data_; // must cover ceil(count_ / 8) bytes + uint16_t count_; +}; + /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index de109606cb..db21b6e6fd 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -7,81 +7,182 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; -uint16_t server_frame_length(const uint8_t *frame, size_t size) { - if (size < 2) - return MIN_FRAME_SIZE; - if (is_function_code_exception(frame[1])) { - return 5; // address(1) + function(1) + exception(1) + CRC(2) +// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, +// and the range [start_address, start_address + quantity) stays inside the 16-bit address space. +// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a +// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed. +static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) { + return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity); +} + +// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and +// on its echoed response alike. +static bool is_canonical_coil_value(uint8_t high_byte, uint8_t low_byte) { + return (high_byte == 0xFF || high_byte == 0x00) && low_byte == 0x00; +} + +uint16_t server_pdu_length(const uint8_t *frame, size_t size) { + if (size < MIN_PDU_SIZE) + return MIN_PDU_SIZE; + if (is_function_code_exception(frame[0])) { + return 2; // function(1) + exception(1) } - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: - return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + switch (static_cast(frame[0])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + return 5; // function(1) + output/register address(2) + value(2) // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. - case ModbusFunctionCode::READ_FILE_RECORD: - case ModbusFunctionCode::WRITE_FILE_RECORD: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); - case ModbusFunctionCode::MASK_WRITE_REGISTER: - return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) - case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); - case ModbusFunctionCode::READ_FIFO_QUEUE: - // address(1) + function(1) + fifo address(2) CRC(2) - return 6; + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0); + case FunctionCode::MASK_WRITE_REGISTER: + return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2) + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case FunctionCode::READ_FIFO_QUEUE: + // function(1) + fifo address(2) + return 3; default: - return MIN_FRAME_SIZE; // unknown length + return MIN_PDU_SIZE; // unknown length } } -uint16_t client_frame_length(const uint8_t *frame, size_t size) { - if (size < 2) - return MIN_FRAME_SIZE; - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: - // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) - return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); +uint16_t client_pdu_length(const uint8_t *frame, size_t size) { + if (size < MIN_PDU_SIZE) + return MIN_PDU_SIZE; + switch (static_cast(frame[0])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + // function(1) + start address(2) + quantity(2) + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + return 5; // function(1) + output/register address(2) + value(2) + case FunctionCode::WRITE_MULTIPLE_COILS: + // function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte). + return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0); + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + // function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register). + return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. - case ModbusFunctionCode::READ_FILE_RECORD: - case ModbusFunctionCode::WRITE_FILE_RECORD: - // address(1) + function(1) + byte count(1) + data + CRC(2) - return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); - case ModbusFunctionCode::MASK_WRITE_REGISTER: - return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) - case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: - // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + - // write quantity(2) + byte count(1) + data + CRC(2) - return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); - case ModbusFunctionCode::READ_FIFO_QUEUE: - // address(1) + function(1) + fifo address(2) CRC(2) - return 6; + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + // function(1) + byte count(1) + data + return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0); + case FunctionCode::MASK_WRITE_REGISTER: + return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2) + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + return 10 + (size > 9 ? std::min(frame[9], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2)) : 0); + case FunctionCode::READ_FIFO_QUEUE: + // function(1) + fifo address(2) + return 3; default: - return MIN_FRAME_SIZE; // unknown length + return MIN_PDU_SIZE; // unknown length + } +} + +bool is_server_pdu_standard(const uint8_t *pdu, size_t size) { + if (server_pdu_length(pdu, size) != size) + return false; + + const auto function_code = static_cast(pdu[0]); + switch (function_code) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + // A conformant bit-read response carries at least one packed byte (up to 2000 bits = 250 bytes). + return pdu[1] != 0 && pdu[1] <= uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ)); + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + // Registers are 2 bytes each: the byte count must be a non-zero even count within the read maximum. + return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2); + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + return pdu[1] <= uint8_t(MAX_PDU_SIZE - 2); + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2); + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + // The response echoes start address and quantity: bound them like the request side does. + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + return quantity_in_range(start_address, quantity, max_quantity); + } + case FunctionCode::WRITE_SINGLE_COIL: + // The response echoes the request, so the same ON/OFF constraint applies. + return is_canonical_coil_value(pdu[3], pdu[4]); + default: + return true; // All other function codes validated by length alone + } +} + +bool is_client_pdu_standard(const uint8_t *pdu, size_t size) { + if (client_pdu_length(pdu, size) != size) + return false; + + const auto function_code = static_cast(pdu[0]); + switch (function_code) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: { + const bool bits = + function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ; + return quantity_in_range(start_address, quantity, max_quantity); + } + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: { + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const uint16_t start_address = get_data(pdu, 1); + const uint16_t quantity = get_data(pdu, 3); + const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + // Coils are packed 8 per data byte; registers are 2 bytes each. + const size_t expected_data_bytes = bits ? packed_bit_bytes(quantity) : quantity * 2; + return quantity_in_range(start_address, quantity, max_quantity) && pdu[5] == expected_data_bytes; + } + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + return pdu[1] <= MAX_PDU_SIZE - 2; + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + const uint16_t start_address_read = get_data(pdu, 1); + const uint16_t quantity_read = get_data(pdu, 3); + const uint16_t start_address_write = get_data(pdu, 5); + const uint16_t quantity_write = get_data(pdu, 7); + return quantity_in_range(start_address_read, quantity_read, MAX_NUM_OF_REGISTERS_TO_READ) && + quantity_in_range(start_address_write, quantity_write, MAX_NUM_OF_REGISTERS_TO_WRITE_RW) && + pdu[9] == quantity_write * 2; + } + case FunctionCode::WRITE_SINGLE_COIL: + // The one variable field in an otherwise fixed-shape PDU: the spec allows exactly ON/OFF. + return is_canonical_coil_value(pdu[3], pdu[4]); + default: + return true; // All other function codes validated by length alone } } static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: + case SensorValueType::U_WORD_S: case SensorValueType::S_WORD: + case SensorValueType::S_WORD_S: return 2; case SensorValueType::U_DWORD: case SensorValueType::FP32: @@ -132,6 +233,11 @@ std::optional payload_to_number(const uint8_t *data, size_t size, Senso case SensorValueType::U_WORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; + case SensorValueType::U_WORD_S: { + uint16_t word = byteswap(get_data(data, offset)); + value = mask_and_shift_by_rightbit(word, bitmask); + break; + } case SensorValueType::U_DWORD: case SensorValueType::FP32: value = get_data(data, offset); @@ -146,6 +252,11 @@ std::optional payload_to_number(const uint8_t *data, size_t size, Senso case SensorValueType::S_WORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; break; + case SensorValueType::S_WORD_S: { + uint16_t word = byteswap(get_data(data, offset)); + value = mask_and_shift_by_rightbit(static_cast(word), bitmask); + break; + } case SensorValueType::S_DWORD: value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); break; @@ -197,101 +308,291 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } -StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, - uint16_t number_of_entities, const uint8_t *values, - size_t values_len) { - if (is_function_code_read(static_cast(function_code))) { +// Append a 16-bit value to a PDU in big-endian (wire) byte order. +template static void append_pdu_word(StaticVector &pdu, uint16_t value) { + pdu.push_back(value >> 8); + pdu.push_back(value >> 0); +} + +// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit +// fields (start address + quantity for reads and multi-writes, address + value for single writes). +template +static void append_pdu_header(StaticVector &pdu, FunctionCode function_code, uint16_t first, + uint16_t second) { + pdu.push_back(static_cast(function_code)); + append_pdu_word(pdu, first); + append_pdu_word(pdu, second); +} + +// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one +// place so the generic and typed coil builders produce identical wire bytes for the same write. +// The caller must pass a span whose LAST byte is the final packed-bit byte - both builders pass the +// whole PDU, which qualifies because the coil data is always the PDU's tail. +static void mask_trailing_pad_bits(std::span data, uint16_t bit_count) { + if (data.empty() || bit_count % 8 == 0) + return; + data.back() &= static_cast((1 << (bit_count % 8)) - 1); +} + +ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities) { + ReadPdu pdu; // declared before every return so NRVO fires (all paths return the same object) + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return pdu; + } + if (!address_range_fits(start_address, number_of_entities)) { + ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, + start_address); + return pdu; + } + + switch (function_code) { + case FunctionCode::READ_COILS: + if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); + return pdu; + } + break; + case FunctionCode::READ_DISCRETE_INPUTS: + if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); + return pdu; + } + break; + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); + return pdu; + } + break; + default: + ESP_LOGE(TAG, "Unsupported function code %02X for read PDU creation", static_cast(function_code)); + return pdu; + } + + append_pdu_header(pdu, function_code, start_address, number_of_entities); + return pdu; +} + +PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, + const uint8_t *values, size_t values_len) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + // Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), + // create_write_registers_pdu(), etc.) which bound their inputs per spec. + if (is_function_code_read_only(static_cast(function_code))) { if (values != nullptr || values_len > 0) { ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", static_cast(function_code)); } - } else if (is_function_code_write(static_cast(function_code))) { - if (values == nullptr || values_len == 0) { - ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); - return {}; - } - } else { + auto read_pdu = create_read_pdu(function_code, start_address, number_of_entities); + pdu.assign(read_pdu.begin(), read_pdu.end()); + return pdu; + } + // Exact codes only: is_function_code_write() masks the exception bit, which would let the + // exception-flagged forms (0x85/0x86/0x8F/0x90) build a request announcing itself as an exception. + const bool is_single = + function_code == FunctionCode::WRITE_SINGLE_COIL || function_code == FunctionCode::WRITE_SINGLE_REGISTER; + const bool is_multi = + function_code == FunctionCode::WRITE_MULTIPLE_COILS || function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; + if (!is_single && !is_multi) { ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); - return {}; + return pdu; } + // Generic write builder: raw caller-supplied bytes, so we can only guard against the PDU byte capacity here. + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return pdu; + } if (number_of_entities == 0) { ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); - return {}; + return pdu; + } + // number_of_entities is ignored for single write, so only validate it for the multiple variants. + // The bound is per function code (coils pack 8 per byte, so their quantity limit is far higher) - + // the same limits is_client_pdu_standard() accepts, so builder and validator agree. + const uint16_t max_entities = + function_code == FunctionCode::WRITE_MULTIPLE_COILS ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE; + if (!is_single && number_of_entities > max_entities) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum %u for function code %02X", number_of_entities, max_entities, + static_cast(function_code)); + return pdu; + } + if (!is_single && !address_range_fits(start_address, number_of_entities)) { + ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, + start_address); + return pdu; } - switch (function_code) { - case ModbusFunctionCode::READ_COILS: - if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { - ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", - number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); - return {}; - } - break; - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { - ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", - number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); - return {}; - } - break; - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: - if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", - number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); - return {}; - } - break; - case ModbusFunctionCode::WRITE_SINGLE_COIL: - case ModbusFunctionCode::WRITE_SINGLE_REGISTER: - break; // number_of_entities is ignored for single write, so no need to validate - case ModbusFunctionCode::WRITE_MULTIPLE_COILS: - case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: - if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", - number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); - return {}; - } - break; - default: - ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); - return {}; - } - - StaticVector pdu; - pdu.push_back(static_cast(function_code)); - pdu.push_back(start_address >> 8); - pdu.push_back(start_address >> 0); - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - pdu.push_back(number_of_entities >> 8); - pdu.push_back(number_of_entities >> 0); - } - - if (is_function_code_write(static_cast(function_code))) { - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values - static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; - if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { - ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, - MAX_WRITE_MULTIPLE_VALUES_LEN); - return {}; - } - pdu.push_back(values_len); // Byte count is required for write multiple - for (size_t i = 0; i < values_len; i++) - pdu.push_back(values[i]); - } else { - // Write single register or coil (2 bytes) - if (values_len < 2) { - ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); - return {}; - } - pdu.push_back(values[0]); - pdu.push_back(values[1]); + if (is_single) { + // Write single register or coil: the two value bytes are the header's second field. + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return pdu; } + // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil write - the same rule + // is_client_pdu_standard() enforces, so a built frame cannot be misclassified on reply. + if (function_code == FunctionCode::WRITE_SINGLE_COIL && !is_canonical_coil_value(values[0], values[1])) { + ESP_LOGE(TAG, "Invalid single-coil value %02X%02X (must be FF00 or 0000), dropping request", values[0], + values[1]); + return pdu; + } + append_pdu_header(pdu, function_code, start_address, uint16_t((values[0] << 8) | values[1])); + return pdu; + } + // The quantity is spec-bounded above, so the data length just has to agree with it exactly + // (registers are 2 bytes each, coils pack 8 per byte). This is the same consistency the response + // dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified + // non-standard on reply, and the spec bound keeps the PDU within capacity by construction. + // Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one. + const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS; + const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast(number_of_entities) * 2; + if (values_len != expected_len) { + ESP_LOGE(TAG, "values_len %zu does not match %u entities (expected %zu) for function code %02X, dropping request", + values_len, number_of_entities, expected_len, static_cast(function_code)); + return pdu; + } + append_pdu_header(pdu, function_code, start_address, number_of_entities); + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + if (bits) + mask_trailing_pad_bits(pdu, number_of_entities); + return pdu; +} + +// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not +// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure +// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in +// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each +// failure names its reason, and taking size_t so an oversize span is caught before any narrowing. +static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity, + uint16_t max_quantity) { + if (quantity == 0 || quantity > max_quantity) { + ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity); + return false; + } + if (!address_range_fits(start_address, quantity)) { + ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role), + quantity, start_address); + return false; + } + return true; +} + +PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) { + return pdu; + } + append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); + pdu.push_back(static_cast(values.size() * 2)); // byte count + for (auto v : values) { + append_pdu_word(pdu, v); } return pdu; } + +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values) { + PduBuffer pdu; + if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) { + return pdu; + } + if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(), + MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) { + return pdu; + } + // fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values. + const auto write_count = static_cast(write_values.size()); + pdu.push_back(static_cast(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS)); + append_pdu_word(pdu, read_start_address); + append_pdu_word(pdu, read_count); + append_pdu_word(pdu, write_start_address); + append_pdu_word(pdu, write_count); + pdu.push_back(static_cast(write_count * 2)); // byte count + for (auto v : write_values) { + append_pdu_word(pdu, v); + } + return pdu; +} + +WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value) { + WriteSinglePdu pdu; + append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_REGISTER, start_address, value); + return pdu; +} + +WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value) { + WriteSinglePdu pdu; + append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_COIL, address, value ? 0xFF00 : 0x0000); + return pdu; +} + +// Shared core for the two coil-write overloads: validates, then builds into the caller's named +// pdu (left empty on failure). Each overload's returns all name one local, so NRVO fires. +static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, PackedBits bits) { + const uint16_t count = bits.size(); + const std::span packed_bits = bits.bytes(); + if (count == 0) { + ESP_LOGE(TAG, "No coils requested for write multiple coils, dropping request"); + return; + } + if (count > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); + return; + } + if (!address_range_fits(start_address, count)) { + ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); + return; + } + const size_t byte_count = packed_bit_bytes(count); + if (packed_bits.size() < byte_count) { + ESP_LOGE(TAG, "packed_bits (%zu bytes) does not cover %u coils (%zu bytes), dropping request", packed_bits.size(), + count, byte_count); + return; + } + append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_COILS, start_address, count); + pdu.push_back(static_cast(byte_count)); + for (size_t i = 0; i != byte_count; i++) { + pdu.push_back(packed_bits[i]); + } + mask_trailing_pad_bits(pdu, count); +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) { + PduBuffer pdu; + build_write_coils_pdu(pdu, start_address, bits); + return pdu; +} + +// Shared by the two bool-container overloads: both index the same way, so the packing is written once. +template +static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) { + PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + const size_t count = values.size(); + // Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest. + if (count > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count, + MAX_NUM_OF_COILS_TO_WRITE); + return pdu; + } + CoilPackBuffer packed; + pack_bits(packed, values); + build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); + return pdu; +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { + return create_write_coils_pdu_from_bools(start_address, values); +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values) { + return create_write_coils_pdu_from_bools(start_address, values); +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 45a13f7582..c737e206c0 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -11,20 +11,36 @@ namespace esphome::modbus::helpers { -inline bool is_function_code_read(uint8_t function_code) { - ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); - return masked_function_code == ModbusFunctionCode::READ_COILS || - masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; +// Pure read codes (0x01-0x04): they only read, so they are idempotent and safe to retry. +inline bool is_function_code_read_only(uint8_t function_code) { + FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == FunctionCode::READ_COILS || + masked_function_code == FunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == FunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == FunctionCode::READ_INPUT_REGISTERS; } +// Codes whose response carries read-back data: the pure reads plus 0x17, which reads and writes at once. +inline bool is_function_code_read(uint8_t function_code) { + return is_function_code_read_only(function_code) || + static_cast(function_code & FUNCTION_CODE_MASK) == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// Codes that mutate registers or coils: the pure writes, 0x16 mask-write, and 0x17 read/write multiple. inline bool is_function_code_write(uint8_t function_code) { - ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); - return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; + FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == FunctionCode::WRITE_SINGLE_COIL || + masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS || + masked_function_code == FunctionCode::MASK_WRITE_REGISTER || + masked_function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// True if [start_address, start_address + count) fits within the 16-bit Modbus address space. The 32-bit +// promotion is the overflow guard - a 16-bit sum could wrap and pass. +inline bool address_range_fits(uint16_t start_address, size_t count) { + return uint32_t(start_address) + count <= 0x10000u; } inline bool is_function_code_exception(uint8_t function_code) { @@ -39,28 +55,70 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } -// Returns the expected length of a server response frame based on the function code -// If the frame is too short to determine the length, returns the minimum length -uint16_t server_frame_length(const uint8_t *frame, size_t size); +// Returns the expected length of a server response PDU based on the function code. +// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the +// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC +// bytes): only fixed header positions are interpreted, so surplus bytes are never misread. +uint16_t server_pdu_length(const uint8_t *frame, size_t size); +// Frame counterpart: address(1) + PDU + CRC(2). Passes every received byte after the address through, +// so header fields (e.g. a byte count) are interpreted as soon as they arrive. +inline uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; // function code not received yet + return server_pdu_length(frame + 1, size - 1) + 3; +} -// Returns the expected length of a client request frame based on the function code -// If the frame is too short to determine the length, returns the minimum length -uint16_t client_frame_length(const uint8_t *frame, size_t size); +// Returns the expected length of a client request PDU based on the function code. +// Same contract as server_pdu_length(): `size` is bytes available so far, may exceed the PDU. +uint16_t client_pdu_length(const uint8_t *frame, size_t size); +inline uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; // function code not received yet + return client_pdu_length(frame + 1, size - 1) + 3; +} +// Returns true if pdu is a complete transaction whose shape is consistent with its function code. +// Unlike *_pdu_length(), `size` here is the exact PDU length: a size mismatch is non-conformant. +// Function codes with nothing variable to cross-check are validated by their fixed length alone: the +// single writes (except 0x05's value field, which must be 0x0000 or 0xFF00), mask-write and FIFO, and +// - deliberately - custom/unknown codes and exception responses, so a dispatcher can still route +// them by function code rather than reject them outright. Tests pin this contract. +bool is_server_pdu_standard(const uint8_t *pdu, size_t size); + +// Client counterpart: additionally checks quantity bounds and address-range arithmetic per function code. +// The same acceptance rule applies to custom/unknown function codes. +bool is_client_pdu_standard(const uint8_t *pdu, size_t size); + +// Remove before 2027.2.0 +ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in 2027.2.0", "2026.8.0") inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { if (size < 2) return 0; - switch (static_cast(frame[1])) { - case ModbusFunctionCode::READ_COILS: - case ModbusFunctionCode::READ_DISCRETE_INPUTS: - case ModbusFunctionCode::READ_HOLDING_REGISTERS: - case ModbusFunctionCode::READ_INPUT_REGISTERS: + switch (static_cast(frame[1])) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) default: return 2; } } +/** Returns the payload portion of a server response PDU: the bytes after the function code, and for the + * read responses (0x01-0x04 and 0x17) also after the byte-count byte. Response 0x14 also carries a + * byte-count byte, but that code is not implemented and its count byte is left in the payload. For + * an exception PDU the payload is the exception code byte (the read check must not see the masked + * function code, or an exception-of-read would classify as a read and return an empty span). Returns an + * empty span if the PDU is too short. + */ +inline std::span server_pdu_payload(std::span pdu) { + if (pdu.empty()) + return {}; + const size_t offset = (!is_function_code_exception(pdu[0]) && is_function_code_read(pdu[0])) ? 2 : 1; + return pdu.size() > offset ? pdu.subspan(offset) : std::span(); +} + inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } enum class SensorValueType : uint8_t { @@ -77,39 +135,46 @@ enum class SensorValueType : uint8_t { U_QWORD_R = 0xA, S_QWORD_R = 0xB, FP32 = 0xC, - FP32_R = 0xD + FP32_R = 0xD, + U_WORD_S = 0xE, // 1 Register unsigned, bytes swapped + S_WORD_S = 0xF, // 1 Register signed, bytes swapped }; inline bool value_type_is_float(SensorValueType v) { return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; } -inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { +/// Coils and discrete inputs are the bit-addressed entity tables; the other types are 16-bit registers. +inline bool is_entity_type_binary(EntityType type) { + return type == EntityType::COIL || type == EntityType::DISCRETE_INPUT; +} + +inline FunctionCode modbus_register_read_function(EntityType reg_type) { switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::READ_COILS; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::READ_DISCRETE_INPUTS; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::INPUT_REGISTER: - return ModbusFunctionCode::READ_INPUT_REGISTERS; + case EntityType::COIL: + return FunctionCode::READ_COILS; + case EntityType::DISCRETE_INPUT: + return FunctionCode::READ_DISCRETE_INPUTS; + case EntityType::HOLDING: + return FunctionCode::READ_HOLDING_REGISTERS; + case EntityType::INPUT_REGISTER: + return FunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::INVALID; + return FunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { +inline FunctionCode modbus_register_write_function(EntityType reg_type, bool multiple = false) { switch (reg_type) { - case ModbusRegisterType::COIL: - return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::HOLDING: - return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + case EntityType::COIL: + return multiple ? FunctionCode::WRITE_MULTIPLE_COILS : FunctionCode::WRITE_SINGLE_COIL; + case EntityType::HOLDING: + return multiple ? FunctionCode::WRITE_MULTIPLE_REGISTERS : FunctionCode::WRITE_SINGLE_REGISTER; // These register types can't be written (per spec) - case ModbusRegisterType::INPUT_REGISTER: - case ModbusRegisterType::DISCRETE_INPUT: + case EntityType::INPUT_REGISTER: + case EntityType::DISCRETE_INPUT: default: - return ModbusFunctionCode::INVALID; + return FunctionCode::INVALID; } } @@ -208,6 +273,29 @@ inline bool bit_from_packed(int bit, std::span data) { ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } +/** Append packed bytes (LSB first) for the given bits onto a growable byte container. + * push_back-based so callers can build a payload incrementally (e.g. a std::vector + * with no fixed upper bound). A non-byte-aligned count appends n+1 bytes, the last holding + * the remaining bits in its low positions. + * @param out destination byte container exposing push_back(uint8_t) + * @param bits container of bool exposing range-based iteration + */ +template void pack_bits(Out &out, const Bits &bits) { + uint8_t byte = 0; + uint8_t bit = 0; + for (bool b : bits) { + if (b) + byte |= (1 << bit); + if (++bit == 8) { + out.push_back(byte); + byte = 0; + bit = 0; + } + } + if (bit != 0) // flush the final partial byte + out.push_back(byte); +} + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask @@ -242,6 +330,10 @@ template void number_to_payload(Container &data, int64_t val case SensorValueType::S_WORD: data.push_back(value & 0xFFFF); break; + case SensorValueType::U_WORD_S: + case SensorValueType::S_WORD_S: + data.push_back(byteswap(static_cast(value & 0xFFFF))); + break; case SensorValueType::U_DWORD: case SensorValueType::S_DWORD: case SensorValueType::FP32: @@ -308,7 +400,26 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy */ std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); -/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. +// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector, +// right-sized per shape) can be swapped in one place without touching every signature. +using PduBuffer = StaticVector; +using ReadPdu = StaticVector; +using WriteSinglePdu = StaticVector; +/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. +using CoilPackBuffer = StaticVector; + +/** Create a modbus read request PDU. + * @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read + * @return PDU (function code + data, no address, no CRC); empty on invalid input + */ +ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities); + +/** Create a modbus client pdu for reading/writing single/multiple coils/register/inputs. + * Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), + * create_write_registers_pdu(), create_write_single_register_pdu(), create_write_coils_pdu(), + * create_write_single_coil_pdu()) which bound their inputs per spec. * @param function_code the modbus function code to use. One of: * READ_COILS * READ_DISCRETE_INPUTS @@ -324,11 +435,84 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou * @param values_len length of values array * @return PDU (function code + data, no address, no CRC) */ -StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, - uint16_t number_of_entities, const uint8_t *values = nullptr, - size_t values_len = 0); +PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, + const uint8_t *values = nullptr, size_t values_len = 0); -inline std::vector float_to_payload(float value, SensorValueType value_type) { +/** Create modbus write multiple registers command + * Function 0x10 Write Multiple Registers + * @param start_address modbus address of the first register to write + * @param values register values to write; the register count is values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE, an over-long set is rejected and an empty PDU is returned). + * Any contiguous uint16_t container converts (std::vector, std::array). + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values); + +/** Create modbus read/write multiple registers command + * Function 0x17 Read/Write Multiple Registers + * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); + * the response carries only the read registers. + * @param read_start_address modbus address of the first register to read back + * @param read_count number of registers to read (at most MAX_NUM_OF_REGISTERS_TO_READ) + * @param write_start_address modbus address of the first register to write + * @param write_values register values to write; the register count is write_values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE_RW). Any contiguous uint16_t container converts. + * @return PDU (function code + data, no address, no CRC); an empty PDU on any out-of-range input + */ +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values); + +/** Create modbus write single register command + * Function 0x06 Write Single Register + * @param start_address modbus address of the register to write + * @param value uint16_t value to write + * @return PDU (function code + data, no address, no CRC) + */ +WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value); + +/** Create modbus write single coil command + * Function 0x05 Write Single Coil + * @param address modbus address of the coil to write + * @param value coil value to write + * @return PDU (function code + data, no address, no CRC) + */ +WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value); + +/** Create modbus write multiple coils command + * Function 0x0F Write Multiple Coils + * @param start_address modbus address of the first coil to write + * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an + * over-long set is rejected and an empty PDU is returned) + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values); + +/** Create modbus write multiple coils command (function 0x0F) from a std::vector. + * Prefer the span overload above whenever the coils are already in contiguous storage - a std::array + * or any other contiguous bool container converts to it. This overload exists only because std::vector + * is bit-packed and so cannot convert to a span; without it every caller holding one re-implements the packing. + * @param start_address modbus address of the first coil to write + * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an + * over-long set is rejected and an empty PDU is returned) + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values); + +/** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire. + * @param start_address modbus address of the first coil to write + * @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid + * input returns an empty PDU + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits); + +/** Append a float converted to register words to any push_back container (heap-free with StaticVector). + * @param data container the register words are appended to + * @param value value to convert + * @param value_type defines if 16/32/64 bits or FP32 is used + */ +template void float_to_payload(Container &data, float value, SensorValueType value_type) { int64_t val; if (value_type_is_float(value_type)) { @@ -337,8 +521,14 @@ inline std::vector float_to_payload(float value, SensorValueType value val = llroundf(value); } - std::vector data; number_to_payload(data, val, value_type); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the container overload of float_to_payload() instead. Removed in 2027.2.0", "2026.8.0") +inline std::vector float_to_payload(float value, SensorValueType value_type) { + std::vector data; + float_to_payload(data, value, value_type); return data; } diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py new file mode 100644 index 0000000000..bb113d649c --- /dev/null +++ b/esphome/components/modbus_client/__init__.py @@ -0,0 +1,546 @@ +from collections.abc import Callable +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_COUNT, + CONF_ID, + CONF_ON_ERROR, + CONF_ON_RESPONSE, + CONF_VALUE, +) +from esphome.core import ID, Lambda +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@exciton"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True +# The modbus hub auto-loads this component to make the actions available. Without this, that auto-load +# would try to create a device with no address. +MULTI_CONF_NO_DEFAULT = True + +CONF_ON_CUSTOM_RESPONSE = "on_custom_response" +CONF_ON_NO_RESPONSE = "on_no_response" +CONF_ON_NOT_SENT = "on_not_sent" +CONF_ON_SENT = "on_sent" +CONF_PDU = "pdu" +CONF_READ_ADDRESS = "read_address" +CONF_READ_COUNT = "read_count" +CONF_RETRY = "retry" +CONF_START_ADDRESS = "start_address" +CONF_VALUES = "values" +CONF_WRITE_ADDRESS = "write_address" + +modbus_client_ns = cg.esphome_ns.namespace("modbus_client") +ModbusClientSendAction = modbus_client_ns.class_( + "ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice +) +ReadRegistersAction = modbus_client_ns.class_( + "ReadRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleRegisterAction = modbus_client_ns.class_( + "WriteSingleRegisterAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleCoilAction = modbus_client_ns.class_( + "WriteSingleCoilAction", automation.Action, modbus.ModbusClientDevice +) +ReadBitsAction = modbus_client_ns.class_( + "ReadBitsAction", automation.Action, modbus.ModbusClientDevice +) + +WriteMultipleRegistersAction = modbus_client_ns.class_( + "WriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteMultipleCoilsAction = modbus_client_ns.class_( + "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice +) +ReadWriteMultipleRegistersAction = modbus_client_ns.class_( + "ReadWriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) + +# Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. +PackedBits = modbus.modbus_ns.class_("PackedBits") + +# The exception code passed to on_error handlers. +ExceptionCode = modbus.modbus_ns.enum("ExceptionCode") + +# Lambda argument types for the reply handlers: the device address the send targeted, and the +# request/response PDUs (function code + data). The spans are only valid for the duration of the handler. +_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) + +# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit +# (modbus.MAX_PDU_SIZE). Lambdas can return a byte list or a modbus::helpers::create_*_pdu() result. +# The list form below is bounded by cv.Length; a lambda cannot be. PduBuffer drops bytes past +# modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. +_PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") + +# A bare modbus::ModbusClientDevice bound to a hub and a device address, and nothing else - no polling, +# no entities, no automation wiring. It exists so a lambda can talk to a device directly: +# +# modbus_client: +# - id: my_client +# address: 0x01 +# +# - lambda: "id(my_client).write_single_register(0x10, 42);" +# +# Nothing here overrides the device callbacks, so every outcome takes the base class default, and those +# are no-ops: a successful reply, a Modbus exception, a timeout, and a frame that never reached the wire +# are all discarded without a log. The single exception is a reply the dispatch gate treats as +# non-standard, which warns once per device and logs at VERBOSE after that. So a lambda gets no feedback +# on an ordinary failure - use the modbus_client.* actions whenever the outcome matters, since they carry +# on_response/on_error/on_no_response/on_not_sent handlers. +# The id is required, not generated: the device is reachable only through id() in a lambda, so an +# entry without one builds something nothing can name. Better to say so than to accept dead config. +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(modbus.ModbusClientDevice), + } +).extend(modbus.modbus_device_schema(None)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await modbus.register_modbus_client_device(var, config) + + +def _packed_bit_bytes(bits: int) -> int: + """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" + return (bits + 7) // 8 + + +def _synchronous_handler(value: ConfigType) -> ConfigType: + """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused + once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " + "handlers: the request/response data is only valid while the handler runs. Copy what you " + "need into globals first, then defer in a separate script or automation." + ) + return value + + +def _handler_schema() -> cv.All: + return cv.All(automation.validate_automation(single=True), _synchronous_handler) + + +# Each action is its own hub device: the modbus hub routes the reply straight back to the action that +# sent it, so the address can even be templatable - the reply is matched by the action's identity, not +# its address. +_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient), + cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t), + # Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive + # later (fire-and-continue), so all run with the request/reply available - not the outer + # automation's variables. + cv.Optional(CONF_ON_SENT): _handler_schema(), + cv.Optional(CONF_ON_ERROR): _handler_schema(), + # on_no_response takes either a returning lambda (`!lambda "return ;"`, gets `request`, + # returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation + # form may also carry an optional `retry:` returning lambda to run actions AND decide the retry. + cv.Optional(CONF_ON_NO_RESPONSE): cv.All( + cv.Any( + cv.returning_lambda, + automation.validate_automation( + {cv.Optional(CONF_RETRY): cv.returning_lambda}, single=True + ), + ), + _synchronous_handler, + ), + cv.Optional(CONF_ON_NOT_SENT): _handler_schema(), + } +) + +MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend( + { + cv.Required(CONF_PDU): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint8_t), + cv.Length(min=1, max=modbus.MAX_PDU_SIZE), + ) + ), + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + } +) + + +async def register_client_action( + var: cg.MockObj, + config: ConfigType, + args: TemplateArgsType, + response_args: TemplateArgsType, +) -> cg.MockObj: + """Wire the shared action plumbing: hub parent, templated device address, outcome triggers. + + response_args are the on_response handler's arguments, which differ per action. + """ + parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add( + var.set_target_address( + await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) + ) + ) + # Present for every typed action and absent from modbus_client.send, which has a pdu instead. + if (start_address := config.get(CONF_START_ADDRESS)) is not None: + cg.add( + var.set_start_address(await cg.templatable(start_address, args, cg.uint16)) + ) + if sent_conf := config.get(CONF_ON_SENT): + await automation.build_automation( + var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf + ) + if response_conf := config.get(CONF_ON_RESPONSE): + await automation.build_automation( + var.get_response_trigger(), response_args, response_conf + ) + if custom_conf := config.get(CONF_ON_CUSTOM_RESPONSE): + # Tell the action a handler exists; without this it falls back to the base's warn-once log so an + # unhandled diverted reply is still reported instead of firing an empty trigger. + cg.add(var.set_custom_response_handled()) + await automation.build_automation( + var.get_custom_response_trigger(), + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + custom_conf, + ) + if error_conf := config.get(CONF_ON_ERROR): + await automation.build_automation( + var.get_error_trigger(), + [(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")], + error_conf, + ) + if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None: + # The lambda form IS the retry decision; the automation form runs actions and may carry a nested + # `retry:` lambda. Either way the retry lambda's bool becomes on_no_response()'s return value. + if isinstance(no_response_conf, Lambda): + retry_conf = no_response_conf + else: + await automation.build_automation( + var.get_no_response_trigger(), + [(_PDU_SPAN, "request")], + no_response_conf, + ) + retry_conf = no_response_conf.get(CONF_RETRY) + if retry_conf is not None: + retry_lambda = await cg.process_lambda( + retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_ + ) + cg.add(var.set_retry(retry_lambda)) + if not_sent_conf := config.get(CONF_ON_NOT_SENT): + await automation.build_automation( + var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf + ) + return var + + +@automation.register_action( + "modbus_client.send", + ModbusClientSendAction, + MODBUS_CLIENT_SEND_SCHEMA, + synchronous=True, +) +async def modbus_client_send_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) + cg.add(var.set_pdu(template_)) + return await register_client_action( + var, + config, + args, + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + ) + + +# --- Typed actions: request PDUs come from the device base's typed senders, replies from its dispatch, +# --- so on_response delivers decoded arguments (host-order words) instead of raw PDU spans. + +_REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) + +# The reply-handler pair every typed-dispatch action reports through. Kept in one place so the +# read/write-multiple schema (which cannot require start_address) shares it instead of drifting. +# Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the hub +# reuses once the handler returns, so a deferring action would resume on freed memory. A reply the +# dispatch gate diverts (not a standard-conformant transaction) arrives at on_custom_response with the +# raw request/response PDUs; real device exceptions still arrive via on_error. +_REPLY_HANDLERS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), + } +) + +# Every typed action addresses a register or coil range and reports through the shared reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + } +) + + +def _no_address_overflow( + count_key: str, address_key: str = CONF_START_ADDRESS +) -> Callable[[ConfigType], ConfigType]: + """Reject a range that runs past the 16-bit address space, which the device could never answer. + + Only literal configurations can be checked: either operand may be a lambda, and its value is not known + until play(). The PDU builders repeat this check at runtime, so the lambda case is still rejected and + logged - just later. + """ + + def validate(config: ConfigType) -> ConfigType: + start = config[address_key] + count = config[count_key] + if isinstance(start, Lambda) or isinstance(count, Lambda): + return config + # A count key holds a number; a values key holds the list whose length is the count. + length = count if isinstance(count, int) else len(count) + if start + length > 0x10000: + raise cv.Invalid( + f"{address_key} 0x{start:04X} plus {length} entities runs past the end of the " + f"16-bit address space (last addressable entity is 0xFFFF)", + path=[address_key], + ) + return config + + return validate + + +def _read_schema(max_count: int) -> cv.All: + """Read action schema. The spec sets the read ceiling per function code, so each one passes its own.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Optional(CONF_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=max_count) + ), + } + ), + _no_address_overflow(CONF_COUNT), + ) + + +def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.All: + """Multi-write action schema, differing only in the element type and the spec's per-function limit.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUES): cv.templatable( + cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) + ), + } + ), + _no_address_overflow(CONF_VALUES), + ) + + +_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) + +_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +) + +# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. +_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +) + + +async def _read_registers_to_code(config, action_id, template_arg, args, holding): + var = cg.new_Pvariable(action_id, template_arg, holding) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) + + +@automation.register_action( + "modbus_client.read_holding_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_holding_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_input_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_input_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, False) + + +async def _write_single_to_code(config, action_id, template_arg, args, value_type): + var = cg.new_Pvariable(action_id, template_arg) + cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_single_register", + WriteSingleRegisterAction, + _WRITE_SINGLE_REGISTER_SCHEMA, + synchronous=True, +) +async def write_single_register_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.uint16) + + +@automation.register_action( + "modbus_client.write_single_coil", + WriteSingleCoilAction, + _WRITE_SINGLE_COIL_SCHEMA, + synchronous=True, +) +async def write_single_coil_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.bool_) + + +_READ_COILS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_COILS_TO_READ) +_READ_DISCRETE_INPUTS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) + + +async def _read_bits_to_code(config, action_id, template_arg, args, coils): + var = cg.new_Pvariable(action_id, template_arg, coils) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(PackedBits, "bits")]) + + +@automation.register_action( + "modbus_client.read_coils", + ReadBitsAction, + _READ_COILS_SCHEMA, + synchronous=True, +) +async def read_coils_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_discrete_inputs", + ReadBitsAction, + _READ_DISCRETE_INPUTS_SCHEMA, + synchronous=True, +) +async def read_discrete_inputs_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, False) + + +_WRITE_MULTIPLE_REGISTERS_SCHEMA = _write_multiple_schema( + cv.hex_uint16_t, modbus.MAX_NUM_OF_REGISTERS_TO_WRITE +) + +_WRITE_MULTIPLE_COILS_SCHEMA = _write_multiple_schema( + cv.boolean, modbus.MAX_NUM_OF_COILS_TO_WRITE +) + + +@automation.register_action( + "modbus_client.write_multiple_registers", + WriteMultipleRegistersAction, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_multiple_coils", + WriteMultipleCoilsAction, + _WRITE_MULTIPLE_COILS_SCHEMA, + synchronous=True, +) +async def write_multiple_coils_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.bool_)) + cg.add(var.set_values_template(templ)) + else: + # Pack to wire layout (LSB first) here, so the runtime neither allocates nor packs. + packed = bytearray(_packed_bit_bytes(len(values))) + for i, coil in enumerate(values): + if coil: + packed[i // 8] |= 1 << (i % 8) + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) + + +# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single +# transaction, so it has two address ranges and uses read_address/write_address instead of start_address. +# Note the two meanings of `values`: here it is the block being WRITTEN, while in on_response the lambda +# argument `values` is the block that was READ BACK (host-order words, the same shape as +# read_holding_registers, so a caller can feed it through the same handler). +_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( + _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_READ_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Optional(CONF_READ_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_READ) + ), + cv.Required(CONF_WRITE_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Required(CONF_VALUES): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint16_t), + cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), + ) + ), + } + ), + _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), + _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), +) + + +@automation.register_action( + "modbus_client.read_write_multiple_registers", + ReadWriteMultipleRegistersAction, + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + cg.add( + var.set_read_address( + await cg.templatable(config[CONF_READ_ADDRESS], args, cg.uint16) + ) + ) + cg.add( + var.set_read_count( + await cg.templatable(config[CONF_READ_COUNT], args, cg.uint16) + ) + ) + cg.add( + var.set_write_address( + await cg.templatable(config[CONF_WRITE_ADDRESS], args, cg.uint16) + ) + ) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h new file mode 100644 index 0000000000..20dc1a4745 --- /dev/null +++ b/esphome/components/modbus_client/modbus_client.h @@ -0,0 +1,388 @@ +#pragma once + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include +#include + +namespace esphome::modbus_client { + +/// Shared base for the modbus_client actions. Each ACTION INSTANCE is its own modbus::ModbusClientDevice: +/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no +/// central client object and no request matching. The device address is templatable; it is stamped on the +/// device at play() time; the hub routes each reply by device pointer, so a changed address never +/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping +/// sends it could misreport, and the handler can recompute the expression it configured.) +template class ClientActionBase : public Action, public modbus::ModbusClientDevice { + public: + TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address + + Trigger> *get_sent_trigger() { return &this->sent_trigger_; } + Trigger, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; } + Trigger> *get_no_response_trigger() { return &this->no_response_trigger_; } + Trigger> *get_not_sent_trigger() { return &this->not_sent_trigger_; } + + /// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue + /// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with + /// the no_response trigger (actions run, then this decides the retry). + using retry_func_t = bool (*)(std::span); + void set_retry(retry_func_t f) { this->retry_func_ = f; } + + /// The frame was written to the wire: fires once per transmission, before any reply, and never for a + /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). + void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } + /// Never reached the wire, from either of two sources. The hub calls this for a request it accepted + /// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus + /// device going offline, say. Everything the hub refuses at the door instead returns false from + /// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same + /// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder. + void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } + /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: + /// register_client_action() wires on_error for all of them, so a derived class must not have to + /// remember the override. + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override { + this->error_trigger_.trigger(request_pdu, exception_code); + } + /// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form), + /// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The + /// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry. + bool on_no_response(std::span request_pdu) override { + this->no_response_trigger_.trigger(request_pdu); + if (this->retry_func_ != nullptr) + return this->retry_func_(request_pdu); + return false; + } + /// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub + /// routes each reply by device pointer, so a changed address never mis-routes earlier replies. + void play_complex(const Ts &...x) override { + this->set_address(this->target_address_.value(x...)); + Action::play_complex(x...); + } + + protected: + /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full + /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every + /// send still gets exactly one outcome (a broadcast (address 0) is the exception - never answered, it + /// resolves through on_sent() alone), so resolve refusals here via on_not_sent. + /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and + /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. + void send_or_resolve_(std::span pdu) { + if (!this->queue_pdu(pdu)) + this->on_not_sent(pdu); + } + + Trigger> sent_trigger_; + Trigger, modbus::ExceptionCode> error_trigger_; + Trigger> no_response_trigger_; + Trigger> not_sent_trigger_; + retry_func_t retry_func_{nullptr}; +}; + +/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is +/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so +/// non-standard/custom transactions pass through untouched. +/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the +/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). +/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check +/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +template class ModbusClientSendAction : public ClientActionBase { + public: + TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) + + Trigger, std::span> *get_response_trigger() { + return &this->response_trigger_; + } + + void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); } + + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + Trigger, std::span> response_trigger_; +}; + +/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs +/// the shared dispatch (validation gate + decode) and the typed callbacks below fire directly on the +/// action. A reply the gate diverts (not a standard-conformant transaction) fires the on_custom_response +/// trigger with the raw request/response PDUs, so non-standard replies stay handleable; the spans are only +/// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the +/// response, never with an exception status - real device exceptions arrive via on_error, which +/// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a +/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch +/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an +/// exception as a successful reply. +template class TypedClientActionBase : public ClientActionBase { + public: + Trigger, std::span> *get_custom_response_trigger() { + return &this->custom_response_trigger_; + } + /// Set by codegen when the config declares on_custom_response. Without it an unhandled diverted reply + /// would fire an empty trigger and vanish, so the base's warn-once diagnostic has to stay reachable. + void set_custom_response_handled() { this->custom_response_handled_ = true; } + + void on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) override { + if (!this->custom_response_handled_) { + modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status); + return; + } + this->custom_response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + Trigger, std::span> custom_response_trigger_; + bool custom_response_handled_{false}; +}; + +/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in +/// host byte order as `values` (only valid for the duration of the trigger). +template class ReadRegistersAction : public TypedClientActionBase { + public: + explicit ReadRegistersAction(bool holding) : holding_(holding) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + bool holding_; +}; + +/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view +/// (bit 0 = the bit at start_address; only valid for the duration of the trigger). +template class ReadBitsAction : public TypedClientActionBase { + public: + explicit ReadBitsAction(bool coils) : coils_(coils) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(bits); + } + + protected: + Trigger response_trigger_; + bool coils_; +}; + +/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the +/// request, so it carries no arguments). +template class WriteSingleRegisterAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one +/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. +template class WriteSingleCoilAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(bool, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_multiple_registers: on_response is the acknowledgement (no arguments). +/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a +/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static +/// list must not allocate on every play(). +template class WriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: the registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the registers are only known at play() time. Stateless lambdas (all ESPHome + /// generates) convert to a plain function pointer, so this stays pointer-sized. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + // An empty or over-long set rejects into an empty PDU inside the builder, which logs the reason; + // the empty PDU then resolves via on_not_sent like any refused send. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( + start, std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + } + void on_write_multiple_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + +/// modbus_client.write_multiple_coils: on_response is the acknowledgement (no arguments). +/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() +/// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than +/// a byte - and is packed into a stack buffer on the way to the builder. +template class WriteMultipleCoilsAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: `packed` is the wire layout (LSB first) held in flash, `count` the number of coils. + void set_values_static(const uint8_t *packed, size_t count) { + this->values_.packed = packed; + this->count_ = static_cast(count); + } + /// Lambda config: the coils are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->count_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + if (this->count_ >= 0) { + const auto count = static_cast(this->count_); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( + start, + modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + return; + } + // The builder packs and bound-checks; an over-long set is rejected and logged there. + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + } + void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t count_{-1}; // -1 = template mode, >= 0 = static mode with this many coils + union Values { + std::vector (*func)(Ts...); + const uint8_t *packed; + } values_; +}; + +/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in +/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. +template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, read_address) + TEMPLATABLE_VALUE(uint16_t, read_count) + TEMPLATABLE_VALUE(uint16_t, write_address) + + /// Static config: the write registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the write registers are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t read_start = this->read_address_.value(x...); + const uint16_t read_count = this->read_count_.value(x...); + const uint16_t write_start = this->write_address_.value(x...); + // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, std::span(values))); + } + // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many write registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + +} // namespace esphome::modbus_client diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 527e9b047f..1ce1e38d16 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -6,7 +6,7 @@ from esphome.components import modbus from esphome.components.modbus.helpers import ( MODBUS_REGISTER_TYPE, TYPE_REGISTER_MAP, - ModbusRegisterType, + EntityType, ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET @@ -42,22 +42,38 @@ AUTO_LOAD = ["modbus"] MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") -ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice -) +ModbusController = modbus_controller_ns.class_("ModbusController", cg.PollingComponent) SensorItem = modbus_controller_ns.struct("SensorItem") _LOGGER = logging.getLogger(__name__) +# Remove before 2027.2.0 +_REMOVED_OPTIONS = { + CONF_COMMAND_THROTTLE: "Command spacing is handled by the 'modbus' component - use 'turnaround_time' there instead.", + CONF_ALLOW_DUPLICATE_COMMANDS: "Polling commands are deduplicated by the modbus hub; one-shot commands (writes) are always transmitted.", +} + + +def _warn_removed_options(config: ConfigType) -> ConfigType: + """Warn about options that no longer do anything, but let the config compile.""" + for option, replacement in _REMOVED_OPTIONS.items(): + if option in config: + _LOGGER.warning( + "[modbus_controller] '%s' no longer has any effect and will be removed in 2027.2.0. %s", + option, + replacement, + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ModbusController), - cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS, default=False): cv.boolean, - cv.Optional( - CONF_COMMAND_THROTTLE, default="0ms" - ): cv.positive_time_period_milliseconds, + # Removed options: accepted (and ignored) until 2027.2.0 so existing configs keep building. + cv.Optional(CONF_ALLOW_DUPLICATE_COMMANDS): cv.boolean, + cv.Optional(CONF_COMMAND_THROTTLE): cv.positive_time_period_milliseconds, cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" ), @@ -74,7 +90,8 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(modbus.modbus_device_schema(0x01)) + .extend(modbus.modbus_device_schema(0x01)), + _warn_removed_options, ) ModbusItemBaseSchema = cv.Schema( @@ -170,10 +187,7 @@ async def add_modbus_base_properties( [ (sensor_type.operator("ptr"), "item"), (lambda_param_type, "x"), - ( - cg.std_vector.template(cg.uint8).operator("const").operator("ref"), - "data", - ), + (cg.std_span.template(cg.uint8.operator("const")), "data"), ], return_type=cg.optional.template(lambda_return_type), ) @@ -201,8 +215,6 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) - cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE])) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) await register_modbus_device(var, config) @@ -217,13 +229,13 @@ async def register_modbus_device(var, config): def function_code_to_register(function_code): FUNCTION_CODE_TYPE_MAP = { - "read_coils": ModbusRegisterType.COIL, - "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, - "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.INPUT_REGISTER, - "write_single_coil": ModbusRegisterType.COIL, - "write_single_register": ModbusRegisterType.HOLDING, - "write_multiple_coils": ModbusRegisterType.COIL, - "write_multiple_registers": ModbusRegisterType.HOLDING, + "read_coils": EntityType.COIL, + "read_discrete_inputs": EntityType.DISCRETE_INPUT, + "read_holding_registers": EntityType.HOLDING, + "read_input_registers": EntityType.INPUT_REGISTER, + "write_single_coil": EntityType.COIL, + "write_single_register": EntityType.HOLDING, + "write_multiple_coils": EntityType.COIL, + "write_multiple_registers": EntityType.HOLDING, } return FUNCTION_CODE_TYPE_MAP[function_code] diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 9656013a5f..b0c927cf84 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -7,17 +7,18 @@ static const char *const TAG = "modbus_controller.binary_sensor"; void ModbusBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Modbus Controller Binary Sensor", this); } -void ModbusBinarySensor::parse_and_publish(const std::vector &data) { +void ModbusBinarySensor::parse_and_publish(std::span data) { bool value; + // For coils/discrete inputs this is the bit index; for registers it is the byte offset. + const size_t offset = this->offset; switch (this->register_type) { - case ModbusRegisterType::DISCRETE_INPUT: - case ModbusRegisterType::COIL: - // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::bit_from_packed(this->offset, data); + case modbus::EntityType::DISCRETE_INPUT: + case modbus::EntityType::COIL: + value = modbus::helpers::bit_from_packed(offset, data); break; default: - value = modbus::helpers::get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data.data(), offset) & this->bitmask; break; } // Is there a lambda registered diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 3f7c6b4dd6..62a7fe93d3 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -4,35 +4,35 @@ #include "esphome/components/modbus_controller/modbus_controller.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: - ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; this->force_new_range = force_new_range; - if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) { + if (modbus::helpers::is_entity_type_binary(register_type)) { this->register_count = offset + 1; } else { this->register_count = 1; } } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void set_state(bool state) { this->state = state; } void dump_config() override; - using transform_func_t = optional (*)(ModbusBinarySensor *, bool, const std::vector &); + using transform_func_t = optional (*)(ModbusBinarySensor *, bool, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } protected: diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 9246239ef9..2c568938e4 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -2,307 +2,363 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; -void ModbusController::setup() { this->create_register_ranges_(); } +void ModbusController::setup() { this->create_polling_commands_(); } -/* - To work with the existing modbus class and avoid polling for responses a command queue is used. - send_next_command will submit the command at the top of the queue and set the corresponding callback - to handle the response from the device. - Once the response has been processed it is removed from the queue and the next command is sent -*/ -bool ModbusController::send_next_command_() { - uint32_t last_send = millis() - this->last_command_timestamp_; +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range) + : modbus::ModbusClientDevice(parent, address), + sensors(std::move(range.sensors)), + skip_updates(range.skip_updates), + register_type_(range.register_type), + start_address_(range.start_address), + register_count_(range.register_count), + function_code_(modbus::helpers::modbus_register_read_function(range.register_type)), + controller_(&controller) {} - if ((last_send > this->command_throttle_) && this->ready_for_immediate_send() && !this->command_queue_.empty()) { - auto &command = this->command_queue_.front(); - - // remove from queue if command was sent too often - if (!command->should_retry(this->max_cmd_retries_)) { - if (!this->module_offline_) { - ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Update skip_updates_counter to stop flooding channel with timeouts - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = this->offline_skip_updates_; - } - } - - this->module_offline_ = true; - this->offline_callback_.call((int) command->function_code, command->register_address); - } - ESP_LOGD(TAG, "Modbus command to device=%d register=0x%02X no response received - removed from send queue", - this->address_, command->register_address); - this->command_queue_.pop_front(); - } else { - ESP_LOGV(TAG, "Sending next modbus command to device %d register 0x%02X count %d", this->address_, - command->register_address, command->register_count); - command->send(); - - this->last_command_timestamp_ = millis(); - - this->command_sent_callback_.call((int) command->function_code, command->register_address); - - // remove from queue if no handler is defined - if (!command->on_data_func) { - this->command_queue_.pop_front(); - } - } - } - return (!this->command_queue_.empty()); +ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + SensorItem *sensor) + : modbus::ModbusClientDevice(parent, address), + skip_updates(sensor->skip_updates), + start_address_(sensor->start_address), + register_count_(sensor->register_count), + function_code_(FunctionCode::CUSTOM), + custom_data_(&sensor->custom_data), + controller_(&controller) { + this->sensors.insert(sensor); } -// Queue incoming response -void ModbusController::on_modbus_data(const std::vector &data) { - if (this->command_queue_.empty()) { - ESP_LOGW(TAG, "Received modbus data but command queue is empty"); - return; +// The base deletes copy/move; command items re-provide construction. The moved-from device must not +// unregister the hub slot we just took over, so its parent_ is cleared. The copy constructor exists +// only for callers that pass an lvalue to queue_command() (in-tree callers move); remove it when +// queue_command() is removed. +ModbusCommandItem::ModbusCommandItem(const ModbusCommandItem &other) + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(other.sensors), + skip_updates(other.skip_updates), + on_data_func(other.on_data_func), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + // SmallInlineBuffer is move-only, so deep-copy the bytes explicitly. + this->payload.set(other.payload.data(), other.payload.size()); +} + +ModbusCommandItem::ModbusCommandItem(ModbusCommandItem &&other) noexcept + : modbus::ModbusClientDevice(other.parent_, other.address_), + sensors(std::move(other.sensors)), + skip_updates(other.skip_updates), + on_data_func(std::move(other.on_data_func)), + payload(std::move(other.payload)), + register_type_(other.register_type_), + start_address_(other.start_address_), + register_count_(other.register_count_), + function_code_(other.function_code_), + custom_data_(other.custom_data_), + controller_(other.controller_) { + other.parent_ = nullptr; +} + +// A valid response: the device is online. Dispatch the payload to the handler or the range's sensors. +void ModbusCommandItem::on_response(std::span request_pdu, std::span response_pdu) { + if (this->controller_ != nullptr) + this->controller_->set_online(true, static_cast(this->function_code_), this->start_address_); + auto data = modbus::helpers::server_pdu_payload(response_pdu); + if (this->on_data_func) { + this->on_data_func(this->register_type_, this->start_address_, data); + } else if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + // write acknowledgement - nothing to publish + } else { + for (auto *sensor : this->sensors) + sensor->parse_and_publish(data); } - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// An exception response is still a legitimate reply, so the device is considered online. +void ModbusCommandItem::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { + const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0]; + ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", function_code, this->start_address_, + static_cast(exception_code)); + if (this->controller_ != nullptr) { + this->controller_->set_online(true, function_code, this->start_address_); + this->controller_->unqueue_command(this); + } +} + +// Not being sent says nothing about online/offline status; just drop it from the pending list. +void ModbusCommandItem::on_not_sent(std::span request_pdu) { + // A dropped write is lost while the entity has already published optimistically, so surface it. + if (modbus::helpers::is_function_code_write(static_cast(this->function_code_))) { + ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", static_cast(this->function_code_), + this->start_address_); + } + if (this->controller_ != nullptr) + this->controller_->unqueue_command(this); +} + +// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent +// trigger reflects when the frame actually went out, not when it was queued. +void ModbusCommandItem::on_sent(std::span request_pdu) { + if (this->controller_ == nullptr) + return; + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + // A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback. + // on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak. + // Test the address the frame went to, not address_: a custom command's frame carries its own address + // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) + uint8_t wire_address = this->address_; + if (this->function_code_ == FunctionCode::CUSTOM) { + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (!frame.empty()) + wire_address = frame[0]; + } + if (wire_address == modbus::BROADCAST_ADDRESS) + this->controller_->unqueue_command(this); +} + +bool ModbusCommandItem::on_no_response(std::span request_pdu) { + if (this->controller_ == nullptr) + return false; + this->controller_->increment_non_response_count(); + if (this->controller_->can_send()) { + // Have the hub re-queue the frame it is holding; on_sent fires again when it goes back out. + return true; + } + this->controller_->set_online(false, static_cast(this->function_code_), this->start_address_); + this->controller_->unqueue_command(this); + return false; +} + +void ModbusController::set_online(bool online, int function_code, int register_address) { + if (online) { + this->cmd_non_responses_ = 0; if (this->module_offline_) { ESP_LOGW(TAG, "Modbus device=%d back online", this->address_); - - if (this->offline_skip_updates_ > 0) { - // Restore skip_updates_counter to restore commands updates - for (auto &r : this->register_ranges_) { - r.skip_updates_counter = 0; - } - } - // Restore module online state this->module_offline_ = false; - this->online_callback_.call((int) current_command->function_code, current_command->register_address); + this->online_callback_.call(function_code, register_address); + } + } else { + // Offline is a property of the physical device, so drop every sender's queued frames for its + // address; retired frames get on_not_sent(), which reclaims one-shots through the normal path. + this->hub_->clear_tx_queue_for_address(this->address_); + if (!this->module_offline_) { + ESP_LOGW(TAG, "Modbus device=%d set offline", this->address_); + this->module_offline_ = true; + this->module_offline_at_ = this->update_counter_; + this->offline_callback_.call(function_code, register_address); } - - // Move the commandItem to the response queue - current_command->payload = data; - this->incoming_queue_.push(std::move(current_command)); - ESP_LOGV(TAG, "Modbus response queued"); - this->command_queue_.pop_front(); } } -// Dispatch the response to the registered handler -void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { - ESP_LOGV(TAG, "Process modbus response for address 0x%X size: %zu", response->register_address, - response->payload.size()); - response->on_data_func(response->register_type, response->register_address, response->payload); +void ModbusController::queue_command(ModbusCommandItem command) { + this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one + // Duplicates are the caller's to manage; the controller only holds the item until its terminal callback. + this->one_shot_command_items_.push_back(make_unique(std::move(command))); + // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. + auto &item = this->one_shot_command_items_.back(); + if (!item->send()) { + // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. + ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), + item->register_address()); + item->pending_removal = true; + } } -void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_code) { - ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, exception_code); - if (this->command_queue_.empty()) { +void ModbusController::unqueue_command(const ModbusCommandItem *command) { + // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/ + // on_no_response), which the hub runs from inside its sweep while this entry is still live. + // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it; + // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands + // (they persist and are not in the one-shot list). + for (auto &item : this->one_shot_command_items_) { + if (item.get() == command) { + item->pending_removal = true; + return; + } + } +} + +void ModbusController::sweep_completed_one_shots_() { + this->one_shot_command_items_.remove_if( + [](const std::unique_ptr &item) { return item->pending_removal; }); +} + +void ModbusController::update_range_(ModbusCommandItem &cmd) { + if (this->update_counter_ % (cmd.skip_updates + 1) != 0) { + ESP_LOGVV(TAG, "Skipping update for range 0x%X", cmd.register_address()); return; } - // Remove pending command waiting for a response - auto ¤t_command = this->command_queue_.front(); - if (current_command != nullptr) { - ESP_LOGE(TAG, - "Modbus error - last command: function code=0x%X register address = 0x%X " - "registers count=%d " - "payload size=%zu", - function_code, current_command->register_address, current_command->register_count, - current_command->payload.size()); - this->command_queue_.pop_front(); - } + // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. + if (!cmd.send()) + ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); } -SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const { - auto reg_it = std::find_if( - std::begin(this->register_ranges_), std::end(this->register_ranges_), - [=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); }); - - if (reg_it == this->register_ranges_.end()) { - ESP_LOGE(TAG, "No matching range for sensor found - start_address : 0x%X", start_address); - } else { - return reg_it->sensors; - } - - // not found - return {}; -} -void ModbusController::on_register_data(ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address); - - // loop through all sensors with the same start address - auto sensors = find_sensors_(register_type, start_address); - for (auto *sensor : sensors) { - sensor->parse_and_publish(data); - } -} - -void ModbusController::queue_command(const ModbusCommandItem &command) { - if (!this->allow_duplicate_commands_) { - // check if this command is already qeued. - // not very effective but the queue is never really large - for (auto &item : this->command_queue_) { - if (item->is_equal(command)) { - ESP_LOGW(TAG, "Duplicate modbus command found: type=0x%x address=%u count=%u", - static_cast(command.register_type), command.register_address, command.register_count); - // update the payload of the queued command - // replaces a previous command - item->payload = command.payload; - return; - } - } - } - this->command_queue_.push_back(make_unique(command)); -} - -void ModbusController::update_range_(RegisterRange &r) { - ESP_LOGV(TAG, "Range : %X Size: %x (%d) skip: %d", r.start_address, r.register_count, (int) r.register_type, - r.skip_updates_counter); - if (r.skip_updates_counter == 0) { - // if a custom command is used the user supplied custom_data is only available in the SensorItem. - if (r.register_type == ModbusRegisterType::CUSTOM) { - auto sensors = this->find_sensors_(r.register_type, r.start_address); - if (!sensors.empty()) { - auto sensor = sensors.cbegin(); - auto command_item = ModbusCommandItem::create_custom_command( - this, (*sensor)->custom_data, - [this](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - this->on_register_data(ModbusRegisterType::CUSTOM, start_address, data); - }); - command_item.register_address = (*sensor)->start_address; - command_item.register_count = (*sensor)->register_count; - command_item.function_code = ModbusFunctionCode::CUSTOM; - queue_command(command_item); +void ModbusController::update() { + this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks + if (this->module_offline_) { + // Offline probing follows the offline cadence alone; per-range skip_updates resumes once the + // device is back online. Requiring both cadences to coincide would leave phase combinations + // where a probe never goes out. + if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) { + ESP_LOGV(TAG, "Module offline - retrying"); + this->cmd_non_responses_ = 0; // allow the probe through can_send() + for (auto &cmd : this->polling_command_items_) { + if (!cmd.send()) + ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); } } else { - queue_command(ModbusCommandItem::create_read_command(this, r.register_type, r.start_address, r.register_count)); + ESP_LOGV(TAG, "Module offline - skipping update"); } - r.skip_updates_counter = r.skip_updates; // reset counter to config value - } else { - r.skip_updates_counter--; - } -} -// -// Queue the modbus requests to be send. -// Once we get a response to the command it is removed from the queue and the next command is send -// -void ModbusController::update() { - if (!this->command_queue_.empty()) { - ESP_LOGV(TAG, "%zu modbus commands already in queue", this->command_queue_.size()); - } else { - ESP_LOGV(TAG, "Updating modbus component"); + this->update_counter_++; + return; } - for (auto &r : this->register_ranges_) { - ESP_LOGVV(TAG, "Updating range 0x%X", r.start_address); - update_range_(r); + if (this->can_send()) { + for (auto &cmd : this->polling_command_items_) { + ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address()); + this->update_range_(cmd); + } } + this->update_counter_++; } // walk through the sensors and determine the register ranges to read -size_t ModbusController::create_register_ranges_() { - this->register_ranges_.clear(); +void ModbusController::create_polling_commands_() { if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); - return 0; + return; } - // iterator is sorted see SensorItemsComparator for details - auto ix = this->sensorset_.begin(); + // Sensors are walked in the sensor set's order (see SensorItemsComparator): register type, then + // force_new_range ahead of the rest, then address - so the walk is not purely address-ordered. + // Each keeps the address it was configured with; what is resolved here is its `offset`, the position + // of its data within the response of whichever range it ends up in. RegisterRange r = {}; - uint8_t buffer_offset = 0; + bool have_range = false; + // Set while the open range belongs to a force_new_range sensor: a range the user asked to keep + // separate must not quietly absorb other sensors. + bool range_forced = false; + // Set once a sensor has joined by sharing the range's start address, which widens the read. Only a + // widened range can absorb a later sensor by coverage: ranges that were kept apart before stay apart, + // so their frames and polling rates are untouched. + bool range_shared = false; + // Bytes the range's registers have consumed so far. An extending sensor starts after them, so a + // register that returns more bytes than its count implies pushes the sensors after it along. + // range_custom_size records whether any of them returns something other than two bytes per register, + // which is what makes a position inside the range impossible to work out from addresses alone. Coils + // count as such: they carry one bit per address, so bit ranges never take the coverage join. + size_t range_bytes = 0; + bool range_custom_size = false; SensorItem *prev = nullptr; - while (ix != this->sensorset_.end()) { - SensorItem *curr = *ix; + for (SensorItem *curr : this->sensorset_) { + ESP_LOGV(TAG, "Register: 0x%X count=%d size=%zu offset=%u skip=%u addr=%p", curr->start_address, + curr->register_count, curr->get_register_size(), curr->offset, curr->skip_updates, curr); - ESP_LOGV(TAG, "Register: 0x%X %d %d %zu offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, - curr->offset, curr->get_register_size(), curr->offset, curr->skip_updates, curr); + const bool custom_size = curr->get_register_size() != static_cast(curr->register_count) * 2; - if (r.register_count == 0) { - // this is the first register in range + bool join = false; + if (have_range && !curr->force_new_range && r.register_type == curr->register_type && + curr->register_type != modbus::EntityType::CUSTOM) { + if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && + prev->start_address + prev->register_count == r.start_address + r.register_count && + curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { + // A second sensor on the register(s) the previous one covers: it reads those same bytes, + // starting where that sensor's offset pointed, so a chain configured 0/2/4 resolves to 0/2/6. + // Both address tests matter. The first identifies the previous sensor's register by working back + // from the range's end, which only describes it while it actually sits there - hence the second. + // A sensor that joined mid-range must never anchor this, or the next one inherits its offset. + curr->offset = static_cast(prev->offset + curr->offset_from_start_address); + join = true; + ESP_LOGV(TAG, "Re-use previous register 0x%X", curr->start_address); + } else if (curr->start_address == (r.start_address + r.register_count)) { + // The next contiguous register(s): the data begins after what the range has consumed so far - + // the byte cursor for registers, the distance in bits for coils. + curr->offset = + static_cast((curr->addresses_bits() ? curr->start_address - r.start_address : range_bytes) + + curr->offset_from_start_address); + range_bytes += curr->get_register_size(); + range_custom_size = range_custom_size || custom_size; + r.register_count += curr->register_count; + join = true; + ESP_LOGV(TAG, "Extend range to include 0x%X", curr->start_address); + } else if (range_shared && !range_forced && curr->start_address >= r.start_address && + curr->start_address + curr->register_count <= r.start_address + r.register_count && + !range_custom_size && !custom_size && curr->skip_updates == r.skip_updates) { + // The registers already fall inside a range that a shared-address join widened, so this sensor + // reads its slice of that response instead of adding an overlapping second poll. The guards keep + // it narrow: only a widened range, never a force-isolated one; only where every register in the + // range returns two bytes, so interior positions follow from the addresses; only sensors genuinely + // inside it, which is why the lower bound is needed given the walk is not address-ordered; and + // only where the polling rates already match, since joining runs this sensor through the rate + // merge below and would otherwise change one of them. + const uint16_t addr_delta = curr->start_address - r.start_address; + curr->offset = static_cast((curr->addresses_bits() ? addr_delta : addr_delta * 2) + + curr->offset_from_start_address); + join = true; + ESP_LOGV(TAG, "Register 0x%X already covered by range 0x%X", curr->start_address, r.start_address); + } + } + + // Sensors on the same start address have to share one range: a response is dispatched to a single + // range per (start_address, register_type), so a second range with that key would never receive + // data. This holds for force_new_range and custom entities too. The read widens to cover whichever + // sensor needs the most registers, which also fixes a short read for coils that use offset. + if (!join && have_range && r.register_type == curr->register_type && r.start_address == curr->start_address) { + curr->offset = curr->offset_from_start_address; // shares the range start + r.register_count = std::max(r.register_count, curr->register_count); + range_bytes = std::max(range_bytes, curr->get_register_size()); + range_custom_size = range_custom_size || custom_size; + range_shared = true; + range_forced = range_forced || curr->force_new_range; + join = true; + ESP_LOGV(TAG, "Share range start 0x%X", curr->start_address); + } + + if (!join) { + if (have_range) { + ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); + this->create_polling_command_(std::move(r)); + } + r = {}; + range_bytes = curr->get_register_size(); + range_custom_size = custom_size; + range_forced = curr->force_new_range; + range_shared = false; + curr->offset = curr->offset_from_start_address; r.start_address = curr->start_address; r.register_count = curr->register_count; r.register_type = curr->register_type; - r.sensors.insert(curr); r.skip_updates = curr->skip_updates; - r.skip_updates_counter = 0; - buffer_offset = curr->get_register_size(); - - ESP_LOGV(TAG, "Started new range"); - } else { - // this is not the first register in range so it might be possible - // to reuse the last register or extend the current range - if (!curr->force_new_range && r.register_type == curr->register_type && - curr->register_type != ModbusRegisterType::CUSTOM) { - if (curr->start_address == (r.start_address + r.register_count - prev->register_count) && - curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) { - // this register can re-use the data from the previous register - - // remove this sensore because start_address is changed (sort-order) - ix = this->sensorset_.erase(ix); - - curr->start_address = r.start_address; - curr->offset += prev->offset; - - this->sensorset_.insert(curr); - // move iterator backwards because it will be incremented later - ix--; - - ESP_LOGV(TAG, "Re-use previous register - change to register: 0x%X %d offset=%u", curr->start_address, - curr->register_count, curr->offset); - } else if (curr->start_address == (r.start_address + r.register_count)) { - // this register can extend the current range - - // remove this sensore because start_address is changed (sort-order) - ix = this->sensorset_.erase(ix); - - curr->start_address = r.start_address; - curr->offset += buffer_offset; - buffer_offset += curr->get_register_size(); - r.register_count += curr->register_count; - - this->sensorset_.insert(curr); - // move iterator backwards because it will be incremented later - ix--; - - ESP_LOGV(TAG, "Extend range - change to register: 0x%X %d offset=%u", curr->start_address, - curr->register_count, curr->offset); - } - } - } - - if (curr->start_address == r.start_address && curr->register_type == r.register_type) { - // use the lowest non zero value for the whole range - // Because zero is the default value for skip_updates it is excluded from getting the min value. - if (curr->skip_updates != 0) { - if (r.skip_updates != 0) { - r.skip_updates = std::min(r.skip_updates, curr->skip_updates); - } else { - r.skip_updates = curr->skip_updates; - } - } - - // add sensor to this range - r.sensors.insert(curr); - - ix++; - } else { - ESP_LOGV(TAG, "Add range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(r); - r = {}; - buffer_offset = 0; - // do not increment the iterator here because the current sensor has to be re-evaluated + have_range = true; + } else if (curr->skip_updates != 0) { + // use the lowest non-zero skip_updates for the whole range (0 is the default and is excluded) + r.skip_updates = (r.skip_updates != 0) ? std::min(r.skip_updates, curr->skip_updates) : curr->skip_updates; } + // Every member records its range's first register. The resolved offset is relative to it, so the + // two together give the sensor's real position, and the address a write entity targets. + curr->range_start_address = r.start_address; + r.sensors.insert(curr); prev = curr; } - - if (r.register_count > 0) { - // Add the last range + if (have_range) { ESP_LOGV(TAG, "Add last range 0x%X %d skip:%d", r.start_address, r.register_count, r.skip_updates); - this->register_ranges_.push_back(r); + this->create_polling_command_(std::move(r)); } - - return this->register_ranges_.size(); + // Reclaim growth slack; safe here because nothing has registered with the hub yet (see the + // lifetime note on polling_command_items_). + this->polling_command_items_.shrink_to_fit(); } void ModbusController::dump_config() { @@ -321,221 +377,164 @@ void ModbusController::dump_config() { it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); - for (auto &it : this->register_ranges_) { - ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), - it.start_address, it.register_count, it.skip_updates); + for (auto &it : this->polling_command_items_) { + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type()), + it.register_address(), it.register_count(), it.skip_updates); } #endif } -void ModbusController::loop() { - // Incoming data to process? - if (!this->incoming_queue_.empty()) { - auto &message = this->incoming_queue_.front(); - if (message != nullptr) - this->process_modbus_data_(message.get()); - this->incoming_queue_.pop(); - +void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address, + std::span data) { + // A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span. + if (data.size() >= 3) { + ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data.data(), 0), + modbus::helpers::get_data(data.data(), 1)); } else { - // all messages processed send pending commands - this->send_next_command_(); - } -} - -void ModbusController::on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), - modbus::helpers::get_data(data, 1)); -} - -void ModbusController::dump_sensors_() { - ESP_LOGV(TAG, "sensors"); - for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, - it->get_register_size(), it->offset); + ESP_LOGV(TAG, "Command ACK (short payload, %zu bytes)", data.size()); } } ModbusCommandItem ModbusCommandItem::create_read_command( - ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(modbus::helpers::modbus_register_read_function(register_type), register_type, start_address, + register_count); cmd.on_data_func = std::move(handler); return cmd; } -ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice, - ModbusRegisterType register_type, uint16_t start_address, - uint16_t register_count) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = register_type; - cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_register_data(register_type, start_address, data); - }; - return cmd; -} - ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t register_count, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::HOLDING; - cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; - cmd.register_address = start_address; - cmd.register_count = register_count; - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_REGISTERS, EntityType::HOLDING, start_address, register_count); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { auto decoded_value = decode_value(v); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + *p++ = decoded_value[0]; + *p++ = decoded_value[1]; } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *modbusdevice, uint16_t address, bool value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::COIL; - cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_COIL; - cmd.register_address = address; - cmd.register_count = 1; - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_COIL, EntityType::COIL, address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - cmd.payload.push_back(value ? 0xFF : 0); - cmd.payload.push_back(0); + uint8_t *p = cmd.payload.init(2); + p[0] = value ? 0xFF : 0; + p[1] = 0; return cmd; } ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusController *modbusdevice, uint16_t start_address, const std::vector &values) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::COIL; - cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_COILS; - cmd.register_address = start_address; - cmd.register_count = values.size(); - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_MULTIPLE_COILS, EntityType::COIL, start_address, values.size()); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; - uint8_t bitmask = 0; - int bitcounter = 0; - for (auto coil : values) { - if (coil) { - bitmask |= (1 << bitcounter); - } - bitcounter++; - if (bitcounter % 8 == 0) { - cmd.payload.push_back(bitmask); - bitmask = 0; - } - } - // add remaining bits - if (bitcounter % 8) { - cmd.payload.push_back(bitmask); + // Pack through the shared bit view (MutablePackedBits) so the coil wire layout lives in one place + // instead of an open-coded loop. + const size_t byte_count = modbus::packed_bit_bytes(values.size()); + uint8_t *p = cmd.payload.init(byte_count); + memset(p, 0, byte_count); + modbus::MutablePackedBits bits(std::span(p, byte_count), static_cast(values.size())); + for (size_t i = 0; i != values.size(); i++) { + if (values[i]) + bits.set(i, true); } return cmd; } ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusController *modbusdevice, uint16_t start_address, uint16_t value) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.register_type = ModbusRegisterType::HOLDING; - cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_REGISTER; - cmd.register_address = start_address; - cmd.register_count = 1; // not used here anyways - cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { - modbusdevice->on_write_register_response(cmd.register_type, start_address, data); + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.set_command_(FunctionCode::WRITE_SINGLE_REGISTER, EntityType::HOLDING, start_address, 1); + cmd.on_data_func = [modbusdevice](EntityType register_type, uint16_t start_address, std::span data) { + modbusdevice->on_write_register_response(register_type, start_address, data); }; auto decoded_value = decode_value(value); - cmd.payload.push_back(decoded_value[0]); - cmd.payload.push_back(decoded_value[1]); + uint8_t *p = cmd.payload.init(2); + p[0] = decoded_value[0]; + p[1] = decoded_value[1]; return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd; - cmd.modbusdevice = modbusdevice; - cmd.function_code = ModbusFunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } - cmd.payload = values; + cmd.payload.set(values.data(), values.size()); return cmd; } ModbusCommandItem ModbusCommandItem::create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler) { - ModbusCommandItem cmd = {}; - cmd.modbusdevice = modbusdevice; - cmd.function_code = ModbusFunctionCode::CUSTOM; + std::function data)> &&handler) { + ModbusCommandItem cmd(*modbusdevice, modbusdevice->hub(), modbusdevice->device_address()); + cmd.function_code_ = FunctionCode::CUSTOM; if (handler == nullptr) { - cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { + cmd.on_data_func = [](EntityType register_type, uint16_t start_address, std::span data) { ESP_LOGI(TAG, "Custom Command sent"); }; } else { cmd.on_data_func = handler; } + uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { - cmd.payload.push_back((v >> 8) & 0xFF); - cmd.payload.push_back(v & 0xFF); + *p++ = (v >> 8) & 0xFF; + *p++ = v & 0xFF; } return cmd; } bool ModbusCommandItem::send() { - if (this->function_code != ModbusFunctionCode::CUSTOM) { - modbusdevice->send(uint8_t(this->function_code), this->register_address, this->register_count, this->payload.size(), - this->payload.empty() ? nullptr : &this->payload[0]); + bool accepted; + if (this->function_code_ != FunctionCode::CUSTOM) { + accepted = this->queue_pdu(modbus::helpers::create_client_pdu( + this->function_code_, this->start_address_, this->register_count_, + this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { - modbusdevice->send_raw(this->payload); + // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own + // address (which may differ from this controller's); the hub appends the CRC and routes the response + // back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted + // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (frame.empty()) { + ESP_LOGW(TAG, "Empty custom command frame, not sent"); + accepted = false; + } else { + accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + } } - this->send_count_++; - ESP_LOGV(TAG, "Command sent %d 0x%X %d send_count: %d", uint8_t(this->function_code), this->register_address, - this->register_count, this->send_count_); - return true; -} - -bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { - // for custom commands we have to check for identical payloads, since - // address/count/type fields will be set to zero - return this->function_code == ModbusFunctionCode::CUSTOM - ? this->payload == other.payload - : other.register_address == this->register_address && other.register_count == this->register_count && - other.register_type == this->register_type && other.function_code == this->function_code; + // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. + if (accepted) { + ESP_LOGV(TAG, "Command queued %d 0x%X %d", uint8_t(this->function_code_), this->start_address_, + this->register_count_); + } + return accepted; } } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 484b59ede3..fb0037a0e6 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -7,8 +7,8 @@ #include "esphome/core/automation.h" #include -#include #include +#include #include #include @@ -16,22 +16,30 @@ namespace esphome::modbus_controller { class ModbusController; +using modbus::EntityType; +using modbus::ExceptionCode; +using modbus::FunctionCode; +using modbus::helpers::SensorValueType; + +// Remove before 2027.2.0 - deprecated names re-exported so external components keep their warning window +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +using modbus::ModbusExceptionCode; using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; -using modbus::ModbusExceptionCode; -using modbus::helpers::SensorValueType; +#pragma GCC diagnostic pop // Remove before 2026.10.0 — these helpers have moved to modbus::helpers ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { +inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { return modbus::helpers::modbus_register_read_function(reg_type); } ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { return modbus::helpers::modbus_register_write_function(reg_type); } @@ -64,12 +72,32 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } +// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// std::span (previously a const std::vector &), and a span does not convert to +// a vector, so existing lambdas calling these by name need an overload that accepts one. These carry +// this release's deprecation window, since the span forms only exist from it. +// payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers +// type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here +// would only make that call ambiguous. +// Remove before 2027.2.0. +template +ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2027.2.0", "2026.8.0") +T get_data(std::span data, size_t buffer_offset) { + return modbus::helpers::get_data(data.data(), buffer_offset); +} + // Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { return modbus::helpers::bit_from_packed(coil, data); } +// Remove before 2027.2.0 +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { + return modbus::helpers::bit_from_packed(coil, data); +} + template ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") N mask_and_shift_by_rightbit(N data, uint32_t mask) { @@ -90,18 +118,48 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") inline std::vector float_to_payload(float value, SensorValueType value_type) { - return modbus::helpers::float_to_payload(value, value_type); + std::vector data; + modbus::helpers::float_to_payload(data, value, value_type); + return data; } class ModbusController; class SensorItem { public: - virtual void parse_and_publish(const std::vector &data) = 0; + /// Parse this sensor's slice out of its range's response and publish it. The span points into the + /// response buffer and is only valid for the duration of the call. Read the sensor's data from + /// `offset` within it. + virtual void parse_and_publish(std::span data) = 0; + + /// Coils and discrete inputs address individual bits; every other type addresses 16-bit registers. + bool addresses_bits() const { return modbus::helpers::is_entity_type_binary(this->register_type); } + + /// Address a write entity (switch/number/select) targets, derived from its resolved position within + /// the range so that a write lands on the register the sensor reads from. + uint16_t write_address() const { + return this->range_start_address + (this->addresses_bits() ? this->offset : this->offset / 2); + } + + /// Records the offset as configured, and seeds the resolved position with it. Building the ranges + /// overwrites `offset` with the position within the range; an item that is never polled keeps this + /// value, which is what its own address arithmetic expects. + void set_offset_from_start_address(uint8_t offset) { + this->offset_from_start_address = offset; + this->offset = offset; + } + + /// Sets the configured address, and points the range base at it. Building the ranges moves the base + /// to the range's first register; an item that is never polled (an output, or a switch with + /// assumed_state) keeps its own address, so write_address() stays correct for it. + void set_address(uint16_t address) { + this->start_address = address; + this->range_start_address = address; + } void set_custom_data(const std::vector &data) { custom_data = data; } size_t virtual get_register_size() const { - if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) { + if (this->addresses_bits()) { return 1; } else { // if CONF_RESPONSE_BYTES is used override the default return response_bytes > 0 ? response_bytes : register_count * 2; @@ -109,19 +167,31 @@ class SensorItem { } // Override register size for modbus devices not using 1 register for one dword void set_register_size(uint8_t register_size) { response_bytes = register_size; } - ModbusRegisterType register_type{ModbusRegisterType::CUSTOM}; + modbus::EntityType register_type{modbus::EntityType::CUSTOM}; SensorValueType sensor_value_type{SensorValueType::RAW}; uint16_t start_address{0}; uint32_t bitmask{0}; + /// Position of this sensor's data within its range's response - a byte offset for registers, a bit + /// index for coils and discrete inputs. Resolved while the ranges are built, so it already accounts + /// for the registers ahead of it (including wide response_size ones) and for any offset inherited + /// from an earlier sensor sharing the same register. uint8_t offset{0}; uint8_t register_count{0}; uint8_t response_bytes{0}; + /// The offset exactly as configured: measured from this sensor's own start_address, where `offset` + /// is measured from the first register of the range it ends up polled in. Same units as `offset` - + /// bytes for registers, bits for coils and discrete inputs. Kept so the resolution can be recomputed, + /// and so the sort order of the sensor set never depends on the resolved value. + /// Declared before range_start_address so it lands in the padding after response_bytes. + uint8_t offset_from_start_address{0}; + /// First register of the range this sensor is polled in; equals start_address for an unpolled item. + uint16_t range_start_address{0}; uint16_t skip_updates{0}; std::vector custom_data{}; bool force_new_range{false}; }; -// ModbusController::create_register_ranges_ tries to optimize register range +// ModbusController::create_polling_commands_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { public: @@ -141,9 +211,11 @@ class SensorItemsComparator { return lhs->start_address < rhs->start_address; } - // sort by offset (ensures update of sensors in ascending order) - if (lhs->offset != rhs->offset) { - return lhs->offset < rhs->offset; + // sort by the offset as configured (ensures update of sensors in ascending order). The resolved + // `offset` is deliberately not used: ranges are built while iterating this set and assign it, and + // a sort key that changed under the iteration would corrupt the set's ordering. + if (lhs->offset_from_start_address != rhs->offset_from_start_address) { + return lhs->offset_from_start_address < rhs->offset_from_start_address; } // The pointer to the sensor is used last to ensure that @@ -156,27 +228,66 @@ using SensorSet = std::set; struct RegisterRange { uint16_t start_address; - ModbusRegisterType register_type; + modbus::EntityType register_type; uint8_t register_count; - uint16_t skip_updates; // the config value - SensorSet sensors; // all sensors of this range - uint16_t skip_updates_counter; // the running value + uint16_t skip_updates; // the config value + SensorSet sensors; // all sensors of this range }; -class ModbusCommandItem { +/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub +/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no +/// longer has to match responses to a FIFO queue. +class ModbusCommandItem : public modbus::ModbusClientDevice { public: - static const size_t MAX_PAYLOAD_BYTES = 240; - ModbusController *modbusdevice{nullptr}; - uint16_t register_address{0}; - uint16_t register_count{0}; - ModbusFunctionCode function_code{ModbusFunctionCode::CUSTOM}; - ModbusRegisterType register_type{ModbusRegisterType::CUSTOM}; - std::function &data)> - on_data_func; - std::vector payload = {}; + /// Empty command with no controller connection (kept for source compatibility with value-type usage). + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address) + : modbus::ModbusClientDevice(parent, address), controller_(&controller) {} + /// Read command built from a range; the read PDU is rebuilt from these fields at send time. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, + RegisterRange &&range); + /// Custom polling command: the PDU bytes are referenced from the sensor (not copied); responses are + /// dispatched to that sensor. + ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, SensorItem *sensor); + + // The base deletes copy/move (its destructor unregisters the device from the hub queue), but command + // items are stored in value containers, so copy/move CONSTRUCTION is re-provided (copy only for the + // queue_command() path). Assignment stays deleted: the item's address-in-memory is its hub identity. + ModbusCommandItem(const ModbusCommandItem &other); + ModbusCommandItem(ModbusCommandItem &&other) noexcept; + ModbusCommandItem &operator=(ModbusCommandItem &&) = delete; + + SensorSet sensors; // sensors served by this command (empty for factory/write commands) + uint16_t skip_updates{0}; + std::function data)> on_data_func; + /// Write data bytes for the command (register/coil values), or the raw frame of a one-shot custom + /// command; reads leave it empty. Small-buffer optimized: fixed-size commands (single-register/coil + /// writes) fit in the 8-byte inline buffer with no heap; only large multi-register or custom frames + /// spill to a single one-time heap allocation. This keeps runtime one-shot writes off the heap without + /// reserving a max-size buffer per command item. + SmallInlineBuffer<8> payload; + // Set by unqueue_command() when this one-shot has completed. The controller erases flagged items at a + // safe point (update()/queue_command()), never from inside the command's own callback. + bool pending_removal{false}; + + /// called when a modbus response was parsed without errors + void on_response(std::span request_pdu, std::span response_pdu) override; + /// called when a modbus error (exception) response was received + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + /// called when the command could not be sent + void on_not_sent(std::span request_pdu) override; + /// called when the command's frame is actually written to the wire; fires the on_command_sent trigger + void on_sent(std::span request_pdu) override; + /// called on timeout; returns true to have the hub re-queue the frame for a retry + bool on_no_response(std::span request_pdu) override; + + uint16_t register_address() const { return this->start_address_; } + uint16_t register_count() const { return this->register_count_; } + EntityType register_type() const { return this->register_type_; } + + /// Queue this command's frame on the hub. Returns false when refused, in which case no callback ever comes. + /// The item is the hub device, so it must stay alive until its terminal callback; a destroyed item's + /// pending frame is silently retired. bool send(); - /// Check if the command should be retried based on the max_retries parameter - bool should_retry(uint8_t max_retries) { return this->send_count_ <= max_retries; }; /// factory methods /** Create modbus read command @@ -189,19 +300,8 @@ class ModbusCommandItem { * @return ModbusCommandItem with the prepared command */ static ModbusCommandItem create_read_command( - ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count, - std::function &data)> - &&handler); - /** Create modbus read command - * Function code 02-04 - * @param modbusdevice pointer to the device to execute the command - * @param function_code modbus function code for the read command - * @param start_address modbus address of the first register to read - * @param register_count number of registers to read - * @return ModbusCommandItem with the prepared command - */ - static ModbusCommandItem create_read_command(ModbusController *modbusdevice, ModbusRegisterType register_type, - uint16_t start_address, uint16_t register_count); + ModbusController *modbusdevice, EntityType register_type, uint16_t start_address, uint16_t register_count, + std::function data)> &&handler); /** Create modbus read command * Function code 02-04 * @param modbusdevice pointer to the device to execute the command @@ -250,8 +350,8 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); + std::function data)> &&handler = + nullptr); /** Create custom modbus command * @param modbusdevice pointer to the device to execute the command @@ -262,17 +362,33 @@ class ModbusCommandItem { */ static ModbusCommandItem create_custom_command( ModbusController *modbusdevice, const std::vector &values, - std::function &data)> - &&handler = nullptr); - - bool is_equal(const ModbusCommandItem &other); + std::function data)> &&handler = + nullptr); protected: - // wrong commands (esp. custom commands) can block the send queue, limit the number of repeats. - /// How many times this command has been sent - uint8_t send_count_{0}; + void set_command_(FunctionCode function_code, EntityType register_type, uint16_t start_address, + uint16_t register_count) { + this->function_code_ = function_code; + this->register_type_ = register_type; + this->start_address_ = start_address; + this->register_count_ = register_count; + } + EntityType register_type_{EntityType::CUSTOM}; + uint16_t start_address_{0}; + uint16_t register_count_{0}; + FunctionCode function_code_{FunctionCode::CUSTOM}; + /// Custom polling commands reference the PDU bytes owned by their SensorItem instead of copying them. + const std::vector *custom_data_{nullptr}; + ModbusController *controller_{nullptr}; }; +/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles, +/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter +/// wraparound) can be unit tested; used by ModbusController::update(). +inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_at, uint16_t offline_skip_updates) { + return static_cast(update_counter + 1 - module_offline_at) % (offline_skip_updates + 1) == 0; +} + /** Modbus controller class. * Each instance handles the modbus commuinication for all sensors with the same modbus address * @@ -281,39 +397,46 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent { public: void dump_config() override; - void loop() override; + // No loop() override: the hub owns transmit/receive timing and each command routes its own + // response, so the controller never joins the looping components at all. void setup() override; void update() override; - /// queues a modbus command in the send queue - void queue_command(const ModbusCommandItem &command); + // The controller is not itself a modbus device - its commands and writer entities send as their own + // devices. It only owns the hub + address so those senders can be built against them. + void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; } + void set_address(uint8_t address) { this->address_ = address; } + + /// The hub and modbus address this controller talks to. Used to build commands/entities that send as + /// their own device. + modbus::ModbusClientHub *hub() const { return this->hub_; } + uint8_t device_address() const { return this->address_; } + + /// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy. + void queue_command(ModbusCommandItem command); + /// Flags a finished one-shot command for removal. Called by the command as the last action of its own + /// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later. + void unqueue_command(const ModbusCommandItem *command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// called when a modbus response was parsed without errors - void on_modbus_data(const std::vector &data) override; - /// called when a modbus error response was received - void on_modbus_error(uint8_t function_code, uint8_t exception_code) override; - /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue - void on_register_data(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data); - /// default delegate called by process_modbus_data when a response for a write response has retrieved from the - /// incoming queue - void on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data); - /// Allow a duplicate command to be sent - void set_allow_duplicate_commands(bool allow_duplicate_commands) { - this->allow_duplicate_commands_ = allow_duplicate_commands; + /// Handles a write command acknowledgement (used by write command on_data_func handlers). + void on_write_register_response(EntityType register_type, uint16_t start_address, std::span data); + /// Update the online/offline state after a response or a run of timeouts, firing the callbacks. + void set_online(bool online, int function_code, int register_address); + /// Fire the on_command_sent trigger (called when a command's frame reaches the wire). + void command_sent(int function_code, int register_address) { + this->command_sent_callback_.call(function_code, register_address); } - /// get if a duplicate command can be sent - bool get_allow_duplicate_commands() { return this->allow_duplicate_commands_; } - /// called by esphome generated code to set the command_throttle period - void set_command_throttle(uint16_t command_throttle) { this->command_throttle_ = command_throttle; } + /// A command timed out; bump the consecutive-timeout counter used by can_send()/offline detection. + void increment_non_response_count() { this->cmd_non_responses_++; } + /// Whether more retries are allowed before the device is considered offline. Deliberately pooled + /// per device, not per command: online/offline is a property of the physical device. + bool can_send() { return this->cmd_non_responses_ <= this->max_cmd_retries_; } /// called by esphome generated code to set the offline_skip_updates void set_offline_skip_updates(uint16_t offline_skip_updates) { this->offline_skip_updates_ = offline_skip_updates; } - /// get the number of queued modbus commands (should be mostly empty) - size_t get_command_queue_length() { return command_queue_.size(); } /// get if the module is offline, didn't respond the last command bool get_module_offline() { return module_offline_; } /// Set callback for commands @@ -335,33 +458,48 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli protected: /// parse sensormap_ and create range of sequential addresses - size_t create_register_ranges_(); - // find register in sensormap. Returns iterator with all registers having the same start address - SensorSet find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const; - /// submit the read command for the address range to the send queue - void update_range_(RegisterRange &r); - /// parse incoming modbus data - void process_modbus_data_(const ModbusCommandItem *response); - /// send the next modbus command from the send queue - bool send_next_command_(); - /// dump the parsed sensormap for diagnostics - void dump_sensors_(); + /// Group the registered sensors into contiguous ranges and create one polling command per range. + void create_polling_commands_(); + /// build one persistent polling command from a range and add it to polling_command_items_ + void create_polling_command_(RegisterRange &&range) { + // A custom range polls the first sensor's custom_data (a ready-made raw frame); it needs the + // sensor constructor so the command references those bytes and decodes the real function code. + // The response still dispatches to every sensor in the range. + if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) { + auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin()); + cmd.sensors = std::move(range.sensors); + cmd.skip_updates = range.skip_updates; // the range's merged rate, not the first sensor's + } else { + this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range)); + } + } + /// send a range's polling command if it is due this update + void update_range_(ModbusCommandItem &cmd); + /// The hub this controller's commands/entities send through, and the modbus address they target. + modbus::ModbusClientHub *hub_{nullptr}; + uint8_t address_{0}; /// Collection of all sensors for this component SensorSet sensorset_; - /// Continuous range of modbus registers - std::vector register_ranges_{}; - /// Hold the pending requests to be sent - std::list> command_queue_; - /// modbus response data waiting to get processed - std::queue> incoming_queue_; - /// if duplicate commands can be sent - bool allow_duplicate_commands_{false}; - /// when was the last send operation - uint32_t last_command_timestamp_{0}; - /// min time in ms between sending modbus commands - uint16_t command_throttle_{0}; + /// One persistent command per register range, each its own ModbusClientDevice. Built once in setup() + /// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which + /// is safe because no command has registered with the hub yet) and never appended to afterward, so the + /// hub's device pointers stay valid once commands start sending. + std::vector polling_command_items_{}; + /// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses. + std::list> one_shot_command_items_; + /// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub + /// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the + /// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the + /// item running its callback is not flagged until that callback returns. + void sweep_completed_one_shots_(); /// if module didn't respond the last command bool module_offline_{false}; + /// update_counter_ value at which the module went offline (for offline_skip_updates timing) + uint16_t module_offline_at_{0}; + /// counts update() cycles; drives skip_updates and offline timing + uint16_t update_counter_{0}; + /// consecutive non-responses; drives can_send() and offline detection + uint8_t cmd_non_responses_{0}; /// how many updates to skip if module is offline uint16_t offline_skip_updates_{0}; /// How many times we will retry a command if we get no response @@ -379,9 +517,8 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(std::span data, const SensorItem &item) { - int64_t number = - modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); +inline float payload_to_float(std::span data, const SensorItem &item, uint8_t offset) { + int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { @@ -393,4 +530,12 @@ inline float payload_to_float(std::span data, const SensorItem &i return float_value; } +// Remove before 2027.2.0 (window opened when this helper gained an explicit offset). item.offset is +// the item's resolved position within its range's response, so this decodes the same bytes as passing +// that offset explicitly. +ESPDEPRECATED("Pass the offset explicitly: payload_to_float(data, item, item.offset). Removed in 2027.2.0", "2026.8.0") +inline float payload_to_float(std::span data, const SensorItem &item) { + return payload_to_float(data, item, item.offset); +} + } // namespace esphome::modbus_controller diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 2c81dd6830..7903b2e317 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -10,8 +10,8 @@ static const char *const TAG = "modbus.number"; // Maximum uint16_t registers to log in verbose hex output static constexpr size_t MODBUS_NUMBER_MAX_LOG_REGISTERS = 32; -void ModbusNumber::parse_and_publish(const std::vector &data) { - float result = payload_to_float(data, *this) / this->multiply_by_; +void ModbusNumber::parse_and_publish(std::span data) { + float result = payload_to_float(data, *this, this->offset) / this->multiply_by_; // Is there a lambda registered // call it with the pre converted value and the raw data array @@ -29,7 +29,7 @@ void ModbusNumber::parse_and_publish(const std::vector &data) { } void ModbusNumber::control(float value) { - ModbusCommandItem write_cmd; + optional write_cmd; std::vector data; float write_value = value; // Is there are lambda configured? @@ -55,13 +55,14 @@ void ModbusNumber::control(float value) { #endif ESP_LOGV(TAG, "Modbus Number write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - write_cmd = ModbusCommandItem::create_custom_command( + write_cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, write_cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { - data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type); + std::vector payload; + modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type); ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", @@ -69,22 +70,21 @@ void ModbusNumber::control(float value) { // Create and send the write command if (this->register_count == 1 && !this->use_write_multiple_) { - // since offset is in bytes and a register is 16 bits we get the start by adding offset/2 - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2, - data[0]); + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0])); } else { - write_cmd = ModbusCommandItem::create_write_multiple_command( - this->parent_, this->start_address + this->offset / 2, this->register_count, data); + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), + this->register_count, payload)); } // publish new value - write_cmd.on_data_func = [this, write_cmd, value](ModbusRegisterType register_type, uint16_t start_address, - const std::vector &data) { + write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address, + std::span data) { // gets called when the write command is ack'd from the device - this->parent_->on_write_register_response(write_cmd.register_type, start_address, data); + this->parent_->on_write_register_response(register_type, start_address, data); this->publish_state(value); }; } - this->parent_->queue_command(write_cmd); + this->parent_->queue_command(std::move(*write_cmd)); this->publish_state(value); } void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); } diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index ce64099170..1f0d0581eb 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -12,11 +12,11 @@ using value_to_data_t = std::function(float); class ModbusNumber final : public number::Number, public Component, public SensorItem { public: - ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = value_type; this->register_count = register_count; @@ -25,12 +25,12 @@ class ModbusNumber final : public number::Number, public Component, public Senso }; void dump_config() override; - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; float get_setup_priority() const override { return setup_priority::HARDWARE; } void set_parent(ModbusController *parent) { this->parent_ = parent; } void set_write_multiply(float factor) { this->multiply_by_ = factor; } - using transform_func_t = optional (*)(ModbusNumber *, float, const std::vector &); + using transform_func_t = optional (*)(ModbusNumber *, float, std::span); using write_transform_func_t = optional (*)(ModbusNumber *, float, std::vector &); void set_template(transform_func_t f) { this->transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 504e09a093..48249f4387 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -33,22 +33,40 @@ void ModbusFloatOutput::write_state(float value) { } // lambda didn't set payload if (data.empty()) { - data = modbus::helpers::float_to_payload(value, this->sensor_value_type); + modbus::helpers::float_to_payload(data, value, this->sensor_value_type); } ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)", this->start_address, this->register_count, value, original_value); - // Create and send the write command - ModbusCommandItem write_cmd; - if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd = - ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]); - } else { - write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset, - this->register_count, data); + // The command declares register_count registers, so the payload must be exactly that many words; + // anything else would put a byte count on the wire that disagrees with the quantity field. + // number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0]. + if (data.empty()) { + ESP_LOGW(TAG, "No payload was created for updating output"); + return; } - this->parent_->queue_command(write_cmd); + + // register_count declares the READ range width - it may pull neighboring registers into one poll - + // so a write covers exactly the registers the value occupies: the quantity comes from the payload, + // never from register_count (padding to it would zero registers the user only declared for reading). + // A payload wider than the declared range means the config and the lambda disagree - drop it. + if (data.size() > this->register_count) { + ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), + this->register_count); + return; + } + + // Create and send the write command + optional write_cmd; + if (this->register_count == 1 && !this->use_write_multiple_) { + write_cmd.emplace( + ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0])); + } else { + write_cmd.emplace(ModbusCommandItem::create_write_multiple_command( + this->parent_, this->start_address + this->offset, data.size(), data)); + } + this->parent_->queue_command(std::move(*write_cmd)); } void ModbusFloatOutput::dump_config() { @@ -64,7 +82,7 @@ void ModbusFloatOutput::dump_config() { // ModbusBinaryOutput void ModbusBinaryOutput::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? @@ -87,11 +105,11 @@ void ModbusBinaryOutput::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus binary output write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state), (int) this->register_type, this->start_address, this->offset); @@ -99,12 +117,14 @@ void ModbusBinaryOutput::write_state(bool state) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state); + cmd.emplace( + ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state)); } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); } void ModbusBinaryOutput::dump_config() { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index d904e58bd7..17eb8e3a8f 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -11,22 +11,22 @@ namespace esphome::modbus_controller { class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { - this->register_type = ModbusRegisterType::HOLDING; - this->start_address = start_address; - this->offset = offset; + this->register_type = modbus::EntityType::HOLDING; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = 0xFFFFFFFF; this->register_count = register_count; this->sensor_value_type = value_type; this->skip_updates = 0; - this->start_address += offset; - this->offset = 0; + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } void dump_config() override; void set_parent(ModbusController *parent) { this->parent_ = parent; } void set_write_multiply(float factor) { this->multiply_by_ = factor; } // Do nothing - void parse_and_publish(const std::vector &data) override{}; + void parse_and_publish(std::span data) override{}; using write_transform_func_t = optional (*)(ModbusFloatOutput *, float, std::vector &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } @@ -44,20 +44,20 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { - this->register_type = ModbusRegisterType::COIL; - this->start_address = start_address; + this->register_type = modbus::EntityType::COIL; + this->set_address(start_address); this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = 0; this->register_count = 1; - this->start_address += offset; - this->offset = 0; + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } void dump_config() override; void set_parent(ModbusController *parent) { this->parent_ = parent; } // Do nothing - void parse_and_publish(const std::vector &data) override{}; + void parse_and_publish(std::span data) override{}; using write_transform_func_t = optional (*)(ModbusBinaryOutput *, bool, std::vector &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index 334a4dfd76..5127360770 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -115,10 +115,7 @@ async def to_code(config): [ (ModbusSelect.operator("const_ptr"), "item"), (cg.int64, "x"), - ( - cg.std_vector.template(cg.uint8).operator("const").operator("ref"), - "data", - ), + (cg.std_span.template(cg.uint8.operator("const")), "data"), ], return_type=cg.optional.template(cg.std_string), ) diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index c650ca7641..0a9383b1b0 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -7,10 +7,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } -void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, - this->offset, this->bitmask) - .value_or(0); +void ModbusSelect::parse_and_publish(std::span data) { + int64_t value = + modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask).value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); @@ -72,16 +71,30 @@ void ModbusSelect::control(size_t index) { return; } - const uint16_t write_address = this->start_address + this->offset / 2; - ModbusCommandItem write_cmd; - if ((this->register_count == 1) && (!this->use_write_multiple_)) { - write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]); - } else { - write_cmd = - ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, this->register_count, data); + // The command declares register_count registers, so the payload must be exactly that many words: + // a value type narrower than the declared width is zero-padded (the config deliberately allows + // register_count larger than the value type). Anything else would put a byte count on the wire + // that disagrees with the quantity field, which conformant devices reject. + // register_count declares the READ range width - it may pull neighboring registers into one poll - + // so a write covers exactly the registers the value occupies: the quantity comes from the payload, + // never from register_count (padding to it would zero registers the user only declared for reading). + // A payload wider than the declared range means the config and the lambda disagree - drop it. + if (data.size() > this->register_count) { + ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(), + this->register_count); + return; } - this->parent_->queue_command(write_cmd); + const uint16_t write_address = this->write_address(); + optional write_cmd; + if ((this->register_count == 1) && (!this->use_write_multiple_)) { + write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0])); + } else { + write_cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data)); + } + + this->parent_->queue_command(std::move(*write_cmd)); if (this->optimistic_) this->publish_state(index); diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index fb9283305c..e1ae578ddf 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -13,11 +13,11 @@ class ModbusSelect final : public Component, public select::Select, public Senso public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { - this->register_type = ModbusRegisterType::HOLDING; // not configurable + this->register_type = modbus::EntityType::HOLDING; // not configurable this->sensor_value_type = sensor_value_type; - this->start_address = start_address; - this->offset = 0; // not configurable - this->bitmask = 0xFFFFFFFF; // not configurable + this->set_address(start_address); + this->set_offset_from_start_address(0); // not configurable + this->bitmask = 0xFFFFFFFF; // not configurable this->register_count = register_count; this->response_bytes = 0; // not configurable this->skip_updates = skip_updates; @@ -25,7 +25,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso this->mapping_ = std::move(mapping); } - using transform_func_t = optional (*)(ModbusSelect *const, int64_t, const std::vector &); + using transform_func_t = optional (*)(ModbusSelect *const, int64_t, std::span); using write_transform_func_t = optional (*)(ModbusSelect *const, const std::string &, int64_t, std::vector &); @@ -36,7 +36,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void dump_config() override; - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void control(size_t index) override; protected: diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index 559724057a..b2bc2b5fd0 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -8,8 +8,8 @@ static const char *const TAG = "modbus_controller.sensor"; void ModbusSensor::dump_config() { LOG_SENSOR(TAG, "Modbus Controller Sensor", this); } -void ModbusSensor::parse_and_publish(const std::vector &data) { - float result = payload_to_float(data, *this); +void ModbusSensor::parse_and_publish(std::span data) { + float result = payload_to_float(data, *this, this->offset); // Is there a lambda registered // call it with the pre converted value and the raw data array diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index ea4f560b9c..9d66b2afa7 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -4,17 +4,17 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: - ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = value_type; this->register_count = register_count; @@ -22,9 +22,9 @@ class ModbusSensor final : public Component, public sensor::Sensor, public Senso this->force_new_range = force_new_range; } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void dump_config() override; - using transform_func_t = optional (*)(ModbusSensor *, float, const std::vector &); + using transform_func_t = optional (*)(ModbusSensor *, float, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index c8b3868bdc..810d904d85 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -27,16 +27,17 @@ void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ bool ModbusSwitch::assumed_state() { return this->assumed_state_; } -void ModbusSwitch::parse_and_publish(const std::vector &data) { +void ModbusSwitch::parse_and_publish(std::span data) { bool value = false; + // For coils/discrete inputs this is the bit index; for registers it is the byte offset. + const size_t offset = this->offset; switch (this->register_type) { - case ModbusRegisterType::DISCRETE_INPUT: - case ModbusRegisterType::COIL: - // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::bit_from_packed(this->offset, data); + case modbus::EntityType::DISCRETE_INPUT: + case modbus::EntityType::COIL: + value = modbus::helpers::bit_from_packed(offset, data); break; default: - value = modbus::helpers::get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data.data(), offset) & this->bitmask; break; } @@ -51,14 +52,14 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { } } - ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), - ONOFF(value), (int) this->register_type, this->start_address, this->offset); + ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %zx", this->get_name().c_str(), + ONOFF(value), (int) this->register_type, this->start_address, offset); this->publish_state(value); } void ModbusSwitch::write_state(bool state) { // This will be called every time the user requests a state change. - ModbusCommandItem cmd; + optional cmd; std::vector data; // Is there are lambda configured? if (this->write_transform_func_.has_value()) { @@ -80,35 +81,34 @@ void ModbusSwitch::write_state(bool state) { #endif ESP_LOGV(TAG, "Modbus Switch write raw: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd = ModbusCommandItem::create_custom_command( + cmd.emplace(ModbusCommandItem::create_custom_command( this->parent_, data, - [this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - this->parent_->on_write_register_response(cmd.register_type, this->start_address, data); - }); + [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { + this->parent_->on_write_register_response(register_type, this->start_address, data); + })); } else { ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), ONOFF(state), (int) this->register_type, this->start_address, this->offset); - if (this->register_type == ModbusRegisterType::COIL) { + if (this->register_type == modbus::EntityType::COIL) { // offset for coil and discrete inputs is the coil/register number not bytes if (this->use_write_multiple_) { std::vector states{state}; - cmd = ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states); + cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states)); } else { - cmd = ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state); + cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state)); } } else { - // since offset is in bytes and a register is 16 bits we get the start by adding offset/2 if (this->use_write_multiple_) { std::vector bool_states(1, state ? (0xFFFF & this->bitmask) : 0); - cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset / 2, 1, - bool_states); + cmd.emplace( + ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states)); } else { - cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2, - state ? 0xFFFF & this->bitmask : 0u); + cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), + state ? 0xFFFF & this->bitmask : 0u)); } } } - this->parent_->queue_command(cmd); + this->parent_->queue_command(std::move(*cmd)); this->publish_state(state); } // ModbusSwitch end diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index d6e991582d..e5b8cf5c21 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -10,18 +10,18 @@ namespace esphome::modbus_controller { class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: - ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, + ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->bitmask = bitmask; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = skip_updates; this->register_count = 1; - if (register_type == ModbusRegisterType::HOLDING || register_type == ModbusRegisterType::COIL) { - this->start_address += offset; - this->offset = 0; + if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) { + this->set_address(this->start_address + offset); + this->set_offset_from_start_address(0); } this->force_new_range = force_new_range; }; @@ -30,10 +30,10 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void dump_config() override; void set_assumed_state(bool assumed_state); void set_state(bool state) { this->state = state; } - void parse_and_publish(const std::vector &data) override; + void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->parent_ = parent; } - using transform_func_t = optional (*)(ModbusSwitch *, bool, const std::vector &); + using transform_func_t = optional (*)(ModbusSwitch *, bool, std::span); using write_transform_func_t = optional (*)(ModbusSwitch *, bool, std::vector &); void set_template(transform_func_t f) { this->publish_transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index 5626515638..31b3fb3e55 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -8,10 +8,11 @@ static const char *const TAG = "modbus_controller.text_sensor"; void ModbusTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Modbus Controller Text Sensor", this); } -void ModbusTextSensor::parse_and_publish(const std::vector &data) { +void ModbusTextSensor::parse_and_publish(std::span data) { std::string output_str{}; uint8_t items_left = this->response_bytes; - uint8_t index = this->offset; + const size_t start_offset = this->offset; + size_t index = start_offset; while ((items_left > 0) && index < data.size()) { uint8_t b = data[index]; switch (this->encode_) { @@ -25,7 +26,7 @@ void ModbusTextSensor::parse_and_publish(const std::vector &data) { case RawEncoding::COMMA: { // max 5: optional ','(1) + uint8(3) + null, for both ",%d" and "%d" char dec_buf[5]; - snprintf(dec_buf, sizeof(dec_buf), index != this->offset ? ",%d" : "%d", b); + snprintf(dec_buf, sizeof(dec_buf), index != start_offset ? ",%d" : "%d", b); output_str += dec_buf; break; } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index e9130c98d4..5bb16eb58a 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -4,7 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/core/component.h" -#include +#include namespace esphome::modbus_controller { @@ -12,11 +12,11 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: - ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, + ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { this->register_type = register_type; - this->start_address = start_address; - this->offset = offset; + this->set_address(start_address); + this->set_offset_from_start_address(offset); this->response_bytes = response_bytes; this->register_count = register_count; this->encode_ = encode; @@ -28,8 +28,8 @@ class ModbusTextSensor final : public Component, public text_sensor::TextSensor, void dump_config() override; - void parse_and_publish(const std::vector &data) override; - using transform_func_t = optional (*)(ModbusTextSensor *, std::string, const std::vector &); + void parse_and_publish(std::span data) override; + using transform_func_t = optional (*)(ModbusTextSensor *, std::string, std::span); void set_template(transform_func_t f) { this->transform_func_ = f; } protected: diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 14f4ca8a4d..16b956d7b5 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType from .const import ( CONF_ALLOW_PARTIAL_READ, + CONF_BITS, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_( ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_server_ns.struct("ServerRegister") +ServerBit = modbus_server_ns.class_("ServerBit") SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( { @@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema( ) +ModbusServerBitSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerBit), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType: + # Coils and discrete inputs share one bit address space (like holding/input registers share the + # register table), so each bit address may appear only once. + seen: set[int] = set() + for bit in config.get(CONF_BITS, []): + address = bit[CONF_ADDRESS] + if address in seen: + raise cv.Invalid( + f"Bit address 0x{address:04X} is configured more than once; coils and discrete " + "inputs share one bit address space, so each address must be unique", + path=[CONF_BITS], + ) + seen.add(address) + return config + + def _validate_register_ranges(config: ConfigType) -> ConfigType: # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit # Modbus address space (0x0000-0xFFFF). @@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), + cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), _validate_register_ranges, _validate_no_overlapping_registers, + _validate_unique_bit_addresses, ) @@ -152,7 +182,7 @@ async def to_code(config): await cg.process_lambda( server_register[CONF_READ_LAMBDA], [(cg.uint16, "address")], - return_type=cpp_type, + return_type=cg.optional.template(cpp_type), ), ) ) @@ -170,5 +200,27 @@ async def to_code(config): if server_register[CONF_ALLOW_PARTIAL_READ]: cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) + for server_bit in config.get(CONF_BITS, []): + server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS]) + cg.add( + server_bit_var.set_read_lambda( + await cg.process_lambda( + server_bit[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cg.optional.template(cg.bool_), + ) + ) + ) + if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None: + cg.add( + server_bit_var.set_write_lambda( + await cg.process_lambda( + write_lambda, + parameters=[(cg.uint16, "address"), (cg.bool_, "x")], + return_type=cg.bool_, + ) + ) + ) + cg.add(var.add_server_bit(server_bit_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f2a8c53f45..86366c7ce0 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_BITS = "bits" CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 4c4e72a086..feb0e67725 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,7 +3,7 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusExceptionCode; +using modbus::ExceptionCode; using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; @@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); + // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement + // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers + // ILLEGAL_DATA_ADDRESS below. + if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled) + return ExceptionCode::ILLEGAL_FUNCTION; + const uint32_t end_address = static_cast(start_address) + number_of_registers; uint32_t current_address = start_address; while (current_address < end_address) { @@ -50,13 +56,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u } ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", static_cast(current_address)); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } if (!server_register->read_lambda) { // Registered but not readable (write-only); don't mask it with the courtesy default. ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } // A multi-register value is normally atomic: the request must start at its first register and cover all of @@ -72,10 +78,16 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " "Sending exception response.", server_register->address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; } - int64_t value = server_register->read_lambda(); + const optional read_value = server_register->read_lambda(); + if (!read_value.has_value()) { + ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.", + server_register->address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + const int64_t value = *read_value; char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), @@ -89,7 +101,7 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, server_register->register_count); - return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + return ExceptionCode::SERVICE_DEVICE_FAILURE; } for (uint16_t i = 0; i < take; i++) { registers.push_back(value_words[value_offset + i]); @@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); + // No registers configured (e.g. a bits-only server): this device does not implement the register-write + // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS. + if (this->server_registers_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + auto for_each_register = [this, start_address, ®isters](const std::function &callback) -> bool { @@ -132,7 +149,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, // so we never apply a partial write before discovering a problem. The commit pass below re-runs // registers_to_number rather than caching the decoded values: using the same function for the check and // the write keeps a single source of truth for the decode bound, independent of how register_count was set. - ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + ExceptionCode precheck = ExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { if (server_register->write_lambda == nullptr) { return false; // unwritable -> ILLEGAL_DATA_ADDRESS @@ -140,12 +157,14 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, server_register->value_type) .has_value()) { - precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + precheck = ExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value return false; } return true; })) { - ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // registers this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "Write request rejected before applying any register."); return precheck; } @@ -158,13 +177,90 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return server_register->write_lambda(number); })) { ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); - return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + return ExceptionCode::SERVICE_DEVICE_FAILURE; } // Success: the caller builds the write response (an echo of the request header). return {}; } +ServerBit *ModbusServer::find_bit_(uint16_t address) const { + for (auto *server_bit : this->server_bits_) { + if (server_bit->address == address) { + return server_bit; + } + } + return nullptr; +} + +modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) { + ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.", + this->address_, start_address, bits.size()); + + // No bits configured: this device does not implement the coil/discrete-input function, so answer + // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); // range pre-checked by the hub + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->read_lambda) { + ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + const optional value = server_bit->read_lambda(address); + if (!value.has_value()) { + ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + bits.set(i, *value); + } + return {}; +} + +modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) { + ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_, + start_address, bits.size()); + + // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather + // than ILLEGAL_DATA_ADDRESS. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write + // before discovering a problem (mirrors the register write's two passes). + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // bits this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + } + + // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather + // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the + // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight + // can never turn this into a silent null dereference. The only expected failure is a write callback + // rejecting the value at runtime, which cannot be rolled back. + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + if (!server_bit->write_lambda(address, bits[i])) { + ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + } + return {}; +} + void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, "ModbusServer:\n" @@ -182,6 +278,11 @@ void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } + ESP_LOGCONFIG(TAG, "server bits"); + for (auto &b : this->server_bits_) { + ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false", + b->write_lambda ? "true" : "false"); + } #endif } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 4fddd9854d..22903abfad 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -20,7 +20,7 @@ struct ServerCourtesyResponse { }; class ServerRegister { - using ReadLambda = std::function; + using ReadLambda = std::function()>; using WriteLambda = std::function; public: @@ -30,13 +30,18 @@ class ServerRegister { this->register_count = register_count; } - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); + /// The user lambda returns optional: an empty optional declines the read, answering the whole + /// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly. + template void set_read_lambda(const std::function(uint16_t address)> &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> optional { + const optional user_value = user_read_lambda(this->address); + if (!user_value.has_value()) { + return {}; + } if constexpr (std::is_same_v) { - return bit_cast(user_value); + return bit_cast(*user_value); } else { - return static_cast(user_value); + return static_cast(*user_value); } }; } @@ -61,6 +66,7 @@ class ServerRegister { const char *format_value(int64_t value, char *buf, size_t buf_size) const { switch (this->value_type) { case SensorValueType::U_WORD: + case SensorValueType::U_WORD_S: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: @@ -68,6 +74,7 @@ class ServerRegister { buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast(value)); return buf; case SensorValueType::S_WORD: + case SensorValueType::S_WORD_S: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: @@ -95,17 +102,43 @@ class ServerRegister { WriteLambda write_lambda; }; +/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete +/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the +/// register table: both read function codes are served from the same bits. +class ServerBit { + /// Returning an empty optional declines the read: the whole request is answered with a + /// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly. + using ReadLambda = std::function(uint16_t address)>; + using WriteLambda = std::function; + + public: + explicit ServerBit(uint16_t address) : address(address) {} + void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); } + void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); } + + uint16_t address{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// Registers a server bit with the controller. Called by esphomes code generator + void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; + /// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are + /// served from the same bit table (see ServerBit) + modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final; + /// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors + modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; @@ -116,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { protected: /// Find the registered value whose register span contains address, or nullptr if none does. ServerRegister *find_containing_register_(uint32_t address) const; + /// Find the registered bit at address, or nullptr if none is. + ServerBit *find_bit_(uint16_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; + /// Collection of all server bits (coils/discrete inputs) for this component + std::vector server_bits_{}; /// Server courtesy response ServerCourtesyResponse server_courtesy_response_{ .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py index c8648cbc63..ab261142b8 100644 --- a/esphome/components/mopeka_ble/__init__.py +++ b/esphome/components/mopeka_ble/__init__.py @@ -1,24 +1,27 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID CODEOWNERS = ["@spbrogan", "@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CONF_SHOW_SENSORS_WITHOUT_SYNC = "show_sensors_without_sync" mopeka_ble_ns = cg.esphome_ns.namespace("mopeka_ble") MopekaListener = mopeka_ble_ns.class_( - "MopekaListener", esp32_ble_tracker.ESPBTDeviceListener + "MopekaListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(MopekaListener), - cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MopekaListener), + cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): @@ -27,4 +30,4 @@ async def to_code(config): cg.add( var.set_show_sensors_without_sync(config[CONF_SHOW_SENSORS_WITHOUT_SYNC]) ) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index ff5dd8d61b..0bef1eb6d4 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -2,8 +2,6 @@ #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { static const char *const TAG = "mopeka_ble"; @@ -34,7 +32,7 @@ static const uint8_t MANUFACTURER_NRF52_DATA_LENGTH = 10; * - Bluetooth data frame size */ -bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaListener::parse_device(const ble_device_base::ESPBTDevice &device) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; // Fetch information about BLE device. const auto &service_uuids = device.get_service_uuids(); @@ -50,8 +48,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) const auto &manu_data = manu_datas[0]; // Is the device maybe a Mopeka Std (CC2540) sensor. - if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { + if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { return false; } @@ -66,8 +64,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } // Is the device maybe a Mopeka Pro (NRF52) sensor. - } else if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { + } else if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { return false; } @@ -86,5 +84,3 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index e6fae23aee..460668ae65 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -2,16 +2,14 @@ #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { -class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { show_sensors_without_sync_ = show_sensors_without_sync; } @@ -21,5 +19,3 @@ class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { }; } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index ab0ff9a113..fe3178d3aa 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -1,8 +1,6 @@ #include "mopeka_pro_check.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_pro_check { static const char *const TAG = "mopeka_pro_check"; @@ -25,7 +23,7 @@ void MopekaProCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaProCheck::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { return false; } @@ -154,5 +152,3 @@ SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector } } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index 40fb338350..0cd53107c6 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -5,9 +5,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::mopeka_pro_check { @@ -27,11 +25,11 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_min_signal_quality(SensorReadQuality min) { this->min_signal_quality_ = min; }; @@ -65,5 +63,3 @@ class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 323175917d..0d10970550 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -56,11 +56,11 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@spbrogan"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_pro_check_ns = cg.esphome_ns.namespace("mopeka_pro_check") MopekaProCheck = mopeka_pro_check_ns.class_( - "MopekaProCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaProCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) SensorReadQuality = mopeka_pro_check_ns.enum("SensorReadQuality") @@ -71,7 +71,8 @@ SIGNAL_QUALITIES = { "HIGH": SensorReadQuality.QUALITY_HIGH, } -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_pro_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaProCheck), @@ -122,15 +123,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 519a45fcb5..d70306c97d 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -3,8 +3,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { static const char *const TAG = "mopeka_std_check"; @@ -33,7 +31,7 @@ void MopekaStdCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { // Validate address. if (device.address_uint64() != this->address_) { return false; @@ -52,7 +50,7 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return false; } const auto &service_uuid = service_uuids[0]; - if (service_uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID)) { + if (service_uuid != ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID)) { return false; } } @@ -232,5 +230,3 @@ int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { } } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index 2f1681f6ea..75d9b36a58 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -3,12 +3,10 @@ #include #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { enum SensorType { @@ -42,11 +40,11 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_level(sensor::Sensor *level) { this->level_ = level; }; @@ -74,5 +72,3 @@ class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index d4535d9671..5cc4ea3039 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -50,14 +50,15 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_std_check_ns = cg.esphome_ns.namespace("mopeka_std_check") MopekaStdCheck = mopeka_std_check_ns.class_( - "MopekaStdCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaStdCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_std_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaStdCheck), @@ -93,15 +94,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 4a5eacf449..713969ab88 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_CLIENT_ID, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, + CONF_DISCOVER_IP, CONF_DISCOVERY, CONF_DISCOVERY_OBJECT_ID_GENERATOR, CONF_DISCOVERY_PREFIX, @@ -62,6 +63,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -73,7 +75,6 @@ def AUTO_LOAD(): return ["json"] -CONF_DISCOVER_IP = "discover_ip" CONF_IDF_SEND_ASYNC = "idf_send_async" CONF_WAIT_FOR_CONNECTION = "wait_for_connection" @@ -332,6 +333,68 @@ CONFIG_SCHEMA = cv.All( ) +# Platforms whose MQTT components subscribe to an object_id-derived command topic. +# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus +# text, whose MQTT component subscribes a command topic that cannot be overridden. +_COMMAND_TOPIC_PLATFORMS = frozenset( + { + "alarm_control_panel", + "button", + "climate", + "cover", + "datetime", + "fan", + "light", + "lock", + "number", + "select", + "switch", + "text", + "update", + "valve", + } +) + + +# Platforms whose MQTT components derive extra sub-topics (position/command, +# mode/command, speed/command, ...) from the object_id, each with its own config +# key; custom state and command topics cannot exempt them from conflicting. +_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) + + +def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: + """Check whether more than one entity actually uses an object_id-derived topic. + + An empty topic_prefix disables default topics entirely, custom state and + command topics avoid the default topics, and disabling discovery (globally + or per entity) avoids the discovery config topic. + """ + if config[CONF_TOPIC_PREFIX]: + platform = entities[0].platform + if platform in _SUB_TOPIC_PLATFORMS: + return True + if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: + return True + if ( + platform in _COMMAND_TOPIC_PLATFORMS + and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 + ): + return True + if not config[CONF_DISCOVERY]: + return False + discovery_entities = sum( + entity.config.get(CONF_DISCOVERY, True) for entity in entities + ) + return discovery_entities > 1 + + +FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( + "mqtt builds default topics and discovery topics from the entity object_id, " + "which is the name converted to ASCII", + conflict_filter=_topics_conflict, +) + + def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 0f4bcb3e16..3544fb2647 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -1,13 +1,20 @@ import ipaddress import logging +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT +from esphome.const import ( + CONF_ENABLE_IPV6, + CONF_ID, + CONF_MIN_IPV6_ADDR_COUNT, + CONF_PRIORITY, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -20,6 +27,54 @@ _LOGGER = logging.getLogger(__name__) KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" +# Network priority tracking infrastructure +# Components can query this to determine their relative setup priority. +# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts of the form +# {"interface": "ethernet"}, in user-declared order. +KEY_NETWORK_PRIORITY = "network_priority" + +# Only interfaces whose component already calls get_network_priority() are +# accepted in the priority list. openthread and modem will be added here when +# they wire up their setup-priority consumer in their own to_code — see +# NETWORK_PLAN.md for the full multi-interface roadmap. +VALID_NETWORK_TYPES = ["ethernet", "wifi"] + +# Interfaces NetworkComponent::loop() knows how to arbitrate the default route +# for. Deliberately NOT derived from VALID_NETWORK_TYPES: extending that list +# without extending the C++ arbitration (and then this set) is caught in +# _final_validate() as a config error instead of a silently mis-routed interface. +ARBITRATED_NETWORK_TYPES = frozenset({"ethernet", "wifi"}) + +# Setup priority base values — first in list gets the highest priority. +# +# The base equals the historical setup_priority::WIFI / ::ETHERNET default +# (250.0), so a single-entry priority list yields exactly the same setup order +# as a config with no priority block. Subsequent entries step down by a small +# amount to break ties without crossing other priority bands. +# +# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH +# (300.0), which NetworkComponent itself uses — otherwise the highest-priority +# interface could tie with NetworkComponent and run before esp_netif_init(). +NETWORK_PRIORITY_BASE = 250.0 +NETWORK_PRIORITY_STEP = 5.0 + +# Lower-bound guard. The lowest-priority entry gets +# NETWORK_PRIORITY_BASE - (len - 1) * NETWORK_PRIORITY_STEP, which must stay +# strictly above setup_priority::AFTER_WIFI (200.0, see esphome/core/component.h) +# so a long priority list never drops an interface into the band used by +# components that expect to run after the network is up. There is ample headroom +# for the two types today; this check raises if a future expansion of +# VALID_NETWORK_TYPES would silently cross that band. Uses an explicit raise +# rather than a bare assert so the guard isn't stripped under python -O/-OO. +_SETUP_PRIORITY_AFTER_WIFI = 200.0 +if ( + NETWORK_PRIORITY_BASE - (len(VALID_NETWORK_TYPES) - 1) * NETWORK_PRIORITY_STEP + <= _SETUP_PRIORITY_AFTER_WIFI +): + raise ValueError( + "network: priority: list is long enough to cross setup_priority::AFTER_WIFI" + ) + network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") @@ -142,6 +197,83 @@ def validate_ipv6(value: bool) -> bool: return value +def get_network_priority(iface: str) -> float | None: + """Get the setup priority for the given network interface type. + + Returns the float setup priority for ``iface`` based on the order declared + under ``network: priority:``. Interfaces listed first receive a higher + setup priority so they are initialised before lower-priority ones. + + If no ``network: priority:`` has been configured this returns ``None`` and + the calling component should fall back to its own default setup priority. + + Args: + iface: Interface type string (case-insensitive). Currently ``"ethernet"`` + or ``"wifi"`` — the only types the priority-list validator + accepts; ``"openthread"`` / ``"modem"`` are planned but not yet + supported. An interface not present in the configured list + returns ``None``. + + Returns: + float setup priority, or None if no priority list was configured. + + Example usage inside a component's ``to_code``. Emit the override before + ``register_component`` so an explicit ``setup_priority:`` on the component + still wins:: + + from esphome.components import network + + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + + prio = network.get_network_priority("ethernet") + if prio is not None: + cg.set_setup_priority(var, prio) + + await cg.register_component(var, config) + ... + """ + priority_list = CORE.data.get(KEY_NETWORK_PRIORITY) + if priority_list is None: + return None + iface_lower = iface.lower() + for idx, entry in enumerate(priority_list): + if entry["interface"] == iface_lower: + return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP) + return None + + +def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str]: + """Return the set of interface names declared in ``network: priority:``. + + Reads from the full validated config (``fv.full_config.get()``) and is + intended for use inside ``FINAL_VALIDATE_SCHEMA`` hooks, before + ``to_code`` has run and ``CORE.data`` has been populated. Returns an + empty set if no priority list was configured. + """ + return { + entry["interface"] + for entry in full_config.get("network", {}).get(CONF_PRIORITY, []) + } + + +def _validate_priority_list(value: Any) -> list[dict[str, str]]: + """Validate and normalize the priority list, rejecting duplicates. + + Each entry is the name of one network interface (one of + ``VALID_NETWORK_TYPES``). Mixed-case input is accepted and normalized + to lowercase. The normalized list is a list of dicts of the form + ``{"interface": "ethernet"}`` so that future per-entry options can be + added without breaking call sites. + """ + raw = cv.ensure_list(cv.one_of(*VALID_NETWORK_TYPES, lower=True))(value) + entries = [{"interface": iface} for iface in raw] + interfaces = [e["interface"] for e in entries] + if len(interfaces) != len(set(interfaces)): + raise cv.Invalid("Duplicate entries are not allowed in 'priority'") + return entries + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -174,17 +306,83 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( cv.boolean, cv.only_on_esp32 ), + cv.Optional(CONF_PRIORITY): _validate_priority_list, } ), _register_provisioning_source, ) +def _final_validate(config: ConfigType) -> None: + """Check that every interface named in 'priority' has a corresponding component block.""" + full = fv.full_config.get() + priority_list = config.get(CONF_PRIORITY, []) + for entry in priority_list: + iface = entry["interface"] + if iface not in full: + raise cv.Invalid( + f"'{iface}' is listed in 'network: priority:' but no '{iface}:' " + f"component is configured", + [CONF_PRIORITY], + ) + + # Tripwire for future interface types (openthread, modem): the C++ default-route + # arbitration pivots on USE_NETWORK_PRIMARY_INTERFACE_WIFI and only knows + # ethernet and wifi. Extend NetworkComponent::loop() before allowing another + # type here. Unreachable until VALID_NETWORK_TYPES grows. + if ( + len(priority_list) > 1 + and ( + unsupported := {e["interface"] for e in priority_list} + - ARBITRATED_NETWORK_TYPES + ) + and CORE.is_esp32 + ): + raise cv.Invalid( + "Default-route arbitration does not support: " + f"{', '.join(sorted(unsupported))}", + [CONF_PRIORITY], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.NETWORK) async def to_code(config): cg.add_define("USE_NETWORK") # ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed + # Store the user-declared network priority list in CORE.data so that ethernet, + # wifi and other network components can query it via get_network_priority() + # during their own to_code phase. + if CONF_PRIORITY in config: + priority_list = config[CONF_PRIORITY] + CORE.data[KEY_NETWORK_PRIORITY] = priority_list + # network/util.cpp resolves the reported address (get_use_address_to, + # get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority + # list is the only case that deviates from it, so it is the only case that + # needs a define. + if priority_list[0]["interface"] == "wifi": + cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI") + + # With more than one interface, NetworkComponent::loop() arbitrates the + # default route (ESP-IDF's fixed route_prio values would always favor + # WiFi). ESP32 only: the arbitration needs esp_netif, which both + # frameworks build from source. + # The ethernet/wifi-only assumption behind the arbitration is enforced in + # _final_validate() so a future unsupported type fails as a config error. + if len(priority_list) > 1 and CORE.is_esp32: + cg.add_define("USE_NETWORK_DEFAULT_ROUTE") + # Have lwIP switch to the DNS servers of the netif that owns the + # default route whenever the arbitration changes it. + add_idf_sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF", True) + + _LOGGER.info( + "Network interface priority: %s", + " > ".join(entry["interface"] for entry in priority_list), + ) + # Apply high performance networking settings # Config can explicitly enable/disable, or default to component-driven behavior enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index d8a127f4a0..f7aa7daf99 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -19,10 +19,37 @@ #ifdef USE_HOST #include +#if USE_NETWORK_IPV6 +using ip4_addr_t = struct in_addr; +using ip6_addr_t = struct in6_addr; +struct ip_addr_t { + union { + struct in6_addr ip6; + struct in_addr ip4; + } u_addr; + uint8_t type; +}; +enum : uint8_t { IPADDR_TYPE_V4 = 0, IPADDR_TYPE_V6 = 6 }; +static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { + if (strchr(cp, ':') != nullptr) { + if (inet_pton(AF_INET6, cp, &addr->u_addr.ip6) != 1) { + return 0; + } + addr->type = IPADDR_TYPE_V6; + return 1; + } + if (inet_aton(cp, &addr->u_addr.ip4) != 1) { + return 0; + } + addr->type = IPADDR_TYPE_V4; + return 1; +} +#else using ip_addr_t = in_addr; using ip4_addr_t = in_addr; #define ipaddr_aton(x, y) inet_aton((x), (y)) -#endif +#endif // USE_NETWORK_IPV6 +#endif // USE_HOST #ifdef USE_ZEPHYR #include @@ -71,15 +98,6 @@ struct IPAddress { bool is_ip4() const { return false; } bool is_ip6() const { return this->is_set(); } bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr) buf[0] = '\0'; @@ -89,12 +107,46 @@ struct IPAddress { bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); } #elif defined(USE_HOST) - IPAddress() { ip_addr_.s_addr = 0; } +#if USE_NETWORK_IPV6 + IPAddress() { memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { - this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip4.s_addr = + htonl(((uint32_t) first << 24) | ((uint32_t) second << 16) | ((uint32_t) third << 8) | fourth); + this->ip_addr_.type = IPADDR_TYPE_V4; + } + IPAddress(const char *in_address) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + ipaddr_aton(in_address, &this->ip_addr_); + } + IPAddress(const std::string &in_address) : IPAddress(in_address.c_str()) {} + IPAddress(const ip_addr_t *other_ip) { memcpy(&this->ip_addr_, other_ip, sizeof(ip_addr_t)); } + IPAddress(ip4_addr_t *other_ip) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip4 = *other_ip; + this->ip_addr_.type = IPADDR_TYPE_V4; + } + IPAddress(ip6_addr_t *other_ip) { + memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); + this->ip_addr_.u_addr.ip6 = *other_ip; + this->ip_addr_.type = IPADDR_TYPE_V6; + } + operator ip_addr_t() const { return this->ip_addr_; } + bool is_set() const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + static constexpr uint8_t zero[sizeof(struct in6_addr)] = {}; + return memcmp(this->ip_addr_.u_addr.ip6.s6_addr, zero, sizeof(zero)) != 0; + } + return this->ip_addr_.u_addr.ip4.s_addr != 0; + } + bool is_ip4() const { return this->ip_addr_.type == IPADDR_TYPE_V4; } + bool is_ip6() const { return this->ip_addr_.type == IPADDR_TYPE_V6; } + bool is_multicast() const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + return this->ip_addr_.u_addr.ip6.s6_addr[0] == 0xff; + } + return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL; } - IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } - IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } // Remove before 2026.8.0 ESPDEPRECATED( "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", @@ -104,11 +156,44 @@ struct IPAddress { this->str_to(buf); return buf; } + char *str_to(char *buf) const { + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE); + } else { + inet_ntop(AF_INET, &this->ip_addr_.u_addr.ip4, buf, IP_ADDRESS_BUFFER_SIZE); + } + lowercase_ip_str(buf); + return buf; + } + bool operator==(const IPAddress &other) const { + if (this->ip_addr_.type != other.ip_addr_.type) { + return false; + } + if (this->ip_addr_.type == IPADDR_TYPE_V6) { + return memcmp(&this->ip_addr_.u_addr.ip6, &other.ip_addr_.u_addr.ip6, sizeof(struct in6_addr)) == 0; + } + return this->ip_addr_.u_addr.ip4.s_addr == other.ip_addr_.u_addr.ip4.s_addr; + } + bool operator!=(const IPAddress &other) const { return !(*this == other); } + IPAddress &operator+=(uint8_t increase) { + if (this->ip_addr_.type == IPADDR_TYPE_V4) { + (((uint8_t *) (&this->ip_addr_.u_addr.ip4))[3]) += increase; + } + return *this; + } +#else + IPAddress() { ip_addr_.s_addr = 0; } + IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { + this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth); + } + IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } + IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. char *str_to(char *buf) const { inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); return buf; // IPv4 only, no hex letters to lowercase } +#endif // USE_NETWORK_IPV6 #else IPAddress() { ip_addr_set_zero(&ip_addr_); } IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) { @@ -186,15 +271,6 @@ struct IPAddress { bool is_ip4() const { return IP_IS_V4(&ip_addr_); } bool is_ip6() const { return IP_IS_V6(&ip_addr_); } bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. /// Output is lowercased per RFC 5952 (IPv6 hex digits a-f). char *str_to(char *buf) const { diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index 40cf64906c..cf457bb661 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -6,6 +6,20 @@ #include "esp_err.h" #include "esp_netif.h" #include "esp_event.h" + +#ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esp_netif_net_stack.h" +#include "lwip/netif.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#endif + namespace esphome::network { static const char *const TAG = "network"; @@ -29,5 +43,81 @@ void NetworkComponent::setup() { } } +#ifdef USE_NETWORK_DEFAULT_ROUTE +static esp_netif_t *connected_wifi_netif() { +#ifdef USE_WIFI + auto *wifi = wifi::global_wifi_component; + if (wifi != nullptr && wifi->is_connected()) + return wifi->get_esp_netif_sta(); +#endif + return nullptr; +} + +static esp_netif_t *connected_ethernet_netif() { +#ifdef USE_ETHERNET + auto *eth = ethernet::global_eth_component; + if (eth != nullptr && eth->is_connected()) + return eth->get_esp_netif(); +#endif + return nullptr; +} + +void NetworkComponent::loop() { + // Pin the default route to the first connected interface in the user's priority + // order; ESP-IDF's own route_prio selection would always favor WiFi. + // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; + // it selects the reported address in util.cpp and doubles as the route-order + // pivot here — the two uses must stay in sync. + esp_netif_t *best; +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + best = connected_wifi_netif(); + if (best == nullptr) + best = connected_ethernet_netif(); +#else + best = connected_ethernet_netif(); + if (best == nullptr) + best = connected_wifi_netif(); +#endif + if (best == nullptr) { + // Forget the last winner: stopping its netif cleared lwIP's default route and + // IDF's manual override suppresses re-election, so reconnect must re-assert it. + this->default_netif_ = nullptr; + return; + } + if (best == this->default_netif_) { + // Same winner as the last assert. Still re-assert if lwIP's default route is + // not the winner's netif: a winner whose netif bounced down and up between two + // polls would otherwise stay routeless (stopping a netif nulls lwIP's + // netif_default). Checking lwIP directly keeps this independent of IDF's + // re-election bookkeeping (esp_netif_get_default_netif() cannot detect it). + // Throttled: LwIPLock is the global lwIP core mutex, and this branch runs on + // every pass once the route has settled. + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_route_check_ < ROUTE_CHECK_INTERVAL_MS) + return; + this->last_route_check_ = now; + bool route_is_ours; + { + LwIPLock lock; + route_is_ours = static_cast(netif_default) == esp_netif_get_netif_impl(best); + } + if (route_is_ours) + return; + } + esp_err_t err = esp_netif_set_default_netif(best); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set default interface: (%d) %s", err, esp_err_to_name(err)); + // Cache the intent anyway: subsequent passes take the same-winner branch + // above, so retries are throttled to ROUTE_CHECK_INTERVAL_MS and the lwIP + // verification keeps re-attempting until the route is actually ours. + this->default_netif_ = best; + this->last_route_check_ = App.get_loop_component_start_time(); + return; + } + this->default_netif_ = best; + ESP_LOGI(TAG, "Default interface: %s", esp_netif_get_desc(best)); +} +#endif // USE_NETWORK_DEFAULT_ROUTE + } // namespace esphome::network #endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 2e76a95673..8d4866d4f0 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -3,12 +3,30 @@ #if defined(USE_NETWORK) && defined(USE_ESP32) #include "esphome/core/component.h" +#ifdef USE_NETWORK_DEFAULT_ROUTE +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::network { class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + +#ifdef USE_NETWORK_DEFAULT_ROUTE + void loop() override; + + protected: + // Verify-lwIP-route interval for the settled state; keeps the global lwIP core + // mutex off the hot loop path. + static constexpr uint32_t ROUTE_CHECK_INTERVAL_MS = 1000; + // Last netif this component made the default; avoids redundant esp_netif calls. + esp_netif_t *default_netif_{nullptr}; + uint32_t last_route_check_{0}; +#endif }; } // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index ae250c6a1f..11485fdcf0 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -10,22 +10,44 @@ namespace esphome::network { // an AP that uses a previous interface for NAT). bool is_disabled() { + // The network is disabled only when every configured interface with a + // disable() lifecycle is disabled; one enabled interface means traffic can flow. + bool disabled = false; #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_disabled(); + if (modem::global_modem_component != nullptr) { + if (!modem::global_modem_component->is_disabled()) + return false; + disabled = true; + } #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_disabled(); + if (wifi::global_wifi_component != nullptr) { + if (!wifi::global_wifi_component->is_disabled()) + return false; + disabled = true; + } #endif - return false; + +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) { + if (!ethernet::global_eth_component->is_disabled()) + return false; + disabled = true; + } +#endif + return disabled; } const char *get_use_address_to(std::span buf) { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined. + // A wifi-first network: priority: list sets USE_NETWORK_PRIMARY_INTERFACE_WIFI to lift + // wifi ahead of the fixed ethernet-first order below; an ethernet-first list already + // matches that order, so no define exists for it. const char *addr = nullptr; -#if defined(USE_ETHERNET) +#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_ETHERNET) addr = ethernet::global_eth_component->get_use_address(); #elif defined(USE_MODEM) addr = modem::global_modem_component->get_use_address(); @@ -44,6 +66,19 @@ const char *get_use_address_to(std::span buf) { } network::IPAddresses get_ip_addresses() { + // With a wifi-first network: priority: list, prefer wifi while it has a valid IP; + // otherwise fall through to the fixed ethernet-first order below. Selection based + // on the runtime-active interface is a planned follow-up. +#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI) + if (wifi::global_wifi_component != nullptr) { + auto ips = wifi::global_wifi_component->get_ip_addresses(); + for (const auto &ip : ips) { + if (ip.is_set()) + return ips; + } + } +#endif + #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) return ethernet::global_eth_component->get_ip_addresses(); diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 17a2ff0977..65a578c22f 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -52,12 +52,14 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return false; } -/// Return whether the network is disabled (only wifi for now) +/// Return whether the network is disabled: every configured interface with a +/// disable() lifecycle (modem, wifi, ethernet) is disabled. bool is_disabled(); /// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; /// Get the active network address for logging. Returns the explicitly configured -/// use_address when one was set, otherwise formats ".local" from the runtime +/// use_address when one was set (from the highest-priority interface when +/// network: priority: is configured), otherwise formats ".local" from the runtime /// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index d51155b0a4..efb6c88d28 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,10 +19,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2_ARDUINO, - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, }, } ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 89e9b93520..4ab123c354 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -4,7 +4,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_ID, + CONF_LAMBDA, + CONF_ON_TOUCH, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE, TimePeriod from . import ( # noqa: F401 pylint: disable=unused-import @@ -135,7 +142,17 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_TFT_URL): cv.url, + # TFT upload needs an HTTP client and runtime UART reconfiguration, + # neither of which is implemented for the RP2 or host platforms. + cv.Optional(CONF_TFT_URL): cv.All( + cv.url, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + ] + ), + ), cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( 0, cv.int_range(min=3, max=65535) ), diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 7dc5a4fe44..aa9fe8abb3 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -23,7 +23,9 @@ #elif defined(USE_ESP8266) #include #include -#endif // USE_ESP32 vs USE_ESP8266 +#elif defined(USE_LIBRETINY) +#include +#endif // USE_ESP32 vs USE_ESP8266 vs USE_LIBRETINY #endif // USE_NEXTION_TFT_UPLOAD namespace esphome::nextion { @@ -1564,7 +1566,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#elif defined(USE_ARDUINO) +#elif defined(USE_ESP8266) || defined(USE_LIBRETINY) /** * will request chunk_size chunks from the web server * and send each to the nextion @@ -1573,7 +1575,7 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#endif // USE_ESP32 vs USE_ARDUINO +#endif // USE_ESP32 vs USE_ESP8266/USE_LIBRETINY /** * Ends the upload process, restart Nextion and, if successful, diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 41379c2345..2f3377d950 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifndef USE_ESP32 +#ifdef USE_ESP8266 #include #include "esphome/components/network/util.h" @@ -209,7 +209,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; -#ifdef USE_ESP8266 #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); #elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) @@ -219,7 +218,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setRedirectLimit(3); #endif begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str()); -#endif // USE_ESP8266 if (!begin_status) { this->connection_state_.is_updating_ = false; ESP_LOGD(TAG, "Connection failed"); @@ -356,7 +354,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return upload_end_(true); } -#ifdef USE_ESP8266 WiFiClient *Nextion::get_wifi_client_() { if (this->tft_url_.starts_with("https:")) { if (this->wifi_client_secure_ == nullptr) { @@ -374,9 +371,8 @@ WiFiClient *Nextion::get_wifi_client_() { } return this->wifi_client_; } -#endif // USE_ESP8266 } // namespace esphome::nextion -#endif // NOT USE_ESP32 +#endif // USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index cd8feab84f..e2d5ae8ad7 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -44,17 +44,60 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r ESP_LOGV(TAG, "Range: %s", range_header); esp_http_client_set_header(http_client, "Range", range_header); ESP_LOGV(TAG, "Open HTTP"); - esp_err_t err = esp_http_client_open(http_client, 0); - if (err != ESP_OK) { - ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err)); - return -1; + int chunk_size = -1; + int status_code = -1; + esp_err_t last_err = ESP_FAIL; + for (uint8_t attempt = 0; attempt < this->tft_upload_http_retries_; attempt++) { + status_code = -1; + last_err = esp_http_client_open(http_client, 0); + if (last_err == ESP_OK) { + ESP_LOGV(TAG, "Fetch length"); + chunk_size = esp_http_client_fetch_headers(http_client); + ESP_LOGV(TAG, "Length: %d", chunk_size); + if (chunk_size >= 0) { + status_code = esp_http_client_get_status_code(http_client); + // Accept the requested range (206 with exact length) or a full-body 200 + // only from offset 0; elsewhere a 200 replays the file and corrupts the display. + if (chunk_size > 0 && + ((status_code == 206 && chunk_size == static_cast(range_end - range_start + 1)) || + (status_code == 200 && range_start == 0 && chunk_size == static_cast(this->tft_size_)))) { + break; + } + if (status_code == 200) { + if (range_start == 0) { + ESP_LOGE(TAG, "Unexpected length for 200 response: %d (expected %d)", chunk_size, + static_cast(this->tft_size_)); + } else { + // A server that ignored the range once will ignore it again + ESP_LOGE(TAG, "Server does not support range requests (got 200 at offset %" PRIu32 ")", range_start); + } + chunk_size = -1; + last_err = ESP_FAIL; + break; + } + ESP_LOGW(TAG, "Bad response: status %d, length %d (expected %" PRIu32 ")", status_code, chunk_size, + range_end - range_start + 1); + chunk_size = -1; + last_err = ESP_FAIL; + // A 4xx (except timeout/rate-limit) won't improve on retry + if (status_code >= 400 && status_code < 500 && status_code != 408 && status_code != 429) { + break; + } + } else { + ESP_LOGW(TAG, "Get length failed: %d", chunk_size); + last_err = ESP_FAIL; + } + } else { + ESP_LOGW(TAG, "HTTP open failed: %s", esp_err_to_name(last_err)); + } + // The server may have dropped the keep-alive connection while the display + // was busy processing a chunk; close so the next attempt reconnects. + esp_http_client_close(http_client); + vTaskDelay(pdMS_TO_TICKS(2)); // NOLINT + App.feed_wdt(); } - - ESP_LOGV(TAG, "Fetch length"); - const int chunk_size = esp_http_client_fetch_headers(http_client); - ESP_LOGV(TAG, "Length: %d", chunk_size); if (chunk_size <= 0) { - ESP_LOGE(TAG, "Get length failed: %d", chunk_size); + ESP_LOGE(TAG, "HTTP request failed, last status: %d, last error: %s", status_code, esp_err_to_name(last_err)); return -1; } @@ -164,6 +207,9 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r } else { range_start = range_end + 1; } + // The response body may be only partially read; close so the next + // range request starts on a clean connection. + esp_http_client_close(http_client); // Deallocate buffer allocator.deallocate(buffer, 4096); buffer = nullptr; diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5b3c250f34..386fed5412 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -283,6 +283,13 @@ def _validate_mcumgr(config): def _final_validate(config): + # Remove before 2027.2.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " + "Please use 'toolchain: sdk-nrf' instead." + ) + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: @@ -715,11 +722,17 @@ def _addr2line(addr2line: str, elf: Path, addr: str) -> str: return "" +# The PC bound matches the gate in platform_hooks.STACKTRACE_GATES; +# the logger prints both registers with %08x, so a real PC is always +# 8 digits. tests/unit_tests/test_stacktrace.py guards against drift. +STACKTRACE_NRF52_PC_LR_RE = re.compile(r"PC=(0x[0-9a-fA-F]{3,})\s+LR=(0x[0-9a-fA-F]+)") + + def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: if "Last crash:" in line: return True if backtrace_state: - match = re.search(r"PC=(0x[0-9a-fA-F]+)\s+LR=(0x[0-9a-fA-F]+)", line) + match = STACKTRACE_NRF52_PC_LR_RE.search(line) if match: pc = match.group(1) lr = match.group(2) @@ -870,6 +883,21 @@ def run_compile(args, config: ConfigType) -> bool: zephyr_dir = build_dir / "zephyr" framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + bootloader = zephyr_data()[KEY_BOOTLOADER] + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant + _UF2_FAMILY_IDS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: "0x7EAED30A", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: "0xADA52840", + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: "0xADA52840", + } + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); # copy files to match get_download_types layout. @@ -881,20 +909,43 @@ def run_compile(args, config: ConfigType) -> bool: _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes - _GENPKG_PARAMS = { - BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), - } - bootloader = zephyr_data()[KEY_BOOTLOADER] + # For Adafruit bootloader builds, regenerate the UF2 from merged.hex, + # whose records carry the correct flash addresses. The build's own + # zephyr.uf2 uses the board's default offset, which is wrong in some cases. + merged_hex = zephyr_dir / "merged.hex" + if bootloader in _UF2_FAMILY_IDS and merged_hex.is_file(): + # Drop the build's own wrong-offset UF2 so it isn't shipped alongside. + app_uf2 = west_out / "zephyr.uf2" + if app_uf2.is_file(): + app_uf2.unlink() + uf2conv = ( + paths["framework_path"] / "zephyr" / "scripts" / "build" / "uf2conv.py" + ) + if not run_command_ok( + [ + str(paths["python_executable"]), + str(uf2conv), + "-f", + _UF2_FAMILY_IDS[bootloader], + "-c", + "-o", + str(zephyr_dir / "zephyr.uf2"), + str(merged_hex), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to generate UF2 from merged hex") + if bootloader in ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ): - hex_file = west_out / "zephyr.hex" + # no fallback is needed for adafruit case. merged merged.hex is always generated. + # get_download_types needs fallback for mcuboot (non adafruit) + hex_file = zephyr_dir / "merged.hex" dfu_package = build_dir / "firmware.zip" genpkg_cmd = [ str(paths["python_executable"]), diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7392ad2d60..6b32fe1fea 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -289,6 +289,7 @@ def check_and_install() -> None: "init", "-m", "https://github.com/nrfconnect/sdk-nrf", + "-o=--depth=1", "--mr", version, str(framework_path), diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index e2828a9e30..f8b515fa05 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -27,11 +27,11 @@ uint8_t parse_u8_lb(OpenthermData &data) { return data.valueLB; } uint8_t parse_u8_hb(OpenthermData &data) { return data.valueHB; } int8_t parse_s8_lb(OpenthermData &data) { return (int8_t) data.valueLB; } int8_t parse_s8_hb(OpenthermData &data) { return (int8_t) data.valueHB; } -uint16_t parse_u16(OpenthermData &data) { return data.u16(); } +uint16_t parse_u16(OpenthermData &data) { return data.get_u16(); } uint16_t parse_u8_lb_60(OpenthermData &data) { return data.valueLB * 60; } uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; } -int16_t parse_s16(OpenthermData &data) { return data.s16(); } -float parse_f88(OpenthermData &data) { return data.f88(); } +int16_t parse_s16(OpenthermData &data) { return data.get_s16(); } +float parse_f88(OpenthermData &data) { return data.get_f88(); } void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); } void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); } @@ -53,9 +53,9 @@ void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = valu void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; } void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; } void write_s8_hb(const int8_t value, OpenthermData &data) { data.valueHB = (uint8_t) value; } -void write_u16(const uint16_t value, OpenthermData &data) { data.u16(value); } -void write_s16(const int16_t value, OpenthermData &data) { data.s16(value); } -void write_f88(const float value, OpenthermData &data) { data.f88(value); } +void write_u16(const uint16_t value, OpenthermData &data) { data.set_u16(value); } +void write_s16(const int16_t value, OpenthermData &data) { data.set_s16(value); } +void write_f88(const float value, OpenthermData &data) { data.set_f88(value); } } // namespace message_data diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 5cf7c19880..3343871cd0 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -114,6 +114,7 @@ bool OpenTherm::get_protocol_error(OpenThermError &error) { void OpenTherm::stop() { this->stop_timer_(); this->mode_ = OperationMode::IDLE; + this->out_pin_->digital_write(true); } void IRAM_ATTR OpenTherm::read_() { @@ -533,34 +534,34 @@ void OpenTherm::debug_data(OpenthermData &data) { ESP_LOGD(TAG, "%s %s %s %s", format_bin_to(type_buf, data.type), format_bin_to(id_buf, data.id), format_bin_to(hb_buf, data.valueHB), format_bin_to(lb_buf, data.valueLB)); ESP_LOGD(TAG, "type: %s; id: %u; HB: %u; LB: %u; uint_16: %u; float: %f", - this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.u16(), - data.f88()); + this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.get_u16(), + data.get_f88()); } void OpenTherm::debug_error(OpenThermError &error) const { ESP_LOGD(TAG, "data: 0x%08" PRIx32 "; clock: %u; capture: 0x%08" PRIx32 "; bit_pos: %u", error.data, this->clock_, error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } +float OpenthermData::get_f88() { return ((float) this->get_s16()) / 256.0f; } -void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } +void OpenthermData::set_f88(float value) { this->set_s16((int16_t) (value * 256)); } -uint16_t OpenthermData::u16() { +uint16_t OpenthermData::get_u16() { uint16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::u16(uint16_t value) { +void OpenthermData::set_u16(uint16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } -int16_t OpenthermData::s16() { +int16_t OpenthermData::get_s16() { int16_t const value = this->valueHB; return (value << 8) | this->valueLB; } -void OpenthermData::s16(int16_t value) { +void OpenthermData::set_s16(int16_t value) { this->valueLB = value & 0xFF; this->valueHB = (value >> 8) & 0xFF; } diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index 3078e92c9d..7aa81cd8a2 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -178,7 +178,7 @@ enum BitPositions { STOP_BIT = 33 }; /** * Structure to hold Opentherm data packet content. - * Use f88(), u16() or s16() functions to get appropriate value of data packet accoridng to id of message. + * Use get_f88(), get_u16() or get_s16() functions to get appropriate value of data packet according to id of message. */ struct OpenthermData { uint8_t type; @@ -191,32 +191,32 @@ struct OpenthermData { /** * @return float representation of data packet value */ - float f88(); + float get_f88(); /** * @param float number to set as value of this data packet */ - void f88(float value); + void set_f88(float value); /** * @return unsigned 16b integer representation of data packet value */ - uint16_t u16(); + uint16_t get_u16(); /** * @param unsigned 16b integer number to set as value of this data packet */ - void u16(uint16_t value); + void set_u16(uint16_t value); /** * @return signed 16b integer representation of data packet value */ - int16_t s16(); + int16_t get_s16(); /** * @param signed 16b integer number to set as value of this data packet */ - void s16(int16_t value); + void set_s16(int16_t value); }; struct OpenThermError { diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index 24d5052076..da2082963d 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -14,7 +14,7 @@ class OpenthermOutput final : public output::FloatOutput, public Component, publ float min_value_, max_value_; public: - float state; + float state{0.0f}; void set_id(const char *id) { this->id_ = id; } diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index f70c8e24db..7f3ea2df36 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -91,7 +91,7 @@ SENSORS: dict[str, SensorSchema] = { ), "dhw_flow_rate": SensorSchema( description="Water flow rate in DHW circuit", - unit_of_measurement="l/min", + unit_of_measurement="L/min", accuracy_decimals=2, icon="mdi:waves-arrow-right", state_class=STATE_CLASS_MEASUREMENT, diff --git a/esphome/components/openthread_info/openthread_info_sensor.cpp b/esphome/components/openthread_info/openthread_info_sensor.cpp new file mode 100644 index 0000000000..9beb52d64b --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.cpp @@ -0,0 +1,24 @@ +#include "openthread_info_sensor.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) +#include "esphome/core/log.h" + +namespace esphome::openthread_info { + +static const char *const TAG = "openthread_info"; + +void ParentAverageRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Average RSSI", this); } +void ParentLastRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Last RSSI", this); } +void ParentLinkQualityInOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality In", this); } +void ParentLinkQualityOutOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality Out", this); } +void TxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Total", this); } +void TxRetriesOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Retries", this); } +void TxErrCcaOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX CCA Errors", this); } +void TxErrAbortOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Abort Errors", this); } +void RxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX Total", this); } +void RxErrFcsOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX FCS Errors", this); } +void AttachAttemptsOpenThreadInfo::dump_config() { LOG_SENSOR("", "Attach Attempts", this); } +void ParentChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Changes", this); } +void PartitionIdChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Partition ID Changes", this); } + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/openthread_info_sensor.h b/esphome/components/openthread_info/openthread_info_sensor.h new file mode 100644 index 0000000000..dcc90da0c0 --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.h @@ -0,0 +1,131 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) + +#include "openthread_info_text_sensor.h" +#include "esphome/components/sensor/sensor.h" + +#include +#include + +namespace esphome::openthread_info { + +// Parent RSSI (average) in dBm. Only valid when device is a child; skips publish otherwise. +class ParentAverageRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentAverageRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Parent RSSI (last received frame) in dBm. Only valid when device is a child. +class ParentLastRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentLastRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Incoming link quality from parent (0-3). Only valid when device is a child. +class ParentLinkQualityInOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityIn); + } + void dump_config() override; +}; + +// Outgoing link quality to parent (0-3). Only valid when device is a child. +class ParentLinkQualityOutOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityOut); + } + void dump_config() override; +}; + +// --- MAC counters (otLinkGetCounters) — cumulative since boot --- + +class TxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxTotal); } + void dump_config() override; +}; + +class TxRetriesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxRetry); } + void dump_config() override; +}; + +class TxErrCcaOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrCca); } + void dump_config() override; +}; + +class TxErrAbortOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrAbort); } + void dump_config() override; +}; + +class RxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxTotal); } + void dump_config() override; +}; + +class RxErrFcsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxErrFcs); } + void dump_config() override; +}; + +// --- MLE stability counters (otThreadGetMleCounters) — cumulative since boot --- + +class AttachAttemptsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mAttachAttempts); + } + void dump_config() override; +}; + +class ParentChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mParentChanges); + } + void dump_config() override; +}; + +class PartitionIdChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mPartitionIdChanges); + } + void dump_config() override; +}; + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py new file mode 100644 index 0000000000..4d5b3d54f4 --- /dev/null +++ b/esphome/components/openthread_info/sensor.py @@ -0,0 +1,188 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_DECIBEL_MILLIWATT, + UNIT_EMPTY, +) + +CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" +CONF_PARENT_LAST_RSSI = "parent_last_rssi" +CONF_PARENT_LINK_QUALITY_IN = "parent_link_quality_in" +CONF_PARENT_LINK_QUALITY_OUT = "parent_link_quality_out" +CONF_TX_TOTAL = "tx_total" +CONF_TX_RETRIES = "tx_retries" +CONF_TX_ERR_CCA = "tx_err_cca" +CONF_TX_ERR_ABORT = "tx_err_abort" +CONF_RX_TOTAL = "rx_total" +CONF_RX_ERR_FCS = "rx_err_fcs" +CONF_ATTACH_ATTEMPTS = "attach_attempts" +CONF_PARENT_CHANGES = "parent_changes" +CONF_PARTITION_ID_CHANGES = "partition_id_changes" + +DEPENDENCIES = ["openthread"] + +openthread_info_ns = cg.esphome_ns.namespace("openthread_info") +ParentAverageRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentAverageRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLastRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentLastRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityInOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityInOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityOutOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityOutOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxTotalOpenThreadInfo = openthread_info_ns.class_( + "TxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxRetriesOpenThreadInfo = openthread_info_ns.class_( + "TxRetriesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrCcaOpenThreadInfo = openthread_info_ns.class_( + "TxErrCcaOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrAbortOpenThreadInfo = openthread_info_ns.class_( + "TxErrAbortOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxTotalOpenThreadInfo = openthread_info_ns.class_( + "RxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxErrFcsOpenThreadInfo = openthread_info_ns.class_( + "RxErrFcsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +AttachAttemptsOpenThreadInfo = openthread_info_ns.class_( + "AttachAttemptsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentChangesOpenThreadInfo = openthread_info_ns.class_( + "ParentChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +PartitionIdChangesOpenThreadInfo = openthread_info_ns.class_( + "PartitionIdChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PARENT_AVERAGE_RSSI): sensor.sensor_schema( + ParentAverageRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LAST_RSSI): sensor.sensor_schema( + ParentLastRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_IN): sensor.sensor_schema( + ParentLinkQualityInOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_OUT): sensor.sensor_schema( + ParentLinkQualityOutOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_TX_TOTAL): sensor.sensor_schema( + TxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_RETRIES): sensor.sensor_schema( + TxRetriesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_CCA): sensor.sensor_schema( + TxErrCcaOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_ABORT): sensor.sensor_schema( + TxErrAbortOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_TOTAL): sensor.sensor_schema( + RxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_ERR_FCS): sensor.sensor_schema( + RxErrFcsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_ATTACH_ATTEMPTS): sensor.sensor_schema( + AttachAttemptsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARENT_CHANGES): sensor.sensor_schema( + ParentChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARTITION_ID_CHANGES): sensor.sensor_schema( + PartitionIdChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + } +) + + +async def setup_conf(config: dict, key: str): + if conf := config.get(key): + var = await sensor.new_sensor(conf) + await cg.register_component(var, conf) + + +async def to_code(config): + await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) + await setup_conf(config, CONF_PARENT_LAST_RSSI) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_OUT) + await setup_conf(config, CONF_TX_TOTAL) + await setup_conf(config, CONF_TX_RETRIES) + await setup_conf(config, CONF_TX_ERR_CCA) + await setup_conf(config, CONF_TX_ERR_ABORT) + await setup_conf(config, CONF_RX_TOTAL) + await setup_conf(config, CONF_RX_ERR_FCS) + await setup_conf(config, CONF_ATTACH_ATTEMPTS) + await setup_conf(config, CONF_PARENT_CHANGES) + await setup_conf(config, CONF_PARTITION_ID_CHANGES) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8296410f2f..2d4de52e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -151,7 +151,7 @@ async def final_step(): cg.add_define("USE_OTA_STATE_LISTENER") -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_filter_backend_source_files = filter_source_files_from_platform( { "ota_backend_esp_idf.cpp": { PlatformFramework.ESP32_ARDUINO, @@ -167,3 +167,19 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) + + +def FILTER_SOURCE_FILES() -> list[str]: + files = _filter_backend_source_files() + # ota_signature_esp_idf.cpp implements multi-key OTA signature verification, + # compiled only when the esp32 component enables it (external RSA signed + # OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on + # ESP32/IDF, so this also excludes the file on every other platform. Filter + # it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened + # and parsed on every build. + if not any( + define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" + for define in CORE.defines + ): + files.append("ota_signature_esp_idf.cpp") + return files diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 01be46a518..aa93df60a5 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include +#include #include #ifdef USE_OTA_STATE_LISTENER @@ -78,6 +80,25 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +// The OTA backend method surface. Exactly one backend exists per build, +// selected in ota_backend_factory.h where this concept is asserted on +// make_ota_backend()'s return type. Semantics beyond the signatures: +// - begin: prepare for an image of the given size; ota_type defaults to an +// app update, so both call forms must be accepted. +// - set_update_md5: expected digest of the incoming image, hex string. +// - write: consume the next chunk; end: finalize and mark bootable. +// - abort: safe to call in any state, including after end(). +template +concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) { + { backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as; + { backend.begin(image_size) } -> std::same_as; + backend.set_update_md5(md5); + { backend.write(data, len) } -> std::same_as; + { backend.end() } -> std::same_as; + backend.abort(); + { backend.supports_compression() } -> std::same_as; +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 8fd21f42bd..108605e4c9 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -144,6 +144,9 @@ OTAResponseTypes IDFOTABackend::end() { } } #ifdef USE_OTA_PARTITIONS + // A partition-table update carries an MD5 (checked by IDF), not a Secure Boot + // signature, and only re-points boot at an already-installed app -- so it is + // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { return this->update_partition_table(); } @@ -162,6 +165,16 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // IDF's built-in on-update check is disabled for this scheme (it only + // matches the incoming image's first signature block against the running + // app's first). Verify here against every key the running app trusts, so + // rotation and backup keys are accepted. Leaving the boot partition + // unchanged means a rejected image never boots. + if (!this->verify_signed_image_(this->partition_)) { + return OTA_RESPONSE_ERROR_SIGNATURE_INVALID; + } +#endif #ifdef USE_OTA_DOWNGRADE_PROTECTION // The image is written and (when signing is enabled) signature-verified by // esp_ota_end(), so its embedded project version can be trusted. Reject the diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index a49a5e34b3..9dffd5429e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -54,6 +54,11 @@ class IDFOTABackend final { #endif private: +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // Accept an image signed by any key the running app trusts (up to 3 blocks), + // so rotation and backup keys work. Fails closed. Covers app and bootloader. + bool verify_signed_image_(const esp_partition_t *incoming); +#endif // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index c543983d8d..82d001ed9e 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -17,11 +17,22 @@ #else // Stub for static analysis when no platform is defined namespace esphome::ota { -struct StubOTABackend {}; +struct StubOTABackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNKNOWN; + } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; } + OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; } + void abort() {} + bool supports_compression() { return false; } +}; std::unique_ptr make_ota_backend(); } // namespace esphome::ota #endif namespace esphome::ota { using OTABackendPtr = decltype(make_ota_backend()); +static_assert(OTABackendContract, + "The platform's OTA backend is missing part of the backend surface (ota_backend.h)"); } // namespace esphome::ota diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 062e4d0811..264218a3df 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -94,6 +94,18 @@ OTAResponseTypes IDFOTABackend::finalize_bootloader_update_(esp_err_t ota_end_er if (ota_end_err != ESP_OK) { return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; } +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY + // The new bootloader is staged in partition_. IDF never signature-checks a + // bootloader image in this software-signed config -- esp_image_verify() skips + // it when is_bootloader() is true -- so without this a bootloader OTA would + // install unverified. Require a trusted signature, which means the bootloader + // must be externally signed and 4 KiB-padded, the same as the app. + if (!this->verify_signed_image_(this->partition_)) { + ESP_LOGE(TAG, "Bootloader image is not signed by a trusted key; a bootloader OTA requires an " + "externally-signed, 4 KiB-padded bootloader.bin"); + return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; + } +#endif esp_bootloader_desc_t bootloader_desc; esp_err_t desc_err = esp_ota_get_bootloader_description(this->partition_, &bootloader_desc); #ifdef USE_ESP32_SRAM1_AS_IRAM diff --git a/esphome/components/ota/ota_rsa_der.h b/esphome/components/ota/ota_rsa_der.h new file mode 100644 index 0000000000..1ec4e3cc62 --- /dev/null +++ b/esphome/components/ota/ota_rsa_der.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ota { + +// The PSA Crypto API imports an RSA public key as a DER RSAPublicKey +// (RFC 3279 2.3.1), not as raw bignums: +// +// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } +// +// The Secure Boot v2 signature block stores the modulus and exponent raw, so +// they are wrapped here. Only RSA-3072 exists in that format, which fixes both +// headers: a 3072-bit modulus always has its top bit set, so its INTEGER is +// always tag + 2-byte length (0x181 = 385) + the sign pad; and the SEQUENCE +// body is always 392..396 bytes, so its header is always tag + 2-byte length. +// Only the exponent varies in width. +constexpr size_t RSA_3072_MODULUS_BYTES = 384; +constexpr uint8_t RSA_DER_MODULUS_PREFIX[] = {0x02, 0x82, 0x01, 0x81, 0x00}; +constexpr size_t RSA_DER_MODULUS_LEN = sizeof(RSA_DER_MODULUS_PREFIX) + RSA_3072_MODULUS_BYTES; // 389 +// 4-byte SEQUENCE header + modulus + the widest exponent INTEGER (tag, length, +// sign pad, 4 bytes). +constexpr size_t RSA_DER_PUBKEY_MAX = 4 + RSA_DER_MODULUS_LEN + 7; + +/// Wrap a raw RSA-3072 modulus and exponent as a DER RSAPublicKey. +/// +/// @param modulus_be Big-endian modulus, RSA_3072_MODULUS_BYTES long. +/// @param exponent_be Big-endian exponent, exponent_len bytes, leading zeros allowed. +/// Rejected if the significant bytes would not fit a short-form length. +/// @return the encoded length, or 0 if the exponent is zero or the buffer is too small. +inline size_t rsa_der_public_key(const uint8_t *modulus_be, const uint8_t *exponent_be, size_t exponent_len, + uint8_t *out, size_t out_len) { + // A DER INTEGER is signed: drop leading zero bytes, then prepend one back if + // the value would otherwise read as negative. + while (exponent_len > 0 && exponent_be[0] == 0x00) { + exponent_be++; + exponent_len--; + } + if (exponent_len == 0) { + return 0; // a zero exponent is not a usable key + } + const bool pad = (exponent_be[0] & 0x80) != 0; + const size_t exponent_content_len = exponent_len + (pad ? 1 : 0); + if (exponent_content_len > 0x7F) { + return 0; // would need a long-form length, which this encoder does not write + } + const size_t exponent_der_len = 2 + exponent_content_len; + const size_t body_len = RSA_DER_MODULUS_LEN + exponent_der_len; + const size_t total_len = 4 + body_len; + if (total_len > out_len) { + return 0; + } + + size_t i = 0; + out[i++] = 0x30; // SEQUENCE + out[i++] = 0x82; // 2-byte length follows + out[i++] = static_cast(body_len >> 8); + out[i++] = static_cast(body_len); + memcpy(out + i, RSA_DER_MODULUS_PREFIX, sizeof(RSA_DER_MODULUS_PREFIX)); + i += sizeof(RSA_DER_MODULUS_PREFIX); + memcpy(out + i, modulus_be, RSA_3072_MODULUS_BYTES); + i += RSA_3072_MODULUS_BYTES; + out[i++] = 0x02; // INTEGER + out[i++] = static_cast(exponent_content_len); + if (pad) { + out[i++] = 0x00; + } + memcpy(out + i, exponent_be, exponent_len); + return total_len; +} + +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp new file mode 100644 index 0000000000..b327988d2d --- /dev/null +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -0,0 +1,297 @@ +#ifdef USE_ESP32 +#include "ota_backend_esp_idf.h" + +#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +#include "esphome/components/watchdog/watchdog.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) made the legacy mbedtls_rsa_*/mbedtls_sha256_* headers +// private. Use the PSA Crypto API instead, like the sha256 component does. PSA +// crypto is auto-initialized by ESP-IDF at startup (esp_psa_crypto_init.c, +// priority 104), so no psa_crypto_init() call is needed. +#define USE_OTA_SIG_PSA +#include "ota_rsa_der.h" +#include +#else +#include +#include +#include +#endif + +namespace esphome::ota { + +static const char *const TAG = "ota.idf"; + +// Route the "Signature check: " prefix (and its per-block form) through one +// shared format string each, so the prefix is pooled once by the linker instead +// of duplicated at every call site. The level macro is forwarded so compile-time +// log-level stripping still applies. +#define OTA_IDF_SIG_LOG(level, msg) level(TAG, "Signature check: %s", msg) +#define OTA_IDF_SIG_LOG_BLOCK(level, i, msg) level(TAG, "Signature check: block %zu: %s", static_cast(i), msg) + +// Secure Boot v2 RSA-3072 signature block, as written by espsecure and stored +// in the 4 KiB sector following the (4 KiB-padded) app image. All bignum +// fields are byte-reversed to little-endian for the RSA accelerator; software +// verification reverses them back. See the espsecure "; +constexpr uint8_t TRUSTED_KEY_DIGESTS[OTA_TRUSTED_KEY_COUNT][SHA256_BYTES] = OTA_TRUSTED_KEY_DIGESTS; + +// A block is structurally valid if the magic, version, and CRC all check out. +// The CRC covers everything before it and uses the same ROM routine the +// bootloader validates the block with, so the check matches byte-for-byte. +bool block_is_valid(const uint8_t *block) { + if (block[0] != SIG_BLOCK_MAGIC || block[1] != SIG_BLOCK_VERSION_RSA) { + return false; + } + uint32_t stored_crc; + memcpy(&stored_crc, block + OFFSET_CRC, sizeof(stored_crc)); + return esp_rom_crc32_le(0, block, OFFSET_CRC) == stored_crc; +} + +bool key_digest_of(const uint8_t *block, KeyDigest &out) { +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + return psa_hash_compute(PSA_ALG_SHA_256, block + OFFSET_KEY, KEY_REGION_LEN, out.data(), out.size(), &out_len) == + PSA_SUCCESS && + out_len == out.size(); +#else + return mbedtls_sha256(block + OFFSET_KEY, KEY_REGION_LEN, out.data(), /*is224=*/0) == 0; +#endif +} + +// The offset of the signature sector: the app length rounded up to 4 KiB. +bool signature_sector_offset(const esp_partition_t *part, size_t &out_offset) { + esp_partition_pos_t pos{.offset = part->address, .size = part->size}; + esp_image_metadata_t meta{}; + if (esp_image_get_metadata(&pos, &meta) != ESP_OK) { + return false; + } + // Bound the image length before rounding up so a crafted header can't + // overflow the addition; the image plus its signature sector must fit. + if (meta.image_len > part->size) { + return false; + } + out_offset = (meta.image_len + SIG_SECTOR_ALIGN - 1) & ~(SIG_SECTOR_ALIGN - 1); + return out_offset + SIG_BLOCK_SIZE <= part->size; +} + +// SHA-256 over the 4 KiB-padded image, i.e. everything the signature covers. +// Returns false on a read or hash error so a hash failure is not later +// misreported as a signature mismatch. +bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t *out) { +#ifdef USE_OTA_SIG_PSA + psa_hash_operation_t ctx = PSA_HASH_OPERATION_INIT; + bool ok = psa_hash_setup(&ctx, PSA_ALG_SHA_256) == PSA_SUCCESS; +#else + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + bool ok = mbedtls_sha256_starts(&ctx, /*is224=*/0) == 0; +#endif + uint8_t buf[512]; + for (size_t off = 0; ok && off < image_padded_len; off += sizeof(buf)) { + size_t chunk = std::min(sizeof(buf), image_padded_len - off); + if (esp_partition_read(part, off, buf, chunk) != ESP_OK) { + ok = false; + break; + } +#ifdef USE_OTA_SIG_PSA + ok = psa_hash_update(&ctx, buf, chunk) == PSA_SUCCESS; +#else + ok = mbedtls_sha256_update(&ctx, buf, chunk) == 0; +#endif + } +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + if (ok) { + ok = psa_hash_finish(&ctx, out, SHA256_BYTES, &out_len) == PSA_SUCCESS && out_len == SHA256_BYTES; + } + // A no-op once the operation has been finished + psa_hash_abort(&ctx); +#else + if (ok) { + ok = mbedtls_sha256_finish(&ctx, out) == 0; + } + mbedtls_sha256_free(&ctx); +#endif + return ok; +} + +// Verify one RSA-PSS-3072-SHA256 signature block over the image digest. The +// block's modulus and signature are stored little-endian; reverse them in place +// -- block is the caller's scratch buffer, overwritten on the next iteration -- +// rather than stacking a second 384-byte copy of each bignum. + +bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { + std::reverse(block + OFFSET_MODULUS, block + OFFSET_MODULUS + RSA_3072_BYTES); + std::reverse(block + OFFSET_SIGNATURE, block + OFFSET_SIGNATURE + RSA_3072_BYTES); + uint32_t exponent_le; + memcpy(&exponent_le, block + OFFSET_EXPONENT, sizeof(exponent_le)); + uint8_t exponent_be[4] = {static_cast(exponent_le >> 24), static_cast(exponent_le >> 16), + static_cast(exponent_le >> 8), static_cast(exponent_le)}; + +#ifdef USE_OTA_SIG_PSA + static_assert(RSA_3072_BYTES == RSA_3072_MODULUS_BYTES, "signature block and DER encoder disagree on modulus size"); + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t der_len = rsa_der_public_key(block + OFFSET_MODULUS, exponent_be, sizeof(exponent_be), der, sizeof(der)); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_RSA_PUBLIC_KEY); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_VERIFY_HASH); + // ANY_SALT preserves the salt-length acceptance of mbedtls_rsa_rsassa_pss_verify(), + // which this replaces; espsecure signs with a 32-byte salt. TF-PSA-Crypto defines + // PSA_WANT_ALG_RSA_PSS_ANY_SALT from PSA_WANT_ALG_RSA_PSS, which IDF enables. + psa_set_key_algorithm(&attr, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256)); + mbedtls_svc_key_id_t key = MBEDTLS_SVC_KEY_ID_INIT; + const bool key_ok = der_len != 0 && psa_import_key(&attr, der, der_len, &key) == PSA_SUCCESS; +#else + mbedtls_rsa_context rsa; + mbedtls_rsa_init(&rsa); + const bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, + nullptr, 0, exponent_be, sizeof(exponent_be)) == 0 && + mbedtls_rsa_complete(&rsa) == 0 && + mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; +#endif + bool verified = false; + if (!key_ok) { + // A setup/allocation failure (e.g. OOM right after the download) is not a + // signature mismatch -- log it distinctly so it isn't read as "wrong key". + OTA_IDF_SIG_LOG(ESP_LOGE, "RSA key setup failed"); + } else { +#ifdef USE_OTA_SIG_PSA + verified = psa_verify_hash(key, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256), digest, SHA256_BYTES, + block + OFFSET_SIGNATURE, RSA_3072_BYTES) == PSA_SUCCESS; +#else + verified = + mbedtls_rsa_rsassa_pss_verify(&rsa, MBEDTLS_MD_SHA256, SHA256_BYTES, digest, block + OFFSET_SIGNATURE) == 0; +#endif + } +#ifdef USE_OTA_SIG_PSA + if (key_ok) { + psa_destroy_key(key); + } +#else + mbedtls_rsa_free(&rsa); +#endif + return verified; +} + +} // namespace + +bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { + // Verification re-hashes the full image (after esp_ota_end already did one + // pass), which can approach the task WDT budget on a large app. Extend it for + // the duration, mirroring the erase budget in begin(). + const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10; + watchdog::WatchdogManager watchdog(verify_budget_ms); + + size_t incoming_sector; + if (!signature_sector_offset(incoming, incoming_sector)) { + OTA_IDF_SIG_LOG(ESP_LOGE, "cannot locate incoming signature sector"); + return false; + } + uint8_t digest[SHA256_BYTES]; + if (!image_digest(incoming, incoming_sector, digest)) { + OTA_IDF_SIG_LOG(ESP_LOGE, "cannot hash incoming image"); + return false; + } + + // Accept if any incoming block is signed by a compiled-in trusted key AND its + // signature verifies over the image. Iterating all blocks (not just the + // first) is the whole point -- it lets a bridge/backup key in a later block + // be the match. The trust check is against the immutable compiled-in set, so + // extra (self-signed) blocks an attacker appends carry keys we simply ignore. + // Heap-allocate the 1216-byte block for the duration of verification: this + // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer + // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens + // a thin margin. One short-lived allocation right before reboot is not the + // fragmentation pattern the project guards against. nothrow so an OOM here + // fails closed like every other error path, rather than aborting. + std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + if (!block) { + OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); + return false; + } + bool any_valid_block = false; + for (size_t i = 0; i < SIG_BLOCK_MAX_COUNT; i++) { + size_t off = incoming_sector + i * SIG_BLOCK_SIZE; + if (off + SIG_BLOCK_SIZE > incoming->size) { + break; // partition has no room for another block; done scanning + } + // A read fault is not "no trusted key" -- fail closed with a distinct error. + if (esp_partition_read(incoming, off, block.get(), SIG_BLOCK_SIZE) != ESP_OK) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "unreadable"); + return false; + } + if (!block_is_valid(block.get())) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "absent or malformed"); + continue; + } + any_valid_block = true; + KeyDigest incoming_key; + if (!key_digest_of(block.get(), incoming_key)) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "key hash failed"); + return false; + } + bool trusted_key = false; + for (const auto &trusted : TRUSTED_KEY_DIGESTS) { + if (memcmp(incoming_key.data(), trusted, SHA256_BYTES) == 0) { + trusted_key = true; + break; + } + } + if (!trusted_key) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "signed by an untrusted key"); + continue; + } + if (rsa_pss_verify(block.get(), digest)) { + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "verified with a trusted key"); + return true; + } + OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "trusted key failed to verify"); + } + + // Separate "not signed at all" from "signed by an untrusted key" -- the former + // otherwise reads as the latter on a device that only logs at INFO. + if (!any_valid_block) { + OTA_IDF_SIG_LOG(ESP_LOGE, "image has no signature block"); + } else { + OTA_IDF_SIG_LOG(ESP_LOGE, "no trusted key produced a valid signature"); + } + return false; +} + +} // namespace esphome::ota + +#endif // USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +#endif // USE_ESP32 diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 6cb9d5f03a..4d1814ac7a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -201,6 +201,14 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: if base_path := config.get(CONF_PATH): repo_dir = repo_dir / base_path + # Deferred import: keeps esphome.bundle off the device builder's + # startup path, since packages is loaded on every config parse. + from esphome.bundle import add_secret_scan_dir + + # Register the path-narrowed dir, not repo_root, so example configs + # elsewhere in the repo do not widen the shipped secrets. + add_secret_scan_dir(repo_dir) + for file in config[CONF_FILES]: if isinstance(file, str): files.append({CONF_PATH: file, CONF_VARS: {}}) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 4293dffb15..7beb13ca31 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -4,12 +4,12 @@ import hashlib import logging import esphome.codegen as cg -from esphome.components.api import CONF_ENCRYPTION from esphome.components.binary_sensor import BinarySensor from esphome.components.sensor import Sensor import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, + CONF_ENCRYPTION, CONF_ID, CONF_INTERNAL, CONF_KEY, diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index cc1541ce80..0a69160fc1 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,6 +3,7 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL +from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -35,6 +36,11 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) +FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( + "prometheus builds metric labels from the entity object_id, " + "which is the name converted to ASCII" +) + async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 7a6be40d6c..64b8974901 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -1,16 +1,17 @@ -#include "pvvx_display.h" -#include "esphome/components/esp32_ble/ble_uuid.h" -#include "esphome/core/log.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 +#include "pvvx_display.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { static const char *const TAG = "display.pvvx_mithermometer"; void PVVXDisplay::dump_config() { - char service_buf[esp32_ble::UUID_STR_LEN]; - char char_buf[esp32_ble::UUID_STR_LEN]; + char service_buf[ble_device_base::UUID_STR_LEN]; + char char_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGCONFIG(TAG, "PVVX MiThermometer display:\n" " MAC address : %s\n" @@ -188,4 +189,4 @@ void PVVXDisplay::sync_time_() { } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index d231111c58..c3f6028423 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -1,13 +1,16 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" #include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/display/display.h" #include -#ifdef USE_ESP32 #include #ifdef USE_TIME #include "esphome/components/time/real_time_clock.h" @@ -121,14 +124,13 @@ class PVVXDisplay final : public ble_client::BLEClientNode, public PollingCompon uint16_t char_handle_ = 0; bool connection_established_ = false; - esp32_ble_tracker::ESPBTUUID service_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); - esp32_ble_tracker::ESPBTUUID char_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID service_uuid_ = + ble_device_base::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID char_uuid_ = ble_device_base::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); pvvx_writer_t writer_{}; }; } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index f674fc3694..9141d12b16 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,8 +1,6 @@ #include "pvvx_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { static const char *const TAG = "pvvx_mithermometer"; @@ -15,7 +13,7 @@ void PVVXMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool PVVXMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return success; } -optional PVVXMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional PVVXMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -140,5 +138,3 @@ bool PVVXMiThermometer::report_results_(const optional &result, con } } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index 382e41d210..7a2244207b 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPB uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index da57c65341..ee5b19ea77 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@pasiz"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] pvvx_mithermometer_ns = cg.esphome_ns.namespace("pvvx_mithermometer") PVVXMiThermometer = pvvx_mithermometer_ns.class_( - "PVVXMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "PVVXMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("pvvx_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(PVVXMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index d36e5d0250..d817888922 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -5,11 +5,11 @@ namespace esphome::pzemac { static const char *const TAG = "pzemac"; -static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers -void PZEMAC::on_modbus_data(const std::vector &data) { +void PZEMAC::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < 20) { ESP_LOGW(TAG, "Invalid size for PZEM AC!"); return; @@ -61,7 +61,7 @@ void PZEMAC::on_modbus_data(const std::vector &data) { this->power_factor_sensor_->publish_state(power_factor); } -void PZEMAC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, PZEM_REGISTER_COUNT); } +void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); } void PZEMAC::dump_config() { ESP_LOGCONFIG(TAG, "PZEMAC:\n" @@ -76,10 +76,8 @@ void PZEMAC::dump_config() { } void PZEMAC::reset_energy_() { - std::vector cmd; - cmd.push_back(this->address_); - cmd.push_back(PZEM_CMD_RESET_ENERGY); - this->send_raw(cmd); + const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; + this->queue_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index a3ad7e1167..171212d3ee 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -5,7 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::pzemac { @@ -22,7 +22,7 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 6ded9b3a34..926ad83f09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -5,11 +5,11 @@ namespace esphome::pzemdc { static const char *const TAG = "pzemdc"; -static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers -void PZEMDC::on_modbus_data(const std::vector &data) { +void PZEMDC::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < 16) { ESP_LOGW(TAG, "Invalid size for PZEM DC!"); return; @@ -51,7 +51,7 @@ void PZEMDC::on_modbus_data(const std::vector &data) { this->energy_sensor_->publish_state(energy); } -void PZEMDC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, 8); } +void PZEMDC::update() { this->read_input_registers(0, 8); } void PZEMDC::dump_config() { ESP_LOGCONFIG(TAG, "PZEMDC:\n" @@ -64,10 +64,8 @@ void PZEMDC::dump_config() { } void PZEMDC::reset_energy() { - std::vector cmd; - cmd.push_back(this->address_); - cmd.push_back(PZEM_CMD_RESET_ENERGY); - this->send_raw(cmd); + const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; + this->queue_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 7d14a5ed4b..b7657608e6 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -5,7 +5,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::pzemdc { @@ -18,7 +18,7 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3e0a905737..fe6c6a9cb5 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,12 +99,8 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData 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()); + api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), + &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 99daef30e5..2ba9d59d4c 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeffeb3"] radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") RadonEyeListener = radon_eye_ble_ns.class_( - "RadonEyeListener", esp32_ble_tracker.ESPBTDeviceListener + "RadonEyeListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RadonEyeListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("radon_eye_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RadonEyeListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 7e7263d73f..9ff279cab9 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -2,13 +2,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::radon_eye_ble { static const char *const TAG = "radon_eye_ble"; -bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RadonEyeListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Radon Eye devices have names starting with "FR:" if (device.get_name().starts_with("FR:")) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -19,5 +17,3 @@ bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::radon_eye_ble - -#endif diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index 30e3ccc1ea..f9c8aa377d 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::radon_eye_ble { -class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::radon_eye_ble - -#endif diff --git a/esphome/components/rc522_i2c/__init__.py b/esphome/components/rc522_i2c/__init__.py index 7c42a12429..c67615e2d8 100644 --- a/esphome/components/rc522_i2c/__init__.py +++ b/esphome/components/rc522_i2c/__init__.py @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(RC522I2C), } - ).extend(i2c.i2c_device_schema(0x2C)) + ).extend(i2c.i2c_device_schema(0x28)) ) diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index 4b4fcfd4e4..953d99c2da 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -19,8 +20,6 @@ from . import CONF_RD03D_ID, RD03DComponent DEPENDENCIES = ["rd03d"] -CONF_TARGET_COUNT = "target_count" - MAX_TARGETS = 3 UNIT_MILLIMETER_PER_SECOND = "mm/s" diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index cbf82e6f44..19b8549f75 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2012,7 +2012,14 @@ HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_p HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) HAIER_SCHEMA = cv.Schema( { - cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=13)), + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], + cv.Any( + cv.Length(min=8, max=8), + cv.Length(min=13, max=13), + msg="must be a list of length 8 or 13", + ), + ), } ) diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index fa4cec773f..8801f1049a 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -11,9 +11,12 @@ constexpr uint32_t HEADER_HIGH_US = 4400; constexpr uint32_t BIT_MARK_US = 540; constexpr uint32_t BIT_ONE_SPACE_US = 1650; constexpr uint32_t BIT_ZERO_SPACE_US = 580; -constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE = 112; +// 8 bytes + checksum +constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE_SHORT = 72; +// 13 bytes + checksum +constexpr unsigned int HAIER_IR_PACKET_BIT_SIZE_LONG = 112; // Max data bytes in packet (excluding checksum) -constexpr size_t HAIER_MAX_DATA_BYTES = (HAIER_IR_PACKET_BIT_SIZE / 8); +constexpr size_t HAIER_MAX_DATA_BYTES = HAIER_IR_PACKET_BIT_SIZE_LONG / 8 - 1; void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { @@ -28,7 +31,7 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((data.data.size() + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); @@ -50,11 +53,16 @@ optional HaierProtocol::decode(RemoteReceiveData src) { return {}; } size_t size = src.size() - src.get_index() - 1; - if (size < HAIER_IR_PACKET_BIT_SIZE * 2) + if (size >= HAIER_IR_PACKET_BIT_SIZE_LONG * 2) { + size = HAIER_IR_PACKET_BIT_SIZE_LONG * 2; + } else if (size >= HAIER_IR_PACKET_BIT_SIZE_SHORT * 2) { + size = HAIER_IR_PACKET_BIT_SIZE_SHORT * 2; + } else { return {}; - size = HAIER_IR_PACKET_BIT_SIZE * 2; + } uint8_t checksum = 0; HaierData out; + out.data.reserve(size / 16 - 1); while (size > 0) { uint8_t data = 0; for (uint8_t mask = 0x80; mask != 0; mask >>= 1) { diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index fad9d3d25b..87e78003ed 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -33,6 +33,7 @@ from esphome.core import ( ) from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.types import ConfigType from . import boards @@ -172,15 +173,10 @@ def get_download_types(storage_json): def _format_framework_arduino_version(ver: cv.Version) -> str: - # The most recent releases have not been uploaded to platformio so grabbing them directly from - # the GitHub release is one path forward for now. + # The framework-arduinopico package is no longer published to the PlatformIO + # registry, so install the framework straight from the GitHub release return f"https://github.com/earlephilhower/arduino-pico/releases/download/{ver}/rp2040-{ver}.zip" - # format the given arduino (https://github.com/earlephilhower/arduino-pico/releases) version to - # a PIO earlephilhower/framework-arduinopico value - # List of package versions: https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico - # return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - def _parse_platform_version(value): value = cv.string(value) @@ -197,19 +193,20 @@ def _parse_platform_version(value): # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" +# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain +# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 1), None), + "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 0, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } @@ -338,7 +335,7 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) + cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"]) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") @@ -354,6 +351,11 @@ async def to_code(config): ], ) + # newlib-nano is the default libc for the arduino-pico toolchain and its + # printf silently drops %f unless _printf_float is force-linked. Components + # use %f widely in logging, so pull it in. + cg.add_build_flag("-Wl,-u,_printf_float") + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~9.2 KB). See printf_stubs.cpp for implementation. if config.get(CONF_ENABLE_FULL_PRINTF): @@ -386,6 +388,74 @@ async def to_code(config): _configure_lwip() +# --- lwIP sizing. See _configure_lwip() for the platform comparison table. --- + +# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. +# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. +LWIP_TCP_SND_BUF = "(4*TCP_MSS)" + +# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. +LWIP_TCP_WND = "(4*TCP_MSS)" + +# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer +# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS +# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 +LWIP_TCP_SND_QUEUELEN = 17 + +# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB +# queue length — lwIP's sanity check only demands >=, the floor for a single +# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured +# at 20 bytes per entry, so under 700 bytes total. +LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN + +# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. +# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path +# copies into PBUF_RAM out of MEM_SIZE. +LWIP_PBUF_POOL_SIZE = 16 + +# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing +# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full +# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 + +# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB. +# +# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c +# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75% +# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well +# before the total does — hence the intermittent failures. With rp2's +# max_connections of 4, a third sender has nothing left. +# +# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards). +# Must stay under 64000 or lwIP widens mem_size_t to u32_t. +LWIP_MEM_SIZE = 32768 + + +def build_lwip_defines( + tcp_sockets: int, udp_sockets: int, listening_tcp: int +) -> dict[str, str]: + """Render the lwIP override values for the Jinja2 template. + + The template uses #include_next to chain to the framework's original + lwipopts.h, then #undef/#define only these. Split out from + _configure_lwip() so the values that actually reach the generated header + can be checked without standing up CORE. + + Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The + static pools are the only IRQ-safe allocator on this platform, so the fix + is to size them correctly rather than to make them dynamic. + """ + return { + "TCP_SND_BUF": LWIP_TCP_SND_BUF, + "TCP_WND": LWIP_TCP_WND, + "TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN), + "MEM_SIZE": str(LWIP_MEM_SIZE), + "MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG), + "PBUF_POOL_SIZE": str(LWIP_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), + } + + def _configure_lwip() -> None: """Configure lwIP options for RP2040 by generating a custom lwipopts.h. @@ -405,25 +475,36 @@ def _configure_lwip() -> None: ──────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + TCP_SND_QUEUELEN ~8 17 32 17 MEM_LIBC_MALLOC 1 1 0 0* MEMP_MEM_MALLOC 1 1 0 0** - MEM_SIZE N/A*** N/A*** 16KB 16KB + MEM_SIZE N/A*** N/A*** 16KB 32KB PBUF_POOL_SIZE 10 16 24 16 - MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_SEG 10 16 32 34**** MEMP_NUM_TCP_PCB 5 16 5 dynamic - MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** 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. + ** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc() + pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes + its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0), + so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c + calls mem_malloc() outside the guard anyway. RX pbufs would then be + allocated from the pendsv IRQ on the same unguarded free list the main + loop uses for tcp_write(). Tried on hardware: faults within seconds on + CYW43. Ethernet survives only because it polls from the main loop. + *** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from + the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps + (MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are + 0 here, so ours are hard limits; don't copy their numbers. + **** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so + sizing it to the per-PCB value lets one busy connection drain it for + every other. 2× covers two PCBs; MEM_SIZE is the real limit past that. + ***** 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. """ @@ -442,48 +523,7 @@ def _configure_lwip() -> None: # 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), - } + lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp) # Store for copy_files() to generate the header CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines @@ -498,7 +538,8 @@ def _configure_lwip() -> None: 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]", + "Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + LWIP_MEM_SIZE, tcp_sockets, tcp_min, sc.tcp_details, @@ -519,7 +560,7 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment + from jinja2 import Environment, StrictUndefined lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: @@ -532,7 +573,10 @@ def _generate_lwipopts_h() -> None: template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( encoding="utf-8" ) - jinja_env = Environment(keep_trailing_newline=True) + # StrictUndefined: a placeholder with no value would otherwise render + # empty, emitting a bare #define that compiles and silently means + # something else in lwIP's config. + jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined) template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) @@ -594,6 +638,7 @@ def copy_files(): inject_lwip_file, CORE.relative_build_path("inject_lwip_include.py"), ) + copy_ccache_script() _generate_lwipopts_h() if generate_pio_files(): path = CORE.relative_src_path("esphome.h") diff --git a/esphome/components/rp2/boards.jinja2 b/esphome/components/rp2/boards.jinja2 index 9223009c26..6e5e55d771 100644 --- a/esphome/components/rp2/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -14,6 +14,10 @@ RP2_BOARD_PINS = { {%- endfor %} } +# RP2350 boards carry a {{ rp2350_die_key | repr }} key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { {%- for name, info in boards %} {{ name | repr }}: { diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 94d0ebbb60..4b2f9769b0 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -133,9 +133,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 3, - "SCL1": 31, "SDA": 2, - "SDA1": 31, "SS": 13, "TX": 0, }, @@ -146,9 +144,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 3, - "SCL1": 31, "SDA": 2, - "SDA1": 31, "SS": 21, "TX": 0, }, @@ -464,9 +460,7 @@ RP2_BOARD_PINS = { "RX": 13, "SCK": 18, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 17, "TX": 12, }, @@ -477,9 +471,7 @@ RP2_BOARD_PINS = { "RX": 13, "SCK": 18, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 17, "TX": 12, }, @@ -509,14 +501,10 @@ RP2_BOARD_PINS = { "LED": 29, "MISO": 20, "MOSI": 19, - "RX": 31, "SCK": 22, "SCL": 17, - "SCL1": 31, "SDA": 16, - "SDA1": 31, "SS": 21, - "TX": 31, }, "cytron_maker_nano_rp2040": { "LED": 2, @@ -708,6 +696,19 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "ilabs_cpico_2350": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "ilabs_rpico32": { "MISO": 24, "MOSI": 23, @@ -941,31 +942,15 @@ RP2_BOARD_PINS = { "TX": 0, }, "pimoroni_plasma2040": {"LED": 16, "SCL": 21, "SDA": 20}, - "pimoroni_plasma2350": { - "LED": 16, - "MISO": 31, - "MOSI": 31, - "RX": 31, - "SCK": 31, - "SCL": 21, - "SCL1": 31, - "SDA": 20, - "SDA1": 31, - "SS": 31, - "TX": 31, - }, + "pimoroni_plasma2350": {"LED": 16, "SCL": 21, "SDA": 20}, "pimoroni_plasma2350w": { "LED": 16, "MISO": 24, "MOSI": 24, - "RX": 31, "SCK": 29, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 25, - "TX": 31, }, "pimoroni_servo2040": {"LED": 18, "SCL": 21, "SDA": 20}, "pimoroni_tiny2040": { @@ -1208,9 +1193,7 @@ RP2_BOARD_PINS = { "RX": 19, "SCK": 14, "SCL": 21, - "SCL1": 31, "SDA": 20, - "SDA1": 31, "SS": 13, "TX": 18, }, @@ -1256,9 +1239,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 22, "SCL": 17, - "SCL1": 31, "SDA": 16, - "SDA1": 31, "SS": 21, "TX": 0, }, @@ -1281,9 +1262,7 @@ RP2_BOARD_PINS = { "RX": 1, "SCK": 2, "SCL": 7, - "SCL1": 31, "SDA": 6, - "SDA1": 31, "SS": 9, "TX": 0, }, @@ -1513,6 +1492,19 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "weact_rp2350b": { + "LED": 25, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "wiznet_55rp20_evb_pico": { "LED": 19, "MISO": 2, @@ -1541,6 +1533,10 @@ RP2_BOARD_PINS = { }, } +# RP2350 boards carry a 'die' key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { "0xcb_helios": { "name": "0xCB Helios", @@ -1556,6 +1552,7 @@ BOARDS = { "name": "MyMakers RP2350B", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "MyRP_bot": { "name": "MyMakers RP2040", @@ -1595,12 +1592,14 @@ BOARDS = { "adafruit_feather_rp2350_adalogger": { "name": "Adafruit Feather RP2350 Adalogger", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "adafruit_feather_rp2350_hstx": { "name": "Adafruit Feather RP2350 HSTX", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "adafruit_feather_scorpio": { "name": "Adafruit Feather RP2040 SCORPIO", @@ -1626,6 +1625,7 @@ BOARDS = { "name": "Adafruit Fruit Jam RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_itsybitsy": { "name": "Adafruit ItsyBitsy RP2040", @@ -1651,6 +1651,7 @@ BOARDS = { "name": "Adafruit Metro RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_qtpy": { "name": "Adafruit QT Py RP2040", @@ -1770,17 +1771,20 @@ BOARDS = { "challenger_2350_bconnect": { "name": "iLabs Challenger 2350 BConnect", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "challenger_2350_nbiot": { "name": "iLabs Challenger 2350 NB-IoT", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "challenger_nb_2040_wifi": { "name": "iLabs Challenger NB 2040 WiFi", @@ -1795,7 +1799,8 @@ BOARDS = { "cytron_iriv_io_controller": { "name": "Cytron IRIV IO Controller", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "cytron_maker_nano_rp2040": { "name": "Cytron Maker Nano RP2040", @@ -1815,7 +1820,8 @@ BOARDS = { "cytron_motion_2350_pro": { "name": "Cytron Motion 2350 Pro", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "datanoisetv_picoadk": { "name": "DatanoiseTV PicoADK", @@ -1825,7 +1831,8 @@ BOARDS = { "datanoisetv_picoadk_v2": { "name": "DatanoiseTV PicoADK v2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "degz_suibo": { "name": "Degz Robotics Suibo RP2040", @@ -1871,12 +1878,19 @@ BOARDS = { "name": "Generic RP2350", "mcu": "rp2350", "max_pin": 47, + "die": None, }, "groundstudio_marble_pico": { "name": "GroundStudio Marble Pico", "mcu": "rp2040", "max_pin": 29, }, + "ilabs_cpico_2350": { + "name": "iLabs CPico 2350", + "mcu": "rp2350", + "max_pin": 29, + "die": "A", + }, "ilabs_rpico32": { "name": "iLabs RPICO32", "mcu": "rp2040", @@ -1891,6 +1905,7 @@ BOARDS = { "name": "Architeuthis Flux Jumperless V5", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "melopero_cookie_rp2040": { "name": "Melopero Cookie RP2040", @@ -1931,16 +1946,19 @@ BOARDS = { "name": "Olimex Pico2BB48", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xl": { "name": "Olimex Pico2XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xxl": { "name": "Olimex Pico2XXL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_rp2040pico30": { "name": "Olimex RP2040-Pico30", @@ -1948,12 +1966,12 @@ BOARDS = { "max_pin": 29, }, "pcbcupid_glyph_2040": { - "name": "PCBCupid Glyph 2040", + "name": "Pcbcupid GLYPH 2040", "mcu": "rp2040", "max_pin": 29, }, "pcbcupid_glyph_mini_2040": { - "name": "PCBCupid Glyph Mini 2040", + "name": "Pcbcupid GLYPH MINI 2040", "mcu": "rp2040", "max_pin": 29, }, @@ -1966,6 +1984,7 @@ BOARDS = { "name": "Pimoroni Explorer", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pga2040": { "name": "Pimoroni PGA2040", @@ -1976,16 +1995,19 @@ BOARDS = { "name": "Pimoroni PGA2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2": { "name": "Pimoroni PicoPlus2", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2w": { "name": "Pimoroni PicoPlus2W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -1997,12 +2019,14 @@ BOARDS = { "pimoroni_plasma2350": { "name": "Pimoroni Plasma2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "pimoroni_plasma2350w": { "name": "Pimoroni Plasma2350W", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", "wifi": True, }, "pimoroni_servo2040": { @@ -2018,7 +2042,8 @@ BOARDS = { "pimoroni_tiny2350": { "name": "Pimoroni Tiny2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "pintronix_pinmax": { "name": "Pintronix PinMax", @@ -2048,12 +2073,14 @@ BOARDS = { "rpipico2": { "name": "Raspberry Pi Pico 2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "rpipico2w": { "name": "Raspberry Pi Pico 2W", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2087,7 +2114,8 @@ BOARDS = { "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "silicognition_rp2040_shim": { "name": "Silicognition RP2040-Shim", @@ -2103,6 +2131,7 @@ BOARDS = { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "solderparty_rp2040_stamp": { @@ -2113,22 +2142,26 @@ BOARDS = { "solderparty_rp2350_stamp": { "name": "Solder Party RP2350 Stamp", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "solderparty_rp2350_stamp_xl": { "name": "Solder Party RP2350 Stamp XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "sparkfun_iotnode_lorawanrp2350": { "name": "SparkFun IoT Node LoRaWAN", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "sparkfun_iotredboard_rp2350": { "name": "SparkFun IoT RedBoard RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "sparkfun_micromodrp2040": { @@ -2144,7 +2177,8 @@ BOARDS = { "sparkfun_promicrorp2350": { "name": "SparkFun ProMicro RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "sparkfun_thingplusrp2040": { "name": "SparkFun Thing Plus RP2040", @@ -2154,7 +2188,8 @@ BOARDS = { "sparkfun_thingplusrp2350": { "name": "SparkFun Thing Plus RP2350", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2162,6 +2197,7 @@ BOARDS = { "name": "SparkFun XRP Controller", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -2235,29 +2271,40 @@ BOARDS = { "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "waveshare_rp2350_pizero": { "name": "Waveshare RP2350 PiZero", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "waveshare_rp2350_plus": { "name": "Waveshare RP2350 Plus", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "waveshare_rp2350b_plus_w": { "name": "Waveshare RP2350B Plus W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, + "weact_rp2350b": { + "name": "WeAct Studio RP2350B Core Board", + "mcu": "rp2350", + "max_pin": 47, + "die": "B", + }, "wiznet_5100s_evb_pico": { "name": "WIZnet W5100S-EVB-Pico", "mcu": "rp2040", @@ -2266,7 +2313,8 @@ BOARDS = { "wiznet_5100s_evb_pico2": { "name": "WIZnet W5100S-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "wiznet_5500_evb_pico": { "name": "WIZnet W5500-EVB-Pico", @@ -2276,7 +2324,8 @@ BOARDS = { "wiznet_5500_evb_pico2": { "name": "WIZnet W5500-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "wiznet_55rp20_evb_pico": { "name": "WIZnet W55RP20-EVB-Pico", @@ -2291,7 +2340,8 @@ BOARDS = { "wiznet_6300_evb_pico2": { "name": "WIZnet W6300-EVB-Pico2", "mcu": "rp2350", - "max_pin": 47, + "max_pin": 29, + "die": "A", }, "wiznet_wizfi360_evb_pico": { "name": "WIZnet WizFi360-EVB-Pico", diff --git a/esphome/components/rp2/core.h b/esphome/components/rp2/core.h index c53c3719eb..4ce9151d41 100644 --- a/esphome/components/rp2/core.h +++ b/esphome/components/rp2/core.h @@ -5,6 +5,7 @@ #include #include +// NOLINTNEXTLINE(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" unsigned long ulMainGetRunTimeCounterValue(); namespace esphome::rp2 {} // namespace esphome::rp2 diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 5553a24a60..a0fea21637 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -64,7 +64,7 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); +} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool crash_handler_has_data() { return s_crash_data.valid; } diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index 33eb1b3058..cd3f50182c 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -34,11 +34,27 @@ CYW43_GPIO_COUNT = 3 # Max GPIO pin per MCU (hardware specs from datasheets) MCU_MAX_PIN = { "rp2040": 29, # GPIO 0-29 - "rp2350": 47, # GPIO 0-47 (RP2350A) + "rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below) } DEFAULT_MAX_PIN = 29 +# The RP2350 currently comes in two die variants: RP2350A exposes GPIO 0-29, +# RP2350B GPIO 0-47. Variant headers declare the die via PICO_RP2350A +# (1 = A, 0 = B). +RP2350_DIE_A = "A" +RP2350_DIE_B = "B" +RP2350A_MAX_PIN = 29 +# Key recording the die letter on RP2350 board entries. Holds a letter rather +# than a bool so a future die can be named instead of forced into "not A". +RP2350_DIE_KEY = "die" PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") +# Accepts the literal forms seen in these headers: 1, (1), 1u, (1u) +RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)") +# Only PICO_RP2350A exists today. A define for any other die letter means the +# A/B assumption below no longer holds. The trailing \b keeps this from +# matching unrelated names such as PICO_RP2350_A2_SUPPORTED. +OTHER_DIE_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350(?!A\b)([B-Z])\b") +RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A" def parse_variant_pins(variant_dir: Path) -> dict[str, int]: @@ -56,6 +72,47 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]: return pins +def parse_variant_rp2350_die(variant_dir: Path) -> str | None: + """Return the RP2350 die letter the variant declares, or None if unknown. + + Generic boards leave the die a build-time menu choice (PICO_RP2350A is set + to a __PICO_RP2350A placeholder rather than a literal); those return None, + meaning the die is genuinely unknown at code generation time. They keep the + permissive B-die pin range, but that is a fallback and must not be recorded + as a known die. + + A missing or unrecognized define raises: silently treating it as B-die + would widen pin validation back to GPIO 47 on A-die boards, so a framework + bump that changes the header format must fail loudly here instead. The same + goes for a die beyond A and B: PICO_RP2350A is a yes/no answer about the A + die, so "not A" can only be read as B while A and B are the whole family. + """ + header = variant_dir / "pins_arduino.h" + text = header.read_text(encoding="utf-8") if header.exists() else "" + if other_die := OTHER_DIE_DEFINE_RE.search(text): + raise ValueError( + f"{header}: found a PICO_RP2350{other_die.group(1)} define; the " + "RP2350 gained a die beyond A and B, so PICO_RP2350A being 0 no " + "longer means the B die" + ) + match = RP2350A_DEFINE_RE.search(text) + if match is None: + raise ValueError( + f"{header}: no PICO_RP2350A define found; cannot classify the " + "RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" + ) + value = match.group(1) + if value == RP2350A_MENU_PLACEHOLDER: + return None + literal = value.strip("()u") + if not literal.isdigit(): + raise ValueError( + f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot " + "classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" + ) + return RP2350_DIE_A if int(literal) == 1 else RP2350_DIE_B + + def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: """Load all board definitions and return (board_pins, boards) dicts.""" json_dir = arduino_pico_path / "tools" / "json" @@ -64,6 +121,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: board_pins = {} boards = {} variant_pins_cache: dict[str, dict[str, int]] = {} + variant_die_cache: dict[str, str | None] = {} for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem @@ -81,11 +139,26 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: extra_flags = build.get("extra_flags", "") has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags + max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN) + die: str | None = None + if mcu == "rp2350": + if variant not in variant_die_cache: + variant_die_cache[variant] = parse_variant_rp2350_die( + variants_dir / variant + ) + die = variant_die_cache[variant] + if die == RP2350_DIE_A: + max_pin = RP2350A_MAX_PIN + board_entry: dict = { "name": display_name, "mcu": mcu, - "max_pin": MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN), + "max_pin": max_pin, } + if mcu == "rp2350": + # Recorded explicitly because max_pin cannot express the die: + # 29 also means RP2040, and 47 also means "die not known yet". + board_entry[RP2350_DIE_KEY] = die if has_wifi: board_entry["wifi"] = True boards[board_name] = board_entry @@ -168,6 +241,7 @@ def generate(arduino_pico_path: Path) -> str: cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, + rp2350_die_key=RP2350_DIE_KEY, board_pins=sorted(board_pins.items()), boards=sorted(boards.items()), ) diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 28535cacbb..ac1467e5e6 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -7,6 +7,7 @@ #include "crash_handler.h" #endif +#include "hardware/clocks.h" #include "hardware/watchdog.h" // Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. @@ -20,8 +21,7 @@ namespace esphome { // arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); - while (1) { - continue; + while (true) { } } @@ -34,7 +34,8 @@ void arch_init() { #endif } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } +// clock_get_hz(clk_sys) is the SDK query for the current system clock frequency in Hz. +uint32_t arch_get_cpu_freq_hz() { return clock_get_hz(clk_sys); } } // namespace esphome diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index b16f31d797..ec46937bab 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -17,13 +17,13 @@ extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) -// Forward decl from . +// Forward decls from and the pico-sdk / FreeRTOS port for the +// inline arch_* wrappers below. +// NOLINTBEGIN(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) extern "C" uint64_t time_us_64(void); - -// Forward decls from pico-sdk / FreeRTOS port for the inline arch_* -// wrappers below. extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); +// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) namespace esphome::rp2 {} diff --git a/esphome/components/rp2/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja index 36d7d4da14..2da4f467a9 100644 --- a/esphome/components/rp2/lwipopts.h.jinja +++ b/esphome/components/rp2/lwipopts.h.jinja @@ -20,13 +20,24 @@ #undef TCP_WND #define TCP_WND {{ TCP_WND }} -// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32 #undef TCP_SND_QUEUELEN #define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} +// Segment pool: global across every PCB, so it is sized above the per-PCB +// queue length rather than equal to it. lwIP's sanity check only requires +// >= TCP_SND_QUEUELEN, which is the floor for a single connection. #undef MEMP_NUM_TCP_SEG #define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} +// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into. +// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB +// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at +// 75%, and mem.c is first-fit, so the largest contiguous run ran out well +// before the total did. +#undef MEM_SIZE +#define MEM_SIZE {{ MEM_SIZE }} + // Packet buffer pool: 16 matches ESP32 (down from 24) #undef PBUF_POOL_SIZE #define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} diff --git a/esphome/components/rp2/preferences.cpp b/esphome/components/rp2/preferences.cpp index 778ce070a9..d1e0bc555f 100644 --- a/esphome/components/rp2/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -26,6 +26,7 @@ static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avo // No preference can exceed the total flash storage, so stack buffer covers all cases. static constexpr size_t PREF_MAX_BUFFER_SIZE = RP2040_FLASH_STORAGE_SIZE; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern "C" uint8_t _EEPROM_start; template uint8_t calculate_crc(It first, It last, uint32_t type) { @@ -38,9 +39,9 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { } bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; memcpy(buffer, data, len); buffer[len] = calculate_crc(buffer, buffer + len, this->type); @@ -59,9 +60,9 @@ bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { } bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { - const size_t buffer_size = len + 1; - if (buffer_size > PREF_MAX_BUFFER_SIZE) + if (len >= PREF_MAX_BUFFER_SIZE) return false; + const size_t buffer_size = len + 1; uint8_t buffer[PREF_MAX_BUFFER_SIZE]; for (size_t i = 0; i < buffer_size; i++) { diff --git a/esphome/components/rp2/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp index bf03565f30..47cf30b263 100644 --- a/esphome/components/rp2/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -33,8 +33,8 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { if (write_len >= PRINTF_BUFFER_SIZE) { fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); // Use fwrite for the message to avoid recursive __wrap_printf call - static const char msg[] = "\nprintf buffer overflow\n"; - fwrite(msg, 1, sizeof(msg) - 1, stream); + static const char MSG[] = "\nprintf buffer overflow\n"; + fwrite(MSG, 1, sizeof(MSG) - 1, stream); abort(); } if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index ac012b5e85..332ea73a61 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -1,11 +1,25 @@ +from collections.abc import Callable, MutableMapping + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE from esphome.types import ConfigType DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] +CONF_RP2040_BLE_ID = "rp2040_ble_id" + +KEY_RP2040_BLE = "rp2040_ble" +KEY_USED_CONNECTION_SLOTS = "used_connection_slots" + +# Hard platform cap on concurrent GATT connections: the BTstack pool overrides +# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this +# as the ceiling. 3 matches the esp32 default and stays within the +# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3). +MAX_CONNECTIONS = 3 + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) @@ -17,6 +31,79 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +def _validate_board(config: ConfigType) -> ConfigType: + from esphome.components.rp2 import board_has_wifi, get_board + + if not board_has_wifi(): + raise cv.Invalid( + f"Board '{get_board()}' does not have Bluetooth support (no CYW43 wireless " + f"chip). Use a board like 'rpipicow' or 'rpipico2w'." + ) + return config + + +def consume_connection_slots( + value: int, consumer: str +) -> Callable[[MutableMapping], MutableMapping]: + """Reserve BLE connection slots for a component (the esp32_ble pattern); + the total is checked against MAX_CONNECTIONS in final validation.""" + + def _consume_connection_slots(config: MutableMapping) -> MutableMapping: + data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) + slots.extend([consumer] * value) + return config + + return _consume_connection_slots + + +def validate_connection_slots() -> None: + """Fail when consumers claimed more slots than the platform cap.""" + # Skip in testing mode to allow component grouping (esp32_ble parity). + if CORE.testing_mode: + return + used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + if len(used) > MAX_CONNECTIONS: + raise cv.Invalid( + f"BLE components require {len(used)} connection slots but the " + f"rp2 maximum is {MAX_CONNECTIONS}. " + f"Components: {', '.join(used)}" + ) + + +def _final_validate(config: ConfigType) -> ConfigType: + _validate_board(config) + validate_connection_slots() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT") + +# The four btstack_memory accessors whose static pools are baked into the +# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the +# archive, so --wrap intercepts them all (see btstack_memory.cpp). +_BTSTACK_POOL_SYMBOLS = ( + "btstack_memory_gatt_client_get", + "btstack_memory_gatt_client_free", + "btstack_memory_hci_connection_get", + "btstack_memory_hci_connection_free", +) + + +def add_btstack_pool_overrides() -> None: + """Emit the --wrap flags that swap the prebuilt BTstack pools for the + ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by + bluetooth_connection when a second GATT backend registers; idempotent + (build flags are a set).""" + for symbol in _BTSTACK_POOL_SYMBOLS: + cg.add_build_flag(f"-Wl,--wrap={symbol}") + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp new file mode 100644 index 0000000000..8af57924a2 --- /dev/null +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -0,0 +1,118 @@ +// Replaces the gatt_client / hci_connection static pools baked into +// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1, +// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT. +// add_btstack_pool_overrides() in this component's codegen emits the matching +// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT +// backend registers; single-backend builds emit no flags and this file +// compiles to nothing, leaving the prebuilt pools in charge. Layout safety: +// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU +// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component +// always sets it), so sizeof() here matches the archive. + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1) + +#include + +#include + +namespace esphome::rp2040_ble { +namespace { + +// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// a changed ENABLE_* macro) shifting the struct layout must fail the build +// here, not overrun the pool blocks at runtime. Sizes differ per core +// architecture (measured from each archive's own storage symbols). GCC only: +// the clang-tidy frontend lays these structs out differently, and the guard +// targets the real link. +#ifndef __clang__ +#ifdef __riscv +static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed"); +#else +static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed"); +#endif +#endif // __clang__ + +// One gatt_client_t per configured connection slot. An hci_connection_t is +// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none); +// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT +// client) so a teardown/re-connect overlap can never starve a slot. +constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) +gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT]; +btstack_memory_pool_t gatt_client_pool; +hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE]; +btstack_memory_pool_t hci_connection_pool; + +// Static init: pool_create only links a free list through its own storage, +// and BTstack first allocates long after static construction. +struct PoolInit { + PoolInit() { + btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT, + sizeof(gatt_client_t)); + btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE, + sizeof(hci_connection_t)); + } +} pool_init; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) + +} // namespace + +// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on +// success, NULL when exhausted; free returns the block to the pool. The +// prebuilt pools stay resident in .bss (~7.4 KB, kept live by +// btstack_memory_init in the archive) — dead weight here, not a leak. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void); +extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client); +extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void); +extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection); + +namespace { +// Fails the link if the corresponding --wrap flag is missing: __real_* only +// exists while --wrap is in effect, and each wrap function anchors its own +// symbol so dropping any single flag fails loudly. A code reference is used +// because the framework links with --gc-sections, which discards an +// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain +// does not emit SHF_GNU_RETAIN for [[gnu::retain]]). +template void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); } +} // namespace + +extern "C" { + +gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) { + anchor_wrap(&__real_btstack_memory_gatt_client_get); + void *buffer = btstack_memory_pool_get(&gatt_client_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(gatt_client_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) { + anchor_wrap(&__real_btstack_memory_gatt_client_free); + btstack_memory_pool_free(&gatt_client_pool, gatt_client); +} + +hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) { + anchor_wrap(&__real_btstack_memory_hci_connection_get); + void *buffer = btstack_memory_pool_get(&hci_connection_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(hci_connection_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) { + anchor_wrap(&__real_btstack_memory_hci_connection_free); + btstack_memory_pool_free(&hci_connection_pool, hci_connection); +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1 diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 4125da7ec0..80e8bf9415 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -2,8 +2,13 @@ #ifdef USE_RP2040_BLE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + +#include + namespace esphome::rp2040_ble { static const char *const TAG = "rp2040_ble"; @@ -14,6 +19,20 @@ RP2040BLE *global_ble = nullptr; void RP2040BLE::setup() { global_ble = this; + // Pre-create every pool entry so the packet handler's allocate() is always a + // free-list pop — the IRQ path must never reach malloc() (the newlib malloc + // lock is not IRQ-safe). Deliberately + // unconditional: warming lazily on the first scan would move the allocations + // after setup, and doing it here keeps the pool's RAM cost visible at + // startup instead of appearing once scanning begins. On an incomplete warm, + // refuse to run instead (the stack is never enabled, so the packet handler + // cannot fire). + if (!this->report_pool_.warm()) { + ESP_LOGE(TAG, "Scan report pool warm-up failed"); + this->mark_failed(); + return; + } + if (this->enable_on_boot_) { this->enable(); } else { @@ -31,31 +50,50 @@ void RP2040BLE::enable() { this->active_logged_ = false; if (!this->btstack_initialized_) { + // Serialize with the BTstack background worker while wiring the stack up + // (arduino-pico's BluetoothHCI::install() takes the same lock here). + BluetoothLock lock; + // BTstack init functions are not idempotent — only call once l2cap_init(); sm_init(); - this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler_; +#ifdef USE_BLE_GATT_CLIENT + gatt_client_init(); + // The GATT engine kicks the MTU exchange explicitly right after a + // connection completes (auto negotiation would only run on the first + // query, which a with-cache connection never issues). + gatt_client_mtu_enable_auto_negotiation(0); + // Just-works security for peripheral-initiated pairing. + sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT); + sm_set_authentication_requirements(SM_AUTHREQ_BONDING); +#endif + + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler; hci_add_event_handler(&this->hci_event_callback_registration_); - this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler; sm_add_event_handler(&this->sm_event_callback_registration_); this->btstack_initialized_ = true; } + BluetoothLock lock; hci_power_control(HCI_POWER_ON); } void RP2040BLE::disable() { - if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::OFF) { + if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::STATE_OFF) { return; } ESP_LOGD(TAG, "Disabling BLE..."); this->state_ = BLEComponentState::DISABLING; - hci_power_control(HCI_POWER_OFF); + { + BluetoothLock lock; + hci_power_control(HCI_POWER_OFF); + } this->state_ = BLEComponentState::DISABLED; ESP_LOGD(TAG, "BLE disabled"); @@ -64,13 +102,37 @@ void RP2040BLE::disable() { void RP2040BLE::loop() { if (this->state_ == BLEComponentState::ACTIVE && !this->active_logged_) { this->active_logged_ = true; - ESP_LOGI(TAG, "BLE active"); + // The controller address becomes readable once HCI reaches WORKING. + // bd_addr_to_str() formats into a BTstack-internal static buffer, so both + // calls stay under the lock like every other BTstack call from the loop. + BluetoothLock lock; + gap_local_bd_addr(this->ble_mac_); + ESP_LOGI(TAG, "BLE active (MAC %s)", bd_addr_to_str(this->ble_mac_)); + } + + // Drain the lock-free ring filled by the BTstack packet handler; all + // per-report work runs here on the main loop, then the report returns to + // the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u scan reports (queue full)", dropped); } } static const char *state_to_str(BLEComponentState state) { switch (state) { - case BLEComponentState::OFF: + case BLEComponentState::STATE_OFF: return "OFF"; case BLEComponentState::ENABLING: return "ENABLING"; @@ -95,7 +157,7 @@ void RP2040BLE::dump_config() { float RP2040BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } -void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { +void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (global_ble == nullptr) { return; } @@ -114,11 +176,116 @@ void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, } break; } + case GAP_EVENT_ADVERTISING_REPORT: { + // Runs in the CYW43 async-context worker (low-priority IRQ), NOT the + // ESPHome main loop: bounded copy into the lock-free queue only. + bd_addr_t addr; // accessor returns printable (MSB-first) order + gap_event_advertising_report_get_address(packet, addr); + uint8_t mac_lsb[MAC_ADDRESS_SIZE]; + reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect + global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), + gap_event_advertising_report_get_address_type(packet), + gap_event_advertising_report_get_advertising_event_type(packet), + gap_event_advertising_report_get_data(packet), + gap_event_advertising_report_get_data_length(packet)); + break; + } default: break; } } +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pool is sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, + uint8_t adv_event_type, const uint8_t *data, uint16_t data_len) { + BLEScanReport *report = this->report_pool_.allocate(); + if (report == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + return; + } + memcpy(report->mac, mac_lsb_first, MAC_ADDRESS_SIZE); + report->rssi = rssi; + report->addr_type = addr_type; + report->adv_event_type = adv_event_type; + report->data_len = + (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); + memcpy(report->data, data, report->data_len); + this->report_queue_.push(report); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +void RP2040BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + memcpy(out, this->ble_mac_, MAC_ADDRESS_SIZE); +} + +bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) { + // Power control stays with the user (enable_on_boot or an explicit + // enable() call) — auto-enabling here would defeat enable_on_boot: false + // the moment a tracker retries. Callers retry until the stack is up. + return false; + } +#ifdef USE_BLE_GATT_CLIENT + this->scan_interval_ = interval; + this->scan_window_ = window; + this->scan_active_mode_ = active; + this->scan_desired_ = true; + if (this->scan_inhibit_count_ > 0) { + // A connect attempt owns the radio; the scan starts physically when the + // inhibit is released. Report success — the controller will run it. + ESP_LOGV(TAG, "Scan start deferred (connect in progress)"); + return true; + } +#endif + // Serialize with the BTstack background worker (arduino-pico's BluetoothHCI + // takes the same lock around its gap_* calls). + BluetoothLock lock; + gap_set_scan_params(active ? 1 : 0, interval, window, 0 /* accept all */); + gap_start_scan(); + return true; +} + +void RP2040BLE::scan_stop() { +#ifdef USE_BLE_GATT_CLIENT + this->scan_desired_ = false; +#endif + if (!this->is_active()) { + return; // nothing can be scanning on a stack that is not up + } + BluetoothLock lock; + gap_stop_scan(); +} + +#ifdef USE_BLE_GATT_CLIENT +void RP2040BLE::inhibit_scan() { + if (this->scan_inhibit_count_++ != 0) { + return; // another connect attempt already owns the radio + } + if (this->scan_desired_ && this->is_active()) { + BluetoothLock lock; + gap_stop_scan(); + } +} + +void RP2040BLE::release_scan_inhibit() { + if (this->scan_inhibit_count_ == 0) { + return; + } + this->scan_inhibit_count_--; + if (this->scan_inhibit_count_ != 0) { + return; + } + if (this->scan_desired_) { + // One physical-start path: scan_start re-applies the remembered params. + this->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_mode_); + } +} +#endif // USE_BLE_GATT_CLIENT + } // namespace esphome::rp2040_ble #endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 885e49f690..263a32106b 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -5,19 +5,60 @@ #ifdef USE_RP2040_BLE #include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" #include +#include + namespace esphome::rp2040_ble { enum class BLEComponentState : uint8_t { - OFF = 0, + STATE_OFF = 0, ENABLING, ACTIVE, DISABLING, DISABLED, }; +/// One advertisement report from the controller. +struct BLEScanReport { + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm + uint8_t addr_type; + uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart + uint8_t data_len; // bytes valid in data[] + // Legacy advertisement (31) + scan response (31). BTstack delivers the two + // as separate reports, so each report fills at most 31 bytes today; the 62 + // matches the API raw-advertisement contract. adv_event_type is what lets a + // future merge point tell the two frames apart — carrying it beyond this + // struct (RawAdvertisement) is deferred until a consumer needs the merge. + uint8_t data[62]; + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always +/// runs on the ESPHome main loop: reports are queued from the BTstack packet +/// handler (CYW43 async-context IRQ) and drained by the controller's loop(), +/// so consumers never deal with cross-context state (the esp32_ble +/// event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the packet handler and loop(). The producer +// is a same-core IRQ and loop() drains the ring every iteration, so only the +// advertisements of a single loop period can accumulate. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 32; + class RP2040BLE final : public Component { public: void setup() override; @@ -31,16 +72,84 @@ class RP2040BLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + /// Controller BLE address in printable (MSB-first) order, as + /// gap_local_bd_addr() delivers it — note BLEScanReport::mac is the opposite + /// (LSB-first) order, hence the explicit names. All zeros until the stack + /// reports ACTIVE (BTstack reads the address from the controller during + /// power-up). + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; + +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main loop via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits RP2040_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Start a controller scan; active sends scan requests and receives scan + /// responses as separate reports. Interval/window are in BLE units + /// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the + /// tracker's rate-limited retry loop); powering the stack on stays with the + /// user (enable_on_boot or an explicit enable() call). The controller keeps + /// no scan state across power cycles: a disable()/enable() cycle ends the + /// scan, and the caller must call scan_start() again once the stack is back + /// to ACTIVE (the tracker's loop() reconciliation does exactly that). + /// While a GATT connect attempt has the scan inhibited, the desired scan is + /// remembered and started physically when the inhibit is released. + bool scan_start(uint16_t interval, uint16_t window, bool active); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + +#ifdef USE_BLE_GATT_CLIENT + /// Pause the physical scan for the duration of a GATT connect attempt + /// (initiating and scanning contend for the radio). The desired scan state + /// set through scan_start()/scan_stop() is remembered and reconciled by + /// release_scan_inhibit(). Holders must guarantee the release on every + /// abort path (the GATT engine reclaims via its connect timeout). + void inhibit_scan(); + void release_scan_inhibit(); +#endif + protected: - static void packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + + /// Buffer one controller report (BTstack packet handler, CYW43 async-context + /// IRQ — bounded copy into the lock-free queue, nothing else). + void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, uint8_t adv_event_type, + const uint8_t *data, uint16_t data_len); + +#ifdef RP2040_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the BTstack packet handler (async-context IRQ) allocates a + // report from the pool, fills it and pushes the pointer; loop() pops, + // dispatches and releases. Lock-free SPSC — the esp32_ble/bk72xx_ble pattern. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - BLEComponentState state_{BLEComponentState::OFF}; + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // printable (MSB-first) order; zeros until ACTIVE + BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; bool active_logged_{false}; +#ifdef USE_BLE_GATT_CLIENT + // Remembered scan intent, so connect attempts can pause the physical scan + // and restore it afterwards without involving the tracker. Counted so + // overlapping connect attempts compose once multiple slots exist. + uint16_t scan_interval_{0}; + uint16_t scan_window_{0}; + uint8_t scan_inhibit_count_{0}; + bool scan_active_mode_{false}; + bool scan_desired_{false}; +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index b9c0a9c257..cf7041931e 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -14,26 +14,15 @@ namespace esphome::rp2040_pio_led_strip { -static const char *TAG = "rp2040_pio_led_strip"; - -static uint8_t num_instance_[2] = {0, 0}; -static std::map chipset_offsets_ = { - {CHIPSET_WS2812, 0}, {CHIPSET_WS2812B, 0}, {CHIPSET_SK6812, 0}, {CHIPSET_SM16703, 0}, {CHIPSET_CUSTOM, 0}, -}; -static std::map conf_count_ = { - {CHIPSET_WS2812, false}, {CHIPSET_WS2812B, false}, {CHIPSET_SK6812, false}, - {CHIPSET_SM16703, false}, {CHIPSET_CUSTOM, false}, -}; -static bool dma_chan_active_[12]; -static struct semaphore dma_write_complete_sem_[12]; +static const char *const TAG = "rp2040_pio_led_strip"; // DMA interrupt service routine -void RP2040PIOLEDStripLightOutput::dma_write_complete_handler_() { +void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { uint32_t channel = dma_hw->ints0; for (uint dma_chan = 0; dma_chan < 12; ++dma_chan) { - if (RP2040PIOLEDStripLightOutput::dma_chan_active_[dma_chan] && (channel & (1u << dma_chan))) { - dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt - sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[dma_chan]); // Handle the interrupt + if (RP2040PIOLEDStripLightOutput::dma_chan_active[dma_chan] && (channel & (1u << dma_chan))) { + dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt + sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[dma_chan]); // Handle the interrupt } } } @@ -69,22 +58,22 @@ void RP2040PIOLEDStripLightOutput::setup() { // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO uint offset = 0; - if (RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1] >= 4) { + if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { ESP_LOGE(TAG, "Too many instances of PIO program"); this->mark_failed(); return; } // keep track of how many instances of the PIO program are running on each PIO - RP2040PIOLEDStripLightOutput::num_instance_[this->pio_ == pio0 ? 0 : 1]++; + RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; // if there are multiple strips of the same chipset, we can reuse the same PIO program and save space - if (this->conf_count_[this->chipset_]) { - offset = RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_]; + if (RP2040PIOLEDStripLightOutput::conf_count[this->chipset_]) { + offset = RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_]; } else { // Load the assembled program into the PIO and get its location in the PIO's instruction memory and save it offset = pio_add_program(this->pio_, this->program_); - RP2040PIOLEDStripLightOutput::chipset_offsets_[this->chipset_] = offset; - RP2040PIOLEDStripLightOutput::conf_count_[this->chipset_] = true; + RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_] = offset; + RP2040PIOLEDStripLightOutput::conf_count[this->chipset_] = true; } // Configure the state machine's PIO, and start it @@ -106,7 +95,7 @@ void RP2040PIOLEDStripLightOutput::setup() { } // Mark the DMA channel as active - RP2040PIOLEDStripLightOutput::dma_chan_active_[this->dma_chan_] = true; + RP2040PIOLEDStripLightOutput::dma_chan_active[this->dma_chan_] = true; this->dma_config_ = dma_channel_get_default_config(this->dma_chan_); channel_config_set_transfer_data_size( @@ -125,11 +114,11 @@ void RP2040PIOLEDStripLightOutput::setup() { ); // Initialize the semaphore for this DMA channel - sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_], 1, 1); + sem_init(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_], 1, 1); - irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler_); // after DMA all data, raise an interrupt - dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt - irq_set_enabled(DMA_IRQ_0, true); // enable interrupt + irq_set_exclusive_handler(DMA_IRQ_0, dma_write_complete_handler); // after DMA all data, raise an interrupt + dma_channel_set_irq0_enabled(this->dma_chan_, true); // map DMA channel to interrupt + irq_set_enabled(DMA_IRQ_0, true); // enable interrupt this->init_(this->pio_, this->sm_, offset, this->pin_, this->max_refresh_rate_); } @@ -148,12 +137,12 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } // the bits are already in the correct order for the pio program so we can just copy the buffer using DMA - sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem_[this->dma_chan_]); + sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_]); dma_channel_transfer_from_buffer_now(this->dma_chan_, this->buf_, this->get_buffer_size_()); } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0, w = 0; + int32_t r = 0, g = 0, b = 0; switch (this->rgb_order_) { case ORDER_RGB: r = 0; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index b74dd14108..c499f0a7ca 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -95,7 +95,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } - static void dma_write_complete_handler_(); + static void dma_write_complete_handler(); uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -119,11 +119,11 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { init_fn init_; private: - inline static int num_instance_[2]; - inline static std::map conf_count_; - inline static std::map chipset_offsets_; - inline static bool dma_chan_active_[12]; - inline static struct semaphore dma_write_complete_sem_[12]; + inline static int num_instance[2]; + inline static std::map conf_count; + inline static std::map chipset_offsets; + inline static bool dma_chan_active[12]; + inline static struct semaphore dma_write_complete_sem[12]; }; } // namespace esphome::rp2040_pio_led_strip diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py new file mode 100644 index 0000000000..7709df9899 --- /dev/null +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -0,0 +1,79 @@ +"""BLE scanner for the Raspberry Pi Pico W / Pico 2 W (BLEHub on rp2040_ble). + +Scan modes: + continuous: true — scan runs forever; never stops automatically. + continuous: false — a started scan runs for `duration`, then stops. The first + start is external too; nothing starts a non-continuous + scan on boot. Until start/stop automation actions land + (follow-up PR), starting means a lambda: + `id(my_tracker).start_scan();`. +""" + +import esphome.codegen as cg +from esphome.components import ble_device_base, ota, rp2040_ble +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["rp2"] +AUTO_LOAD = ["ble_device_base", "rp2040_ble"] +CODEOWNERS = ["@bdraco"] + +ble_device_base.register_hub_provider("rp2_ble_tracker") + +rp2_ble_tracker_ns = cg.esphome_ns.namespace("rp2_ble_tracker") +RP2BLETracker = rp2_ble_tracker_ns.class_( + "RP2BLETracker", ble_device_base.BLEHub, cg.Component +) + + +# interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle — +# the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for +# WiFi on the shared CYW43. Converted to the controller's 0.625 ms BLE units in +# to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan +# request TX and roughly doubles the reports through the queue, so +# `active: false` is the lighter choice when scan response data is not needed. +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RP2BLETracker), + cv.GenerateID(CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_RP2_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (BTstack delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + rp2040_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp new file mode 100644 index 0000000000..06beb186ae --- /dev/null +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -0,0 +1,250 @@ +#ifdef USE_RP2 + +#include "rp2_ble_tracker.h" + +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rp2_ble_tracker { + +static const char *const TAG = "rp2_ble_tracker"; + +// Minimum interval between scan start attempts on an active stack. The +// controller start has no failure mode once HCI is WORKING, so this fires at +// most once per enable cycle today; the floor is insurance against a future +// scan_start() failure being retried every main-loop iteration. +static constexpr uint32_t SCAN_START_RETRY_MS = 1000; + +// One BLE scan unit in milliseconds; the controller programs interval/window in these units. +static constexpr float BLE_SCAN_UNIT_MS = 0.625f; + +void RP2BLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // BTstack packet handler (IRQ) and delivers here on the main loop. + this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — the BLE scan competes with + // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + if (!this->scan_continuous_) { + // Nothing to do until an external start_scan(); the loop is re-enabled there. + this->disable_loop(); + } +} + +#ifdef USE_OTA_STATE_LISTENER +void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + // A one-shot scan counts as pending when it is running or still retrying + // its start (loop enabled); captured before stop_scan() disables the loop. + this->scan_pending_before_ota_ = !this->scan_continuous_ && (this->scan_running_ || this->is_in_loop_state()); + this->stop_scan(); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted update; + // loop()'s retry branch restarts the scan on its next iteration. + if (this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + this->enable_loop(); + } + // A one-shot scan interrupted by the OTA resumes for a fresh duration + // rather than silently staying idle — an OTA failure does not reboot, so + // nothing external would restart it. + if (this->scan_pending_before_ota_) { + this->scan_pending_before_ota_ = false; + this->enable_loop(); + } + } +} +#endif // USE_OTA_STATE_LISTENER + +void RP2BLETracker::loop() { + const uint32_t now = App.get_loop_component_start_time(); + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + if (this->scan_running_ && !this->parent_->is_active()) { + // The controller was disabled underneath us (e.g. a lambda calling + // rp2040_ble's disable()); the scan died with the stack. Reconcile so the + // retry branch below takes over once the user re-enables the stack. + this->scan_running_ = false; + this->fire_scan_end_(); + } + if (!this->scan_running_) { + // A scan should be running but is not: continuous mode is always in this + // state until the start succeeds, and non-continuous mode only reaches + // here between start_scan() and a successful controller start, because + // stop_scan_() disables the loop otherwise. + if (!this->parent_->is_active()) { + // Stack not up (still booting, or the user called disable()) — + // scan_start() cannot succeed, so there is nothing to attempt; scanning + // starts on the first iteration after HCI reaches WORKING. + return; + } + if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) { + this->start_scan_(); + } + return; + } + + if (this->scan_continuous_) { + // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring + // esp32_ble_tracker::cleanup_scan_state_(). + if (now - this->scan_period_start_ >= this->scan_duration_) { + this->fire_scan_end_(); + this->scan_period_start_ = now; + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. api: on_client_connected:). + if (now - this->scan_period_start_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +void RP2BLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "RP2 BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, + this->scan_active_ ? LOG_STR_LITERAL("ACTIVE") : LOG_STR_LITERAL("PASSIVE"), + YESNO(this->scan_continuous_)); +} + +// GAP advertising event types as BTstack reports them (Core spec advertising +// report event types; the tracker deliberately does not include BTstack +// headers). ADV_IND and ADV_SCAN_IND are the scannable types. +static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0; +static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2; +static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4; + +// Demux advertisements vs scan responses into the shared merger: BTstack +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; + } + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && + (report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); +} + +void RP2BLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + this->enable_loop(); + this->start_scan_(); +} + +bool RP2BLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); + // Apply to a running scan by restarting the CONTROLLER scan with the new + // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the + // scan logically continues, only the request mode changes), no period reset. + // An idle scanner picks the mode up on its next start. + if (this->scan_running_) { + this->parent_->scan_stop(); + if (!this->controller_scan_start_()) { + // The controller really stopped: behave exactly like loop()'s + // reconciliation branch - notify listeners and let its retry recover. + this->scan_running_ = false; + this->fire_scan_end_(); + } + } + return true; +} + +void RP2BLETracker::stop_scan() { + this->scan_continuous_ = false; + this->stop_scan_(); + // stop_scan_() early-returns when no scan is running, so disable the loop + // here too: a scan that never came up (stack still powering on at OTA start) + // must not keep attempting scan_start() from the loop's retry branch. + this->disable_loop(); +} + +// Stamp-and-start for every controller scan attempt: the stamp keeps the +// SCAN_START_RETRY_MS floor covering all callers, not only loop()'s retry. +bool RP2BLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + +void RP2BLETracker::start_scan_() { + if (this->scan_running_) + return; + + if (!this->controller_scan_start_()) + return; + + this->scan_running_ = true; + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)", + this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"), + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); + // Re-anchor the scan period to every successful start — first start (so the + // period counts from the scan, not from boot) and every restart after a stop (so + // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous + // mode 10 minutes later, does not fire on_scan_end before an advertisement can + // arrive). Same clock as loop()'s `now`: a fresh millis() here would be ahead of + // the cached loop time and make the same-iteration period check underflow. + this->scan_period_start_ = App.get_loop_component_start_time(); +} + +void RP2BLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + ESP_LOGD(TAG, "Scan stopped"); + this->fire_scan_end_(); + // Reset the period clock so on_scan_end does not double-fire; same clock as loop(). + this->scan_period_start_ = App.get_loop_component_start_time(); + if (!this->scan_continuous_) { + // Nothing left to time; start_scan() re-enables the loop. + this->disable_loop(); + } +} + +void RP2BLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h new file mode 100644 index 0000000000..431f2daec7 --- /dev/null +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -0,0 +1,116 @@ +#pragma once + +#ifdef USE_RP2 + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::rp2_ble_tracker { + +class RP2BLETracker : public Component, + public rp2040_ble::BLEScanListener, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (the BLE scan competes with the OTA + // download on the shared CYW43 radio); mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { + this->dispatcher_.register_listener(listener); + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->dispatcher_.set_raw_advertisement_callback(callback); + } + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // BTstack delivers scan responses as separate advertisement reports; this + // tracker merges the pair before delivery (shared ScanResponseMerger, + // Bluedroid semantics). GATT is available when the BTstack connection + // backend is compiled in (bluetooth_proxy active). +#ifdef USE_BLE_GATT_CLIENT + constexpr bool has_gatt = true; +#else + constexpr bool has_gatt = false; +#endif + return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true}; + } + // The controller stores the address in printable (MSB-first) order, which is + // exactly what the contract wants. + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); + + // ---- rp2040_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main loop — the + // IRQ → main-loop handoff already happened in the controller's queue. + void on_scan_report(const rp2040_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + bool controller_scan_start_(); + void stop_scan_(); + void fire_scan_end_(); + + // Defaults: 30 % duty cycle (interval 100 ms / window 30 ms), in 0.625 ms + // BLE units — same defaults as bk72xx_ble_tracker. + uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms + uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) + uint32_t scan_duration_{300000}; + uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries + uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end() + bool scan_running_{false}; + bool scan_active_{true}; + bool scan_continuous_{true}; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure +#endif + + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main loop. Merger clock: stash_adv() reads the + // PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue + // drain), sweep() this component's — same App.loop() pass, so the delta + // stays non-negative and the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; +}; + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index a3b1dba4f2..a8eabae9a0 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -51,7 +51,11 @@ def _set_core_data(config): return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 8db69aa53e..d8517d4493 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -82,7 +82,7 @@ class JPEGFormat(Format): # JPEGDEC uses ESP32-S3 SIMD optimizations (guarded by board-level # ARDUINO_ESP32S3_DEV define) that require esp-dsp headers. # On Arduino this overwrites the stub; on IDF it adds the component. - add_idf_component(name="espressif/esp-dsp", ref="1.7.1") + add_idf_component(name="espressif/esp-dsp", ref="1.8.2") class PNGFormat(Format): diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 8d3320b5d1..f2c4f5c8cd 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -10,12 +10,16 @@ static const char *const TAG = "image_decoder"; bool ImageDecoder::set_size(int width, int height) { bool success = this->image_->resize(width, height) > 0; + this->size_valid_ = success; this->x_scale_ = static_cast(this->image_->get_buffer_width()) / width; this->y_scale_ = static_cast(this->image_->get_buffer_height()) / height; return success; } void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { + if (!this->size_valid_) { + return; + } auto width = std::min(this->image_->get_buffer_width(), static_cast(std::ceil((x + w) * this->x_scale_))); auto height = std::min(this->image_->get_buffer_height(), static_cast(std::ceil((y + h) * this->y_scale_))); for (int i = x * this->x_scale_; i < width; i++) { diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index c68ea5720b..6d351a10aa 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -108,6 +108,7 @@ class ImageDecoder { size_t decoded_bytes_ = 0; // Bytes processed so far double x_scale_ = 1.0; double y_scale_ = 1.0; + bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 12bce0d284..9501702711 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -96,6 +96,8 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } else if (!this->size_valid_) { + return DECODE_ERROR_OUT_OF_MEMORY; } else { this->decoded_bytes_ += fed; } diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 4c7f1bfb6f..8fe9be4c8c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -3,7 +3,9 @@ #include "esphome/core/log.h" #include "esphome/core/helpers.h" #include +#include #include +#include #ifdef USE_RUNTIME_IMAGE_BMP #include "bmp_decoder.h" @@ -19,6 +21,13 @@ namespace esphome::runtime_image { static const char *const TAG = "runtime_image"; +// Widest supported format is 4 bytes/pixel, so 32767 * 32767 * 4 still fits a 32-bit size_t +static constexpr int MAX_IMAGE_DIMENSION = 32767; +static constexpr int MAX_IMAGE_BPP = 32; +static_assert((static_cast(MAX_IMAGE_BPP) * MAX_IMAGE_DIMENSION + 7) / 8 * MAX_IMAGE_DIMENSION <= + std::numeric_limits::max(), + "MAX_IMAGE_DIMENSION must keep the worst-case buffer size within size_t"); + inline bool is_color_on(const Color &color) { // This produces the most accurate monochrome conversion, but is slightly slower. // return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127; @@ -239,9 +248,15 @@ void RuntimeImage::release() { void RuntimeImage::release_buffer_() { if (this->buffer_) { - ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size_(this->buffer_width_, this->buffer_height_)); - RAMAllocator allocator; - allocator.deallocate(this->buffer_, this->get_buffer_size_(this->buffer_width_, this->buffer_height_)); + if (this->external_buffer_) { + // The caller owns this memory and goes on using it after the image lets go of it. + ESP_LOGV(TAG, "Letting go of the external %dx%d buffer", this->buffer_width_, this->buffer_height_); + this->external_buffer_ = false; + } else { + ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size(this->buffer_width_, this->buffer_height_)); + RAMAllocator allocator; + allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_)); + } this->buffer_ = nullptr; this->data_start_ = nullptr; this->width_ = 0; @@ -254,14 +269,46 @@ void RuntimeImage::release_buffer_() { } } -size_t RuntimeImage::resize_buffer_(int width, int height) { - size_t new_size = this->get_buffer_size_(width, height); +bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) { + this->release_buffer_(); + if (buffer == nullptr || this->get_buffer_size(width, height) == 0) { + // Keep the released state rather than remembering a buffer that cannot be decoded into: an + // external buffer that is never handed back would otherwise block every later allocation. + ESP_LOGE(TAG, "Refusing an invalid external buffer for %dx%d", width, height); + return false; + } + this->buffer_ = buffer; + this->external_buffer_ = true; + this->buffer_width_ = width; + this->buffer_height_ = height; + return true; +} +size_t RuntimeImage::resize_buffer_(int width, int height) { + size_t new_size = this->get_buffer_size(width, height); + + // A buffer only ever exists with dimensions the image can decode at, so a match here means + // new_size is non-zero. Checking it before the invalid dimension case below lets the external + // buffer be let go of for every decode it cannot serve, not just for valid other dimensions. if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) { // Buffer already allocated with correct size return new_size; } + if (this->external_buffer_) { + ESP_LOGE(TAG, "Image decoded to %dx%d, but the external buffer is %dx%d", width, height, this->buffer_width_, + this->buffer_height_); + // Let the buffer go rather than free memory that belongs to the caller. Dropping it also stops + // a decoder that ignores this failure from publishing a picture it never painted. + this->release_buffer_(); + return 0; + } + + if (new_size == 0) { + ESP_LOGE(TAG, "Refusing to allocate buffer for invalid image dimensions %dx%d", width, height); + return 0; + } + // Release old buffer if dimensions changed if (this->buffer_) { this->release_buffer_(); @@ -286,12 +333,16 @@ size_t RuntimeImage::resize_buffer_(int width, int height) { return new_size; } -size_t RuntimeImage::get_buffer_size_(int width, int height) const { +size_t RuntimeImage::get_buffer_size(int width, int height) const { + // Dimensions come from a remote image header; reject absurd values so the size math cannot overflow + if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + return 0; + } if (this->get_type() == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { // Add extra alpha channel for RGB565 with alpha - return width * height * 3; + return static_cast(width) * height * 3; } - return (this->get_bpp() * width + 7u) / 8u * height; + return (static_cast(this->get_bpp()) * width + 7u) / 8u * height; } int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 4bdcdcac9e..10ce980be2 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -121,9 +121,48 @@ class RuntimeImage : public image::Image { /** * @brief Release the image buffer and free memory. + * + * An external buffer is let go of rather than freed. */ void release(); + /** + * @brief Decode into a buffer the caller owns, instead of one allocated here. + * + * The image never frees an external buffer and never resizes it: a decode that needs other + * dimensions fails as if the allocation had failed, and the buffer is let go of so a decoder + * that ignores that failure cannot publish a picture it did not paint. The caller keeps the + * buffer alive for as long as anything can draw the image, and calls release() (or hands over + * another buffer) before reusing it. + * + * Hand a buffer over before every decode. The image lets go of one whenever a decode fails and + * whenever release() is called, and it does not remember that it ever had one: a decode that + * starts without a buffer allocates its own, which is the runtime allocation this method exists + * to avoid. + * + * The buffer is decoded into as it is handed over, so the caller owns its initial contents. + * Zero it first if anything can draw the image before a decode has painted every pixel. + * + * Do not hand a buffer over while is_decoding() is true. A running decoder keeps scaling values + * for the buffer it started with. + * + * A null buffer or dimensions the image cannot decode at are refused, leaving the image with + * no buffer at all. + * + * @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes. + * @param width Width of the buffer in pixels. + * @param height Height of the buffer in pixels. + * @return true if the image took the buffer, false if it was refused. + */ + bool set_external_buffer(uint8_t *buffer, int width, int height); + + /** + * @brief Get the buffer size in bytes needed for a picture of the given dimensions. + * + * Returns 0 for dimensions the image cannot decode at. + */ + size_t get_buffer_size(int width, int height) const; + /** * @brief Set whether to allow progressive display during decode. * @@ -149,11 +188,6 @@ class RuntimeImage : public image::Image { */ void release_buffer_(); - /** - * @brief Get the buffer size in bytes for given dimensions. - */ - size_t get_buffer_size_(int width, int height) const; - /** * @brief Get the position in the buffer for a pixel. */ @@ -208,6 +242,8 @@ class RuntimeImage : public image::Image { * This is used to determine how to store 16 bit colors in the buffer. */ bool is_big_endian_{false}; + /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ + bool external_buffer_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/ruuvi_ble/__init__.py b/esphome/components/ruuvi_ble/__init__.py index 13d49d3cfe..8ab95dcb72 100644 --- a/esphome/components/ruuvi_ble/__init__.py +++ b/esphome/components/ruuvi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ruuvi_ble_ns = cg.esphome_ns.namespace("ruuvi_ble") RuuviListener = ruuvi_ble_ns.class_( - "RuuviListener", esp32_ble_tracker.ESPBTDeviceListener + "RuuviListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RuuviListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RuuviListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index b73b73d56e..19753b3c9e 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,13 +1,11 @@ #include "ruuvi_ble.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvi_ble { static const char *const TAG = "ruuvi_ble"; -bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviParseResult &result) { +bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviParseResult &result) { const uint8_t data_type = adv_data[0]; const auto *data = &adv_data[1]; switch (data_type) { @@ -80,7 +78,7 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP return false; } } -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device) { +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device) { bool success = false; RuuviParseResult result{}; for (auto &it : device.get_manufacturer_datas()) { @@ -96,7 +94,7 @@ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &dev return result; } -bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RuuviListener::parse_device(const ble_device_base::ESPBTDevice &device) { auto res = parse_ruuvi(device); if (!res.has_value()) return false; @@ -142,5 +140,3 @@ bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index e372b24944..d345790e3a 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -1,9 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::ruuvi_ble { @@ -23,13 +21,11 @@ struct RuuviParseResult { bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_length, RuuviParseResult &result); -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device); -class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 99c6b8ae26..1536befb1b 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -1,8 +1,6 @@ #include "ruuvitag.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { static const char *const TAG = "ruuvitag"; @@ -23,5 +21,3 @@ void RuuviTag::dump_config() { } } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 9602b82afc..fc2d05a642 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ruuvi_ble/ruuvi_ble.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { -class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (device.address_uint64() != this->address_) return false; @@ -77,5 +75,3 @@ class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceLi }; } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index af262b2950..e58d38ca84 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ACCELERATION, @@ -35,15 +35,15 @@ from esphome.const import ( UNIT_VOLT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["ruuvi_ble"] +AUTO_LOAD = ["ble_device_base", "ruuvi_ble"] ruuvitag_ns = cg.esphome_ns.namespace("ruuvitag") RuuviTag = ruuvitag_ns.class_( - "RuuviTag", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "RuuviTag", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvitag"), cv.Schema( { cv.GenerateID(): cv.declare_id(RuuviTag), @@ -116,15 +116,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index c11447e604..70096a56bc 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -16,6 +16,7 @@ from esphome.cpp_generator import RawExpression CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] CONF_BOOT_IS_GOOD_AFTER = "boot_is_good_after" +CONF_BOOT_IS_GOOD_ON_SHUTDOWN = "boot_is_good_on_shutdown" CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") @@ -37,6 +38,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BOOT_IS_GOOD_AFTER, default="1min" ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_BOOT_IS_GOOD_ON_SHUTDOWN, default=True): cv.boolean, cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_NUM_ATTEMPTS, default="10"): cv.positive_not_null_int, cv.Optional( @@ -78,6 +80,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config[CONF_BOOT_IS_GOOD_ON_SHUTDOWN]: + cg.add_define("USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN") + 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)) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 2eb1085ee5..ce029b4f55 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -117,19 +117,31 @@ void SafeModeComponent::dump_config() { float SafeModeComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -void SafeModeComponent::mark_successful() { - this->clean_rtc(); - this->boot_successful_ = true; -#if defined(USE_OTA_ROLLBACK) -// Mark OTA partition as valid to prevent rollback +#ifdef USE_OTA_ROLLBACK +void SafeModeComponent::confirm_app_image_() { + // Mark the running app image as valid so the bootloader will not roll back + // to the previously flashed image #if defined(USE_ZEPHYR) if (!boot_is_img_confirmed()) { boot_write_img_confirmed(); } #elif defined(USE_ESP32) - // Mark OTA partition as valid to prevent rollback - esp_ota_mark_app_valid_cancel_rollback(); + // esp_ota_mark_app_valid_cancel_rollback() acts on the partition selected + // for the next boot, not the running one. After an OTA update those differ: + // the new image is already selected, and marking it valid before it has ever + // booted would disable rollback protection for that update. + if (esp_ota_get_running_partition() == esp_ota_get_boot_partition()) { + esp_ota_mark_app_valid_cancel_rollback(); + } #endif +} +#endif + +void SafeModeComponent::mark_successful() { + this->clean_rtc(); + this->boot_successful_ = true; +#ifdef USE_OTA_ROLLBACK + this->confirm_app_image_(); #endif // Disable loop since we no longer need to check this->disable_loop(); @@ -266,6 +278,15 @@ void SafeModeComponent::clean_rtc() { void SafeModeComponent::on_safe_shutdown() { if (this->read_rtc_() != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) this->clean_rtc(); +#if defined(USE_OTA_ROLLBACK) && defined(USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN) + // An orderly shutdown (deep sleep, restart, power off) means the firmware is + // functional, so confirm the running app image even if boot_is_good_after has + // not elapsed yet. Without this, a device that enters deep sleep shortly + // after waking would have every OTA update rolled back by the bootloader on + // the next wake. Can be turned off with boot_is_good_on_shutdown: false for + // strict rollback semantics. + this->confirm_app_image_(); +#endif } } // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index d81b8a42d1..0633c92a78 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -42,6 +42,9 @@ class SafeModeComponent final : public Component { protected: void write_rtc_(uint32_t val); uint32_t read_rtc_(); +#ifdef USE_OTA_ROLLBACK + void confirm_app_image_(); +#endif // Group all 4-byte aligned members together to avoid padding uint32_t safe_mode_boot_is_good_after_{60000}; ///< The amount of time after which the boot is considered successful diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 790ac107c5..63d0ff7cb3 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -142,6 +142,9 @@ template class QueueingScript : public Script, public Com // Use std::make_unique to replace the unique_ptr this->var_queue_[write_pos] = std::make_unique>(x...); this->num_queued_++; + // Enable loop now that there is something to dequeue - don't call loop() + // synchronously! Let the event loop call it to avoid reentrancy issues + this->enable_loop(); return; } @@ -168,6 +171,15 @@ template class QueueingScript : public Script, public Com this->queue_front_ = (this->queue_front_ + 1) % queue_capacity; this->trigger_tuple_(*tuple_ptr, std::make_index_sequence{}); } + if (this->num_queued_ == 0 && !this->is_idle()) { + // Queue is now empty - disable loop until the next execute() queues an + // instance. The inline is_idle() check skips the out-of-line call when + // the loop is already disabled (execute() calls loop() synchronously). + // This can run before this component's setup() (execute() from on_boot), + // which leaves the state machine in LOOP_DONE and skips call_setup(); + // this class therefore must not rely on a setup() override. + this->disable_loop(); + } } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index a4fe6e7d35..1ebc7fa3d8 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -7,10 +7,10 @@ namespace esphome::sdm_meter { static const char *const TAG = "sdm_meter"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers -void SDMMeter::on_modbus_data(const std::vector &data) { +void SDMMeter::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for SDMMeter!"); return; @@ -82,7 +82,7 @@ void SDMMeter::on_modbus_data(const std::vector &data) { this->export_reactive_energy_sensor_->publish_state(export_reactive_energy); } -void SDMMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void SDMMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SDMMeter::dump_config() { ESP_LOGCONFIG(TAG, "SDM Meter:\n" diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index aa71fcaa47..e09b74bbc0 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::sdm_meter { @@ -55,7 +55,7 @@ class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevic void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index f612b89934..688923d8e6 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -7,10 +7,10 @@ namespace esphome::selec_meter { static const char *const TAG = "selec_meter"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers -void SelecMeter::on_modbus_data(const std::vector &data) { +void SelecMeter::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for SelecMeter!"); return; @@ -81,7 +81,7 @@ void SelecMeter::on_modbus_data(const std::vector &data) { this->maximum_demand_apparent_power_sensor_->publish_state(maximum_demand_apparent_power); } -void SelecMeter::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index c367d1d15d..5ae1f9bf99 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::selec_meter { @@ -37,7 +37,7 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; }; diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 3ea526d931..761a1885ea 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -122,7 +123,9 @@ def float_previously_pct(value): return value -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen5x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen5x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SEN5XComponent), @@ -154,7 +157,7 @@ CONFIG_SCHEMA = ( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.update_interval, - cv.Optional(CONF_VOC): _gas_sensor( + cv.Optional(CONF_VOC_INDEX): _gas_sensor( index_offset=100, learning_time_offset=12, learning_time_gain=12, @@ -162,7 +165,7 @@ CONFIG_SCHEMA = ( std_initial=50, gain_factor=230, ), - cv.Optional(CONF_NOX): _gas_sensor( + cv.Optional(CONF_NOX_INDEX): _gas_sensor( index_offset=1, learning_time_offset=12, learning_time_gain=12, @@ -199,7 +202,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x69)) + .extend(i2c.i2c_device_schema(0x69)), ) SENSOR_MAP = { @@ -207,8 +210,8 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", - CONF_VOC: "set_voc_sensor", - CONF_NOX: "set_nox_sensor", + CONF_VOC_INDEX: "set_voc_sensor", + CONF_NOX_INDEX: "set_nox_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", } @@ -237,7 +240,7 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(cfg) cg.add(getattr(var, funcName)(sens)) - if cfg := config.get(CONF_VOC, {}).get(CONF_ALGORITHM_TUNING): + if cfg := config.get(CONF_VOC_INDEX, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_voc_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -248,7 +251,7 @@ async def to_code(config: ConfigType) -> None: cfg[CONF_GAIN_FACTOR], ) ) - if cfg := config.get(CONF_NOX, {}).get(CONF_ALGORITHM_TUNING): + if cfg := config.get(CONF_NOX_INDEX, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_nox_algorithm_tuning( cfg[CONF_INDEX_OFFSET], diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 5eb34add65..832a2188ee 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -41,7 +42,10 @@ SEN6XComponent = sen6x_ns.class_( "SEN6XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -CONFIG_SCHEMA = ( + +CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SEN6XComponent), @@ -89,12 +93,12 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_VOC): sensor.sensor_schema( + cv.Optional(CONF_VOC_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_NOX): sensor.sensor_schema( + cv.Optional(CONF_NOX_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -115,7 +119,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x6B)) + .extend(i2c.i2c_device_schema(0x6B)), ) SENSOR_MAP = { @@ -125,8 +129,8 @@ SENSOR_MAP = { CONF_PM_10_0: "set_pm_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", - CONF_VOC: "set_voc_sensor", - CONF_NOX: "set_nox_sensor", + CONF_VOC_INDEX: "set_voc_sensor", + CONF_NOX_INDEX: "set_nox_sensor", CONF_CO2: "set_co2_sensor", CONF_FORMALDEHYDE: "set_hcho_sensor", } diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e20925f323..bd889c2c92 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg @@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_FORMAT, + CONF_HEIGHT, CONF_ID, CONF_SAMPLE_RATE, + CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_WIDTH, ) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType @@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. +MAX_ARTWORK_SLOTS = 4 + # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -36,9 +44,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) +IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") +IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") +IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") + +SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) +IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") +IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST") + # Library Structs AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig") +ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference") # MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement # preference for the player role's transfer buffers. @@ -76,6 +95,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -110,6 +130,22 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> int: + """Register an artwork slot preference and return the slot it was given. + + A slot is a preference's position in the list, which is also the order the roles are + advertised to the server in. + """ + request_artwork_support() + preferences = _get_data().artwork_preferences + if len(preferences) >= MAX_ARTWORK_SLOTS: + raise cv.Invalid( + f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}." + ) + preferences.append(config) + return len(preferences) - 1 + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -198,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -211,6 +247,29 @@ async def to_code(config: ConfigType) -> None: # 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) + + # require_frame_done is always on: SendspinImageSlot always acks a delivery, either + # immediately or from the transition_finished action. + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ("require_frame_done", True), + ("display_offset_ms", pref[CONF_DISPLAY_OFFSET]), + ) + for pref in data.artwork_preferences + ] + + artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM)) + artwork_config = cg.StructInitializer( + ArtworkRoleConfig, + ("preferred_formats", preference_structs), + ("psram_stack", artwork_psram_stack), + ) + cg.add(var.set_artwork_config(artwork_config)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py new file mode 100644 index 0000000000..94d6e7cfca --- /dev/null +++ b/esphome/components/sendspin/image/__init__.py @@ -0,0 +1,228 @@ +"""Sendspin image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_DISPLAY_OFFSET, + CONF_SENDSPIN_ID, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather +# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which +# is the looser of the two bounds. +MAX_IMAGE_DIMENSION = 32767 + +# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond +# a few seconds around the track boundary are meaningless. +MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) +MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) + +CONF_SLOT = "slot" +CONF_CURRENT_IMAGE = "current_image" +CONF_TRANSITION_IMAGE = "transition_image" +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_CLEAR = "on_image_clear" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum. +# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "JPG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it +# would never receive artwork while still paying for two frame buffers, so it is not offered here. +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, +} + +# The platform entry configures an artwork slot; the images it shows are declared inside it. The +# slot itself is the automation target (triggers and the transition_finished action). +SendspinImageSlot = sendspin_ns.class_( + "SendspinImageSlot", + cg.Component, + cg.Parented.template(SendspinHub), +) +ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_) + +# A dict rather than a bare ID so per-image options can be added later without a new top-level key. +_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)}) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_IMAGE_DISPLAY, + "add_on_image_display_callback", + [(cg.uint32, "lateness_ms")], + ), + automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"), + automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"), +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Register the artwork preference with the hub and record the slot it was given.""" + width, height = config[CONF_RESIZE] + if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION: + raise cv.Invalid( + f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less", + path=[CONF_RESIZE], + ) + + config[CONF_SLOT] = register_artwork_preference( + { + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds, + } + ) + return config + + +# The format, type, resize, transparency, byte order and placeholder keys all describe the slot: +# they set what is requested from the server and how it is decoded, not either individual image. +# Only the IDs are per-image, so runtime_image_schema declares the slot itself. +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImageSlot).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImageSlot), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + # Narrow runtime_image's format list to what the library can request, so the + # accepted set and the enum map below cannot drift apart. + cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + # Positive fires on_image_display before the server's display timestamp (negative + # delays it), so a cross-fade can straddle the track boundary. + cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All( + cv.time_period, + # The library field is whole milliseconds; reject finer values rather than + # silently rounding them down to zero. + cv.time_period_in_milliseconds_, + cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET), + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}), + } + ), + runtime_image.validate_runtime_image_settings, + cv.only_on_esp32, + _assign_slot_and_register, +) + + +async def to_code(config: ConfigType) -> None: + settings = await runtime_image.process_runtime_image_config(config) + + def make_view(view_id: ID) -> cg.MockObj: + # Views start with no frame; the slot points them at its buffers in setup(). The size is + # given up front so the view is well formed before then. LVGL picks it up from the first + # lvgl.image.update in on_image_display, not from the widget's initial src: at that point + # the view still has no frame, so its descriptor is empty. + view = cg.new_Pvariable( + view_id, + cg.nullptr, + settings.width, + settings.height, + settings.image_type_enum, + settings.transparent, + ) + add_metadata( + view_id, + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + return view + + current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID]) + if settings.placeholder is not None: + cg.add(current_image.set_placeholder(settings.placeholder)) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_SLOT], + current_image, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None: + cg.add(var.set_transition_image(make_view(transition_image[CONF_ID]))) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +SendspinImageTransitionFinishedAction = sendspin_ns.class_( + "SendspinImageTransitionFinishedAction", + automation.Action, + cg.Parented.template(SendspinImageSlot), +) + + +@automation.register_action( + "sendspin.image.transition_finished", + SendspinImageTransitionFinishedAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinImageSlot), + } + ) + ), + synchronous=True, +) +async def sendspin_image_transition_finished_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.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/image/automation.h b/esphome/components/sendspin/image/automation.h new file mode 100644 index 0000000000..154e62a4b2 --- /dev/null +++ b/esphome/components/sendspin/image/automation.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/automation.h" +#include "sendspin_image.h" + +namespace esphome::sendspin_ { + +template +class SendspinImageTransitionFinishedAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->transition_finished(); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp new file mode 100644 index 0000000000..626d7966b7 --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -0,0 +1,261 @@ +#include "sendspin_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.image"; + +// How long a displayed frame may wait for sendspin.image.transition_finished before a warning +// names the missing ack. Generous next to a typical fade of a second or two. +static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; + +// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists. +void SendspinImageSlot::setup() { + const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_); + if (frame_size == 0) { + // The sink would refuse a buffer of these dimensions, so every decode would fall back to + // allocating one of its own. Fail here instead, where the dimensions are already known. + ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_); + this->mark_failed(); + return; + } + + RAMAllocator allocator; + for (uint8_t *&buffer : this->buffers_) { + buffer = allocator.allocate(frame_size); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size, + allocator.get_max_free_block_size()); + for (uint8_t *&allocated : this->buffers_) { + allocator.deallocate(allocated, frame_size); + allocated = nullptr; + } + this->mark_failed(); + return; + } + // Both buffers start black, so a transition has something to fade from before any artwork + // has arrived. + memset(buffer, 0, frame_size); + } + + // Point both views at buffers_[current_index_] rather than the buffer the first decode writes + // into, so they name a frame that stays black until artwork arrives. + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + } + + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) { + if (slot == this->slot_) + this->on_display_(lateness_ms); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call. +void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { + uint8_t *target; + { + // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate + // is what guarantees the buffer it picks out is not still needed by the main loop. + LockGuard lock(this->pending_mutex_); + target = this->buffers_[this->current_index_ ^ 1]; + } + + // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink + // is pinned to them: a decode that asks for anything else is a malformed payload and drops the + // frame. + if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) { + // setup() rules this out, but decoding without the handover would allocate a frame-sized + // buffer on this thread, which is exactly what the permanent buffers exist to avoid. + this->report_error_(); + return; + } + + const bool decoded = this->decode_frame_(data, length, target); + // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is + // safe on every path. + this->decode_sink_.release(); + + if (!decoded) { + // The buffer keeps whatever the failed decode painted into it, but no view names it while a + // decode can run, so nothing shows it. + this->report_error_(); + return; + } + + LockGuard lock(this->pending_mutex_); + this->frame_pending_ = true; +} + +// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink. +bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) { + if (!this->decode_sink_.begin_decode(length)) { + ESP_LOGE(TAG, "Could not start decode"); + return false; + } + + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->decode_sink_.feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed <= 0) { + // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data). + ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed); + return false; + } + total_consumed += consumed; + } + + if (!this->decode_sink_.end_decode()) { + ESP_LOGE(TAG, "Could not finalize decode"); + return false; + } + + // A decode that asked for other dimensions had the buffer taken away from it, so it painted + // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // regardless, so the frame is dropped here. + return this->decode_sink_.decoded_into(target); +} + +// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached). +void SendspinImageSlot::on_display_(uint32_t lateness_ms) { + bool frame_ready; + { + LockGuard lock(this->pending_mutex_); + frame_ready = this->frame_pending_; + this->frame_pending_ = false; + if (frame_ready) { + // The decoded frame becomes the current one; the frame it replaces becomes the outgoing + // frame, and the next decode target once the transition is acked. + this->current_index_ ^= 1; + } + } + if (!frame_ready) { + // The decode for this display failed, so there is nothing new to show. The delivery still owes + // its ack or the library would withhold every later frame for this slot. + this->parent_->artwork_frame_done(this->slot_); + return; + } + + // The frame this display replaces is only real artwork if something was already on screen. + const bool outgoing_is_artwork = this->showing_artwork_; + this->showing_artwork_ = true; + this->apply_frames_(outgoing_is_artwork); + + // Armed before the trigger fires so an automation that acks synchronously still counts, and armed + // for the first frame too so the contract stays uniform: one transition_finished per display. + this->transition_pending_ = this->transition_image_ != nullptr; + if (this->transition_pending_) { + // The library holds back further deliveries until the ack, with no timeout, so an automation + // that never reaches the action stalls the slot with nothing in the log. Name the cause after + // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame + // that was already acked and superseded. + this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + if (this->transition_pending_) { + ESP_LOGW(TAG, + "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " + "sendspin.image.transition_finished runs or the stream is cleared", + this->slot_); + } + }); + } + this->image_display_callback_.call(lateness_ms); + if (this->transition_image_ == nullptr) { + this->finish_transition_(); + } +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::finish_transition_() { + this->transition_pending_ = false; + if (this->transition_image_ != nullptr) { + // Move it off the buffer the next decode writes into. What it shows does not change: the + // buffer it moves to holds the artwork the transition just settled on. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(this->showing_artwork_); + } + // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight + // away, so nothing may still name that buffer by the time this runs. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action). +void SendspinImageSlot::transition_finished() { + if (!this->transition_pending_) { + return; + } + this->finish_transition_(); +} + +// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot). +void SendspinImageSlot::on_clear_() { + { + LockGuard lock(this->pending_mutex_); + // Drop a frame that was decoded but never displayed; its buffer stays the decode target. + this->frame_pending_ = false; + } + // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing + // the last artwork until the automation points it elsewhere or hides it. Only the display lambda + // path stops drawing the artwork, falling back to the placeholder. + this->current_image_->set_showing_artwork(false); + if (this->transition_image_ != nullptr) { + // Point it away from the decode target, as at setup, so it cannot show a frame being decoded. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(false); + } + this->showing_artwork_ = false; + // Drops a running transition. Its automation cannot be cancelled here, so a late + // transition_finished() can ack the next stream's first frame early, showing it without its + // transition. The ack count stays right. + this->transition_pending_ = false; + this->image_clear_callback_.call(); + // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame -- + // including one whose transition never signalled transition_finished(), so a stalled slot + // recovers here. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::dump_config() { + ESP_LOGCONFIG(TAG, + "Artwork slot %u:\n" + " Dimensions: %dx%d\n" + " Frame buffers: 2 x %zu bytes\n" + " Transition image: %s", + this->slot_, this->width_, this->height_, + this->decode_sink_.get_buffer_size(this->width_, this->height_), + YESNO(this->transition_image_ != nullptr)); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::apply_frames_(bool transition_is_artwork) { + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->current_image_->set_showing_artwork(true); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_); + this->transition_image_->set_showing_artwork(transition_is_artwork); + } +} + +// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe +// here because the hub enables wake_loop_threadsafe support. +void SendspinImageSlot::report_error_() { + this->defer([this]() { this->image_error_callback_.call(); }); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h new file mode 100644 index 0000000000..2f6f4e8a4d --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -0,0 +1,185 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/image/image.h" +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/core/helpers.h" + +#include + +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot. +/// +/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path +/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the +/// object shown on screen. +class ArtworkDecodeSink : public runtime_image::RuntimeImage { + public: + using runtime_image::RuntimeImage::RuntimeImage; + + /// @brief True when the decode ended with the given buffer still in place. + /// + /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions + /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. + bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } +}; + +/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot. +/// +/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a +/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin +/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only. +class ArtworkImageView : public image::Image { + public: + using image::Image::Image; + + void set_frame(const uint8_t *data, int width, int height) { + this->data_start_ = data; + this->width_ = width; + this->height_ = height; +#ifdef USE_LVGL + // Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything: + // only setting a widget's source invalidates it. + this->get_lv_image_dsc(); +#endif + } + + /// @brief Records whether the frame on show is real artwork rather than the black it starts as. + /// + /// Only changes what the display lambda path draws. The frame itself is left alone, so anything + /// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is + /// pointed elsewhere. + void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; } + + void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; } + + void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override { + if (!this->showing_artwork_) { + // Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be + // rather than paint a blank frame over it. + if (this->placeholder_ != nullptr) { + this->placeholder_->draw(x, y, display, color_on, color_off); + } + return; + } + image::Image::draw(x, y, display, color_on, color_off); + } + + protected: + image::Image *placeholder_{nullptr}; + bool showing_artwork_{false}; +}; + +/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views. +/// +/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current +/// image shows; the other holds the outgoing frame a transition shows, and is where the next +/// artwork is decoded. Each display swaps their roles. +/// +/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the +/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is +/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the +/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering +/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what +/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame. +/// +/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not +/// invalidate the widget, so every widget's source must be set again on each display. +class SendspinImageSlot : public SendspinChild { + public: + SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height, + runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency, + bool is_big_endian) + : decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height), + current_image_(current_image), + width_(width), + height_(height), + slot_(slot) {} + + void setup() override; + void dump_config() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_clear_callback(F &&callback) { + this->image_clear_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + /// @brief Sets the optional view a transition draws the outgoing artwork from. + /// + /// It holds the outgoing frame while a transition is running and the current frame at any other + /// time, so it always names a picture and never the frame being decoded. + /// + /// Setting it is also what defers the library ack to transition_finished(): the ack releases the + /// outgoing frame to be decoded over, and this view is the only thing that still names it. + void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; } + + /// @brief Signals that the display transition for the last frame has finished. + /// + /// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's + /// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition + /// image is configured, a clear already ended the transition, or the call is a duplicate). Must + /// run on the main loop thread; exposed as the sendspin.image.transition_finished action. + void transition_finished(); + + protected: + void on_decode_(const uint8_t *data, size_t length); + bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target); + void on_display_(uint32_t lateness_ms); + void on_clear_(); + void finish_transition_(); + void apply_frames_(bool transition_is_artwork); + void report_error_(); + + ArtworkDecodeSink decode_sink_; + + // The two frame buffers, allocated in setup() and never freed. Their contents are written on the + // decode thread and read by whatever draws the views, so only their roles are swapped, never the + // pointers themselves. + std::array buffers_{}; + + // pending_mutex_ guards the two fields below, the only state shared across threads. Everything + // after them is touched on the main loop only. + Mutex pending_mutex_; + // Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds + // the outgoing frame and is the next decode target. Written on the main loop, read on the + // decode thread. + uint8_t current_index_{0}; + // Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1]. + bool frame_pending_{false}; + + // True once artwork has been displayed, until the next clear; decides whether the outgoing frame + // is real artwork or the black the buffers start as. Main loop only. + bool showing_artwork_{false}; + // True while a displayed frame awaits transition_finished(); gates duplicate or stray calls + // so exactly one ack reaches the library per delivery. Main loop only. + bool transition_pending_{false}; + + ArtworkImageView *current_image_; + ArtworkImageView *transition_image_{nullptr}; + int width_; + int height_; + uint8_t slot_; + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_clear_callback_{}; + LazyCallbackManager image_error_callback_{}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index beb2028689..fe0bda6f42 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() { 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->set_playback_state_(new_state); } }); @@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() { } }); + // The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so + // without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since + // media_player has no way to express an unknown volume. + this->parent_->add_controller_state_clear_callback( + [this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); }); + // Publish an initial state this->state = media_player::MEDIA_PLAYER_STATE_IDLE; this->publish_state(); } +// THREAD CONTEXT: Main loop (called from the callbacks registered in setup()) +void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) { + if (this->state == new_state) { + return; + } + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); +} + // THREAD CONTEXT: Main loop (invoked by the media_player framework) media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { auto traits = media_player::MediaPlayerTraits(); diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 651e1562be..ff76473189 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med // Receives commands from HA void control(const media_player::MediaPlayerCall &call) override; + /// @brief Publishes @p new_state if it differs from the current state. + void set_playback_state_(media_player::MediaPlayerState new_state); + float volume_increment_{0.05f}; bool muted_{false}; }; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04dbab0080..028491284a 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,12 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_SENDSPIN_ARTWORK +// Indexed by the library enums, which start at zero and are contiguous. +static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +#endif + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -37,6 +43,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_); + this->artwork_role_->set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -67,6 +78,18 @@ void SendspinHub::dump_config() { " Client ID: %s\n" " Task stack in PSRAM: %s", get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + +#ifdef USE_SENDSPIN_ARTWORK + // Slot indices come from the order the image platform entries were declared, so the log is the + // only place the mapping from a slot to the artwork it asked for can be read back. + uint8_t slot = 0; + for (const auto &preference : this->artwork_config_.preferred_formats) { + ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++, + IMAGE_SOURCE_NAMES[static_cast(preference.source)], + IMAGE_FORMAT_NAMES[static_cast(preference.format)], preference.width, preference.height, + preference.display_offset_ms); + } +#endif } // --- Delegating methods --- @@ -174,6 +197,30 @@ std::optional SendspinHub::load_last_server_hash() { // --- Sendspin role specific methods/overrides --- +#ifdef USE_SENDSPIN_ARTWORK +// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too +void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length, + sendspin::SendspinImageFormat format) { + this->artwork_image_decode_callbacks_.call(slot, data, length, format); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display +// deadline is reached; lateness_ms reports how far past the deadline the display slipped) +void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) { + this->artwork_image_display_callbacks_.call(slot, lateness_ms); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented) +void SendspinHub::artwork_frame_done(uint8_t slot) { + if (this->artwork_role_ != nullptr) { + this->artwork_role_->frame_done(slot); + } +} +#endif + #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, @@ -192,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); } + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values +// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate +// callback lets each child clear only what it can represent. +void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); } #endif #ifdef USE_SENDSPIN_METADATA @@ -200,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat this->metadata_update_callbacks_.call(metadata); } +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is +// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback +// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown. +void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); } + // THREAD CONTEXT: Main loop (invoked from Sendspin components) uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c6b1ed97f7..7c50c3eb80 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,9 @@ #include #include +#ifdef USE_SENDSPIN_ARTWORK +#include +#endif #ifdef USE_SENDSPIN_CONTROLLER #include #endif @@ -69,6 +72,9 @@ struct StaticDelayPref { /// (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_ARTWORK + public sendspin::ArtworkRoleListener, +#endif #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, #endif @@ -121,6 +127,27 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods --- +#ifdef USE_SENDSPIN_ARTWORK + void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; } + + /// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot. + /// + /// Every slot is configured with the library's require_frame_done gate, which withholds the + /// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a + /// redundant call is a safe no-op in the library. Must be called from the main loop thread. + void artwork_frame_done(uint8_t slot); + + template void add_image_decode_callback(F &&callback) { + this->artwork_image_decode_callbacks_.add(std::forward(callback)); + } + template void add_image_display_callback(F &&callback) { + this->artwork_image_display_callbacks_.add(std::forward(callback)); + } + template void add_image_clear_callback(F &&callback) { + this->artwork_image_clear_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_CONTROLLER void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); @@ -128,9 +155,18 @@ class SendspinHub final : public Component, template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } + + /// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped. + template void add_controller_state_clear_callback(F &&callback) { + this->controller_state_clear_callbacks_.add(std::forward(callback)); + } #endif #ifdef USE_SENDSPIN_METADATA + /// @brief Registers a callback that fires when the server sends metadata. + /// + /// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning + /// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update. template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } @@ -171,13 +207,34 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods/overrides/member variables --- +#ifdef USE_SENDSPIN_ARTWORK + void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override; + + void on_image_display(uint8_t slot, uint32_t lateness_ms) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + sendspin::ArtworkRole *artwork_role_{nullptr}; + + // Callback fan-out to child components; they filter by slot as needed. + CallbackManager + artwork_image_decode_callbacks_{}; + CallbackManager artwork_image_display_callbacks_{}; + CallbackManager artwork_image_clear_callbacks_{}; +#endif + #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_{}; + void on_controller_state_clear() override; + + // Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch + // action and the media source enable the controller role without one, so keep the idle cost to a single pointer. + LazyCallbackManager controller_state_callbacks_{}; + LazyCallbackManager controller_state_clear_callbacks_{}; #endif #ifdef USE_SENDSPIN_METADATA @@ -185,6 +242,8 @@ class SendspinHub final : public Component, void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + void on_metadata_clear() override; + // Callback fan-out to child components; they filter as needed CallbackManager metadata_update_callbacks_{}; #endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 68848a6f3e..dcbab75b65 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -4,6 +4,8 @@ #include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.sensor"; @@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() { void SendspinTrackProgressSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { if (!metadata.progress.has_value()) { + // Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and + // report unknown rather than leaving the last position frozen on the frontend. Only the transition is published; + // NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update. + this->stop_poller(); + if (!std::isnan(this->get_raw_state())) { + this->publish_state(NAN); + } return; } const auto &progress = metadata.progress.value(); @@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() { this->start_poller(); } }); + + // PollingComponent starts the poller before setup(), but there is nothing to interpolate yet: + // get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick + // from boot until the first metadata arrives. The callback above starts it once playback is running. + this->stop_poller(); } // THREAD CONTEXT: Main loop. @@ -80,15 +94,19 @@ std::optional SendspinMetadataSensor::extract_value_(const sendspin::Serv // (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); - } + // A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for + // unknown) rather than skipped, so a value that goes away does not linger from the previous track. + this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN)); }); } // 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) { + const float current = this->get_raw_state(); + // The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here + // as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to + // itself, so a field that stays cleared would republish on every metadata update without the second check. + if (current != value && !(std::isnan(current) && std::isnan(value))) { this->publish_state(value); } } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 9843fb966e..554e01cf88 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to +// show, so return the empty string and let the caller publish it; returning early would leave the previous track's +// value on display. +// +// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag, +// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published, +// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown. 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; + return metadata.title.has_value() ? metadata.title.value().c_str() : ""; case SendspinTextMetadataTypes::ARTIST: - if (metadata.artist.has_value()) - return metadata.artist.value().c_str(); - return nullptr; + return metadata.artist.has_value() ? metadata.artist.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM: - if (metadata.album.has_value()) - return metadata.album.value().c_str(); - return nullptr; + return metadata.album.has_value() ? metadata.album.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM_ARTIST: - if (metadata.album_artist.has_value()) - return metadata.album_artist.value().c_str(); - return nullptr; + return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : ""; } - return nullptr; + return ""; } // 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); - } + this->publish_if_changed_(this->extract_value_(metadata)); }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. void SendspinTextSensor::publish_if_changed_(const char *value) { + // The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the + // entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the + // empty string and fire on_value with it. if (this->get_raw_state() != value) { this->publish_state(value); } diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 04c94e9292..4b3a907416 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -89,8 +89,14 @@ void SerialProxy::dump_config() { this->dtr_pin_ != nullptr ? "configured" : "not configured"); } -void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, - uint8_t data_size) { +void SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint8_t stop_bits, uint8_t data_size) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif ESP_LOGD(TAG, "Configuring serial proxy [%" PRIu32 "]: baud=%" PRIu32 ", flow_ctrl=%s, parity=%" PRIu8 ", stop=%" PRIu8 ", data=%" PRIu8, @@ -143,13 +149,27 @@ void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity } } -void SerialProxy::write_from_client(const uint8_t *data, size_t len) { +void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { +#ifdef USE_API + // Bytes from a client other than the live subscriber would interleave with the + // subscriber's traffic on the wire + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif if (data == nullptr || len == 0) return; this->write_array(data, len); } -void SerialProxy::set_modem_pins(uint32_t line_states) { +void SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { +#ifdef USE_API + if (this->port_claimed_by_other_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + return; + } +#endif const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0; const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); @@ -175,13 +195,28 @@ uart::UARTFlushResult SerialProxy::flush_port() { } #ifdef USE_API +bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { + return this->api_connection_ != nullptr && this->api_connection_ != api_connection && + this->api_connection_->is_connection_setup(); +} + void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; this->enable_loop(); ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e35fab3d42..268c1b52be 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -67,12 +67,14 @@ class SerialProxy final : public uart::UARTDevice, public Component { api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } /// Configure UART parameters and apply them + /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second /// @param flow_control True to enable hardware flow control /// @param parity Parity setting (0=none, 1=even, 2=odd) /// @param stop_bits Number of stop bits (1 or 2) /// @param data_size Number of data bits (5-8) - void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size); + void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint8_t stop_bits, uint8_t data_size); /// Get the currently subscribed API connection (nullptr if none) api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -81,12 +83,13 @@ class SerialProxy final : public uart::UARTDevice, public Component { void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type); /// Write data received from an API client to the serial device + /// @param api_connection The API connection sending the data /// @param data Pointer to data buffer /// @param len Number of bytes to write - void write_from_client(const uint8_t *data, size_t len); + void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len); /// Set modem pin states from a bitmask of SerialProxyLineStateFlag values - void set_modem_pins(uint32_t line_states); + void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states); /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; @@ -104,6 +107,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { #ifdef USE_API /// Read from UART and send to API client (slow path with 256-byte stack buffer) void read_and_send_(size_t available); + + /// True when a live subscriber other than the given connection holds the port + bool port_claimed_by_other_(api::APIConnection *api_connection) const; #endif /// Instance index for identifying this proxy in API messages diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index d407f20a4e..87ef050bc1 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -35,9 +36,9 @@ CONF_HUMIDITY_SOURCE = "humidity_source" def validate_sensors(config): - if CONF_VOC not in config and CONF_NOX not in config: + if CONF_VOC_INDEX not in config and CONF_NOX_INDEX not in config: raise cv.Invalid( - f"At least one sensor is required. Define {CONF_VOC} and/or {CONF_NOX}" + f"At least one sensor is required. Define {CONF_VOC_INDEX} and/or {CONF_NOX_INDEX}" ) return config @@ -65,15 +66,17 @@ VOC_SENSOR = _gas_sensor_schema(100) NOX_SENSOR = _gas_sensor_schema(1) CONFIG_SCHEMA = cv.All( + cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sgp4x"), + cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sgp4x"), cv.Schema( { cv.GenerateID(): cv.declare_id(SGP4xComponent), - cv.Optional(CONF_VOC): sensor.sensor_schema( + cv.Optional(CONF_VOC_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ).extend(VOC_SENSOR), - cv.Optional(CONF_NOX): sensor.sensor_schema( + cv.Optional(CONF_NOX_INDEX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -107,11 +110,11 @@ async def to_code(config): cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) - if CONF_VOC in config: - sens = await sensor.new_sensor(config[CONF_VOC]) + if CONF_VOC_INDEX in config: + sens = await sensor.new_sensor(config[CONF_VOC_INDEX]) cg.add(var.set_voc_sensor(sens)) - if CONF_ALGORITHM_TUNING in config[CONF_VOC]: - cfg = config[CONF_VOC][CONF_ALGORITHM_TUNING] + if CONF_ALGORITHM_TUNING in config[CONF_VOC_INDEX]: + cfg = config[CONF_VOC_INDEX][CONF_ALGORITHM_TUNING] cg.add( var.set_voc_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -123,11 +126,11 @@ async def to_code(config): ) ) - if CONF_NOX in config: - sens = await sensor.new_sensor(config[CONF_NOX]) + if CONF_NOX_INDEX in config: + sens = await sensor.new_sensor(config[CONF_NOX_INDEX]) cg.add(var.set_nox_sensor(sens)) - if CONF_ALGORITHM_TUNING in config[CONF_NOX]: - cfg = config[CONF_NOX][CONF_ALGORITHM_TUNING] + if CONF_ALGORITHM_TUNING in config[CONF_NOX_INDEX]: + cfg = config[CONF_NOX_INDEX][CONF_ALGORITHM_TUNING] cg.add( var.set_nox_algorithm_tuning( cfg[CONF_INDEX_OFFSET], diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index db56bd13f0..bc6fe794a0 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -18,7 +18,7 @@ void SGP4xComponent::setup() { this->mark_failed(); return; } - this->serial_number_ = (uint64_t(raw_serial_number[0]) << 24) | (uint64_t(raw_serial_number[1]) << 16) | + this->serial_number_ = (uint64_t(raw_serial_number[0]) << 32) | (uint64_t(raw_serial_number[1]) << 16) | (uint64_t(raw_serial_number[2])); ESP_LOGD(TAG, "Serial number: %" PRIu64, this->serial_number_); @@ -32,7 +32,6 @@ void SGP4xComponent::setup() { featureset &= 0x1FF; if (featureset == SGP40_FEATURESET) { this->sgp_type_ = SGP40; - this->self_test_time_ = SPG40_SELFTEST_TIME; this->measure_time_ = SGP40_MEASURE_TIME; if (this->nox_sensor_) { ESP_LOGE(TAG, "SGP41 required for NOx, disabling NOx sensor"); @@ -42,7 +41,6 @@ void SGP4xComponent::setup() { } } else if (featureset == SGP41_FEATURESET) { this->sgp_type_ = SGP41; - this->self_test_time_ = SPG41_SELFTEST_TIME; this->measure_time_ = SGP41_MEASURE_TIME; } else { ESP_LOGD(TAG, "Unknown feature set 0x%0X", featureset); @@ -52,29 +50,6 @@ void SGP4xComponent::setup() { ESP_LOGD(TAG, "Version 0x%0X", featureset); - if (this->store_baseline_) { - // Hash with config hash, version, and serial number - // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), this->serial_number_); - this->pref_ = global_preferences->make_preference(hash, true); - - if (this->pref_.load(&this->voc_baselines_storage_)) { - this->voc_state0_ = this->voc_baselines_storage_.state0; - this->voc_state1_ = this->voc_baselines_storage_.state1; - ESP_LOGV(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); - } - - // Initialize storage timestamp - this->seconds_since_last_store_ = 0; - - if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) { - ESP_LOGV(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); - voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); - } - } if (this->voc_sensor_ && this->voc_tuning_params_.has_value()) { voc_algorithm_.set_tuning_parameters( voc_tuning_params_.value().index_offset, voc_tuning_params_.value().learning_time_offset_hours, @@ -89,6 +64,33 @@ void SGP4xComponent::setup() { nox_tuning_params_.value().std_initial, nox_tuning_params_.value().gain_factor); } + if (this->store_baseline_) { + // Initialize storage timestamp + this->seconds_since_last_store_ = 0; + + // Hash with config hash, version, and serial number + // This ensures the baseline storage is cleared after OTA + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict + uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), this->serial_number_); + this->pref_ = global_preferences->make_preference(hash, true); + + if (this->pref_.load(&this->voc_baselines_storage_)) { + this->voc_state0_ = this->voc_baselines_storage_.state0; + this->voc_state1_ = this->voc_baselines_storage_.state1; + + ESP_LOGV(TAG, "Loaded VOC baseline state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); + + if (std::isnormal(this->voc_baselines_storage_.state0) && std::isnormal(this->voc_baselines_storage_.state1)) { + ESP_LOGV(TAG, "Setting VOC baseline from save state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); + // Sensirion advises restoring states only after interruptions shorter than 10 minutes; with no way to know + // how long the device was off, restoring a stale state still beats a fresh 12-hour learning phase + voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); + } + } + } + this->self_test_(); /* The official spec for this sensor at @@ -112,11 +114,15 @@ void SGP4xComponent::self_test_() { this->error_code_ = COMMUNICATION_FAILED; ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); this->mark_failed(); + return; } - this->set_timeout(this->self_test_time_, [this]() { + this->set_timeout(SGP4X_SELF_TEST_TIME, [this]() { uint16_t reply = 0; - if (!this->read_data(reply) || (reply != 0xD400)) { + // SGP40: MSB is 0xD4 on success, LSB is undefined; SGP41: MSB is undefined, LSB bits 0/1 flag VOC/NOx pixel + // failures + bool passed = this->read_data(reply) && (this->sgp_type_ == SGP41 ? (reply & 0x0003) == 0 : (reply >> 8) == 0xD4); + if (!passed) { this->error_code_ = SELF_TEST_FAILED; ESP_LOGW(TAG, "Self-test failed (0x%X)", reply); this->mark_failed(); @@ -134,19 +140,19 @@ void SGP4xComponent::update_gas_indices_() { if (this->nox_sensor_ != nullptr) this->nox_index_ = this->nox_algorithm_.process(this->nox_sraw_); ESP_LOGV(TAG, "VOC: %" PRId32 ", NOx: %" PRId32, this->voc_index_, this->nox_index_); - // Store baselines after defined interval or if the difference between current and stored baseline becomes too - // much + // Store baselines once the minimum interval has passed and the state has drifted from the stored copy; + // both conditions limit flash wear if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) { this->voc_algorithm_.get_states(this->voc_state0_, this->voc_state1_); - if (std::abs(this->voc_baselines_storage_.state0 - this->voc_state0_) > MAXIMUM_STORAGE_DIFF || - std::abs(this->voc_baselines_storage_.state1 - this->voc_state1_) > MAXIMUM_STORAGE_DIFF) { + if (std::abs(this->voc_baselines_storage_.state0 - this->voc_state0_) > MAXIMUM_STORAGE_DIFF_STATE0 || + std::abs(this->voc_baselines_storage_.state1 - this->voc_state1_) > MAXIMUM_STORAGE_DIFF_STATE1) { this->seconds_since_last_store_ = 0; this->voc_baselines_storage_.state0 = this->voc_state0_; this->voc_baselines_storage_.state1 = this->voc_state1_; if (this->pref_.save(&this->voc_baselines_storage_)) { - ESP_LOGV(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); + ESP_LOGV(TAG, "Stored VOC baseline state0: %f, state1: %f", this->voc_baselines_storage_.state0, + this->voc_baselines_storage_.state1); } else { ESP_LOGW(TAG, "Storing VOC baselines failed"); } @@ -185,27 +191,28 @@ void SGP4xComponent::measure_raw_() { uint16_t command; uint16_t data[2]; size_t response_words; - // Use SGP40 measure command if we don't care about NOx - if (nox_sensor_ == nullptr) { + if (this->sgp_type_ == SGP40) { command = SGP40_CMD_MEASURE_RAW; response_words = 1; + } else if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { + // SGP41 must run the NOx conditioning command for the first 10 seconds + command = SGP41_CMD_NOX_CONDITIONING; + response_words = 1; } else { - // SGP41 sensor must use NOx conditioning command for the first 10 seconds - if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { - command = SGP41_CMD_NOX_CONDITIONING; - response_words = 1; - } else { - this->nox_conditioning_start_.reset(); - command = SGP41_CMD_MEASURE_RAW; - response_words = 2; - } + this->nox_conditioning_start_.reset(); + command = SGP41_CMD_MEASURE_RAW; + response_words = 2; + } + if (command == SGP41_CMD_NOX_CONDITIONING) { + // Conditioning requires the default parameters (compensation disabled) + data[0] = 0x8000; + data[1] = 0x6666; + } else { + // first parameter are the relative humidity ticks + data[0] = (uint16_t) std::llround((humidity * 65535) / 100); + // second parameter are the temperature ticks + data[1] = (uint16_t) (((temperature + 45) * 65535) / 175); } - uint16_t rhticks = (uint16_t) std::llround((humidity * 65535) / 100); - uint16_t tempticks = (uint16_t) (((temperature + 45) * 65535) / 175); - // first parameter are the relative humidity ticks - data[0] = rhticks; - // secomd parameter are the temperature ticks - data[1] = tempticks; if (!this->write_command(command, data, 2)) { ESP_LOGD(TAG, "write error (%d)", this->last_error_); @@ -232,7 +239,9 @@ void SGP4xComponent::measure_raw_() { void SGP4xComponent::take_sample() { if (!this->self_test_complete_) return; - this->seconds_since_last_store_ += 1; + if (this->store_baseline_) { + this->seconds_since_last_store_ += 1; + } this->measure_raw_(); } @@ -275,7 +284,8 @@ void SGP4xComponent::dump_config() { " Type: %s\n" " Serial number: %" PRIu64 "\n" " Minimum Samples: %f", - sgp_type_ == SGP41 ? "SGP41" : "SPG40", this->serial_number_, GasIndexAlgorithm_INITIAL_BLACKOUT); + this->sgp_type_ == SGP41 ? "SGP41" : "SGP40", this->serial_number_, + GasIndexAlgorithm_INITIAL_BLACKOUT); } LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index a40188e629..2aaf06601b 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -14,9 +14,9 @@ namespace esphome::sgp4x { struct SGP4xBaselines { - int32_t state0; - int32_t state1; -} PACKED; // NOLINT + float state0; + float state1; +}; enum SgpType { SGP40, SGP41 }; @@ -39,17 +39,19 @@ static const uint16_t SGP4X_CMD_SELF_TEST = 0x280e; static const uint16_t SGP40_CMD_MEASURE_RAW = 0x260F; static const uint16_t SGP41_CMD_MEASURE_RAW = 0x2619; static const uint16_t SGP41_CMD_NOX_CONDITIONING = 0x2612; -static const uint8_t SGP41_SUBCMD_NOX_CONDITIONING = 0x12; // Shortest time interval of 3H for storing baseline values. // Prevents wear of the flash because of too many write operations const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; -static const uint16_t SPG40_SELFTEST_TIME = 250; // 250 ms for self test -static const uint16_t SPG41_SELFTEST_TIME = 320; // 320 ms for self test +static const uint16_t SGP4X_SELF_TEST_TIME = 320; // maximum self-test duration for both SGP40 and SGP41 static const uint16_t SGP40_MEASURE_TIME = 30; static const uint16_t SGP41_MEASURE_TIME = 55; -// Store anyway if the baseline difference exceeds the max storage diff value -const float MAXIMUM_STORAGE_DIFF = 50.0f; +// Once the store interval has passed, store only if the baseline drifted from the stored copy by more than these +// state0 is mean of variance estimator, hence can have larger absolute values and a larger diff threshold +const float MAXIMUM_STORAGE_DIFF_STATE0 = 50.0f; +// state1 is std of variance estimator, so it typically has smaller absolute values than state0, hence we use a smaller +// diff threshold +const float MAXIMUM_STORAGE_DIFF_STATE1 = 5.0f; class SGP4xComponent; @@ -111,7 +113,6 @@ class SGP4xComponent final : public PollingComponent, uint64_t serial_number_; bool self_test_complete_; - uint16_t self_test_time_; sensor::Sensor *voc_sensor_{nullptr}; VOCGasIndexAlgorithm voc_algorithm_; diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index f2ab5a4bc1..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,7 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -75,45 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - 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}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index c038426f61..0358ed278f 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -13,7 +13,7 @@ using SPIInterface = spi_host_device_t; -#elif defined(USE_ARDUINO) +#elif defined(USE_ARDUINO) && !defined(USE_LIBRETINY) #include diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index ea79054c88..b4fcf36c9e 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -1,5 +1,7 @@ from collections import ChainMap +from itertools import product import logging +import re from typing import Any import esphome @@ -7,6 +9,7 @@ from esphome import core from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS +from esphome.expression import JINJA_PROG from esphome.types import ConfigType from esphome.util import OrderedDict from esphome.yaml_util import ( @@ -27,6 +30,14 @@ _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] ErrList = list[tuple[UndefinedError, DocumentPath, Any]] +# Candidate-pattern shaping for include_candidate_patterns. +_ADJACENT_WILDCARDS_RE = re.compile(r"\*+") +# Dots are included so a variant like `../*` counts as fully dynamic too; +# it would otherwise glob everything in the parent directory. +_WILDCARDS_ONLY_RE = re.compile(r"[*./\\]+") +_GLOB_META_RE = re.compile(r"[?\[]") +_STRING_LITERAL_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"") + # 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() @@ -360,9 +371,7 @@ def resolve_include( ) substituted = filename != original_str if substituted: - include = IncludeFile( - include.parent_file, filename, include.vars, include.yaml_loader - ) + include = include.with_file(filename) try: return include.load() except esphome.core.EsphomeError as err: @@ -374,6 +383,45 @@ def resolve_include( ) from err +def include_candidate_patterns(value: str) -> list[str]: + """Expand a substitution/Jinja-templated path into glob-style candidate patterns. + + Mirrors the two phases of :func:`_expand_substitutions` without variable + values: ``$var`` / ``${var}`` references become ``*`` and each remaining + Jinja expression contributes one pattern per quoted string literal it + holds (``*`` when it holds none), so every conditional branch is a + candidate — deliberately over-inclusive. Emitted wildcard patterns are + glob-safe: adjacent wildcards collapse (no recursive ``**``), ``[`` / + ``?`` from the filename text are escaped, and variants reduced to + nothing but wildcards, dots and separators are dropped so a fully + dynamic filename never expands to "everything in the directory", + including via a ``../*`` parent traversal. + """ + # Replacing $var / ${var} first also keeps JINJA_PROG's first-} span + # matching correct for references nested inside string literals, the + # same ordering _expand_substitutions relies on. + value = cv.VARIABLE_PROG.sub("*", value) + options = [ + [a or b for a, b in _STRING_LITERAL_RE.findall(expr)] or ["*"] + for expr in JINJA_PROG.findall(value) + ] + + variants: list[str] = [] + for combination in product(*options): + replacements = iter(combination) + spliced = JINJA_PROG.sub(lambda _, _next=replacements: next(_next), value) + variants.append(_ADJACENT_WILDCARDS_RE.sub("*", spliced)) + + patterns: list[str] = [] + for variant in dict.fromkeys(variants): + if not variant or _WILDCARDS_ONLY_RE.fullmatch(variant): + continue + if "*" in variant: + variant = _GLOB_META_RE.sub(r"[\g<0>]", variant) + patterns.append(variant) + return patterns + + def _substitute_include( include: IncludeFile, path: DocumentPath, diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index cd2ddbbb38..e00895d162 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -57,7 +57,7 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { */ if (buf_index_ >= (MAX_BUF_SIZE - 1)) { ESP_LOGW(TAG, "Internal buffer full"); - state_ = OFF; + state_ = STATE_OFF; return false; } buf_[buf_index_++] = received; @@ -65,18 +65,18 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { return false; } -void TeleInfo::setup() { state_ = OFF; } +void TeleInfo::setup() { state_ = STATE_OFF; } void TeleInfo::update() { - if (state_ == OFF) { + if (state_ == STATE_OFF) { buf_index_ = 0; - state_ = ON; + state_ = STATE_ON; } } void TeleInfo::loop() { switch (state_) { - case OFF: + case STATE_OFF: break; - case ON: + case STATE_ON: /* Dequeue chars until start frame (0x2) */ if (read_chars_until_(true, 0x2)) state_ = START_FRAME_RECEIVED; @@ -173,7 +173,7 @@ void TeleInfo::loop() { publish_value_(std::string(tag_), std::string(val_)); } - state_ = OFF; + state_ = STATE_OFF; break; } } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index 83ea1474f2..4aab3bf2cd 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -40,11 +40,11 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { char val_[MAX_VAL_SIZE]; char timestamp_[MAX_TIMESTAMP_SIZE]; enum State { - OFF, - ON, + STATE_OFF, + STATE_ON, START_FRAME_RECEIVED, END_FRAME_RECEIVED, - } state_{OFF}; + } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); bool check_crc_(const char *grp, const char *grp_end); void publish_value_(const std::string &tag, const std::string &val); diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index af134e6ed4..ffe11cf229 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,18 +20,14 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - // For future hash migration: use migrate_entity_preference_() with: - // old_key = get_preference_hash() + extra - // new_key = get_preference_hash_v2() + extra - // See: https://github.com/esphome/backlog/issues/85 -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash(); -#pragma GCC diagnostic pop - key += this->traits.get_min_length() << 2; - key += this->traits.get_max_length() << 4; - key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - this->pref_->setup(key, value); + uint32_t extra = 0; + extra += this->traits.get_min_length() << 2; + extra += this->traits.get_max_length() << 4; + extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + // TextSaver::setup() picks the key for the platform and migrates old data once + uint32_t key = this->preference_key_base_() + extra; + uint32_t old_key = this->old_preference_key_base_() + extra; + this->pref_->setup(key, old_key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 229a61d9b8..beeea4396a 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,7 +14,9 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - virtual void setup(uint32_t id, std::string &value) {} + /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. + /// See: https://github.com/esphome/backlog/issues/85 + virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -45,11 +47,16 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, std::string &value) override { - this->pref_ = global_preferences->make_preference(id); - + void setup(uint32_t id, uint32_t old_id, std::string &value) override { char temp[SZ + 1]; +#ifdef USE_PREFERENCE_KEY_LOOKUP + this->pref_ = global_preferences->make_preference(id); + bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); +#else + // Slot-based backends keep the old key; it is only a validity tag on a positional slot + this->pref_ = global_preferences->make_preference(old_id); bool hasdata = this->pref_.load(&temp); +#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index de63229621..d0d6cdacb7 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( CODEOWNERS = ["@sittner"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") ThermoProBLE = thermopro_ble_ns.class_( - "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("thermopro_ble"), cv.Schema( { cv.GenerateID(): cv.declare_id(ThermoProBLE), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2a950d3664..d10a6c33cd 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -2,8 +2,6 @@ #include #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::thermopro_ble { // this size must be large enough to hold the largest data frame @@ -34,7 +32,7 @@ void ThermoProBLE::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { // check for matching mac address if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); @@ -66,8 +64,8 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } // reconstruct whole record from 2 byte uuid and data - esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); - uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + uint16_t svc_uuid16 = service_data.uuid.uuid16(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(svc_uuid16), static_cast(svc_uuid16 >> 8)}; std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); // dispatch data to parser @@ -92,7 +90,7 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -void ThermoProBLE::update_device_type_(const std::string &device_name) { +void ThermoProBLE::update_device_type_(StringRef device_name) { // check for changed device name (should only happen on initial call) if (this->device_name_ == device_name) { return; @@ -202,5 +200,3 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz } } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 2d7523e07a..1c05516e47 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -2,9 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::thermopro_ble { @@ -17,11 +15,11 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -41,9 +39,7 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi sensor::Sensor *humidity_{nullptr}; sensor::Sensor *battery_level_{nullptr}; - void update_device_type_(const std::string &device_name); + void update_device_type_(StringRef device_name); }; } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index b748959571..c8c36f0ffb 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -12,7 +12,7 @@ static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { - static char mac_addr_buf[13]; + static char mac_addr_buf[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr_buf); this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf; } diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 3058d82cc4..15ab4b6dc3 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -303,6 +303,22 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff ESP_LOGW(TAG, "LOCAL_TIME_QUERY is not handled because time is not configured"); } break; + case TuyaCommandType::GMT_TIME_QUERY: +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + this->send_gmt_time_(); + + if (!this->gmt_time_sync_callback_registered_) { + // tuya mcu supports time, so we let them know when our time changed + this->time_id_->add_on_time_sync_callback([this] { this->send_gmt_time_(); }); + this->gmt_time_sync_callback_registered_ = true; + } + } else +#endif + { + ESP_LOGW(TAG, "GMT_TIME_QUERY is not handled because time is not configured"); + } + break; case TuyaCommandType::VACUUM_MAP_UPLOAD: this->send_command_( TuyaCommand{.cmd = TuyaCommandType::VACUUM_MAP_UPLOAD, .payload = std::vector{0x01}}); @@ -609,6 +625,25 @@ void Tuya::send_local_time_() { } this->send_command_(TuyaCommand{.cmd = TuyaCommandType::LOCAL_TIME_QUERY, .payload = payload}); } +void Tuya::send_gmt_time_() { + std::vector payload; + ESPTime now = this->time_id_->utcnow(); + if (now.is_valid()) { + uint8_t year = now.year - 2000; + uint8_t month = now.month; + uint8_t day_of_month = now.day_of_month; + uint8_t hour = now.hour; + uint8_t minute = now.minute; + uint8_t second = now.second; + ESP_LOGD(TAG, "Sending gmt time"); + payload = std::vector{0x01, year, month, day_of_month, hour, minute, second}; + } else { + // By spec we need to notify MCU that the time was not obtained if this is a response to a query + ESP_LOGW(TAG, "Sending missing gmt time"); + payload = std::vector{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + } + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::GMT_TIME_QUERY, .payload = payload}); +} #endif void Tuya::set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value) { diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 4e7ab5c7f9..b8bf4e0ab1 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -54,6 +54,7 @@ enum class TuyaCommandType : uint8_t { DATAPOINT_DELIVER = 0x06, DATAPOINT_REPORT_ASYNC = 0x07, DATAPOINT_QUERY = 0x08, + GMT_TIME_QUERY = 0x0C, WIFI_TEST = 0x0E, LOCAL_TIME_QUERY = 0x1C, DATAPOINT_REPORT_SYNC = 0x22, @@ -138,8 +139,10 @@ class Tuya final : public Component, public uart::UARTDevice { #ifdef USE_TIME void send_local_time_(); + void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; bool time_sync_callback_registered_{false}; + bool gmt_time_sync_callback_registered_{false}; #endif TuyaInitState init_state_ = TuyaInitState::INIT_HEARTBEAT; bool init_failed_{false}; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4172e7c164..fbf0c20ded 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -18,7 +18,7 @@ namespace esphome::uart { static const char *const TAG = "uart.lt"; -static const char *UART_TYPE[] = { +static const char *const UART_TYPE[] = { "hardware", "software", }; @@ -45,19 +45,19 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { - int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); - int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); - bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + int16_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); + int16_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); - auto shouldFallbackToSoftwareSerial = [&]() -> bool { - auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + auto should_fallback_to_software_serial = [&]() -> bool { + auto has_flags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; - if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || - hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { + if (has_flags(this->tx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + has_flags(this->rx_pin_, + gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { #if LT_ARD_HAS_SOFTSERIAL - ESP_LOGI(TAG, "Pins has flags set. Using Software Serial"); + ESP_LOGI(TAG, "Pins have flags set. Using Software Serial"); return true; #else ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); @@ -66,25 +66,26 @@ void LibreTinyUARTComponent::setup() { return false; }; - if (false) + if (false) { // NOLINT(readability-simplify-boolean-expr) return; + } #if LT_HW_UART0 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial0; this->hardware_idx_ = 0; } #endif #if LT_HW_UART1 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial1; this->hardware_idx_ = 1; } #endif #if LT_HW_UART2 else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && - !shouldFallbackToSoftwareSerial()) { + !should_fallback_to_software_serial()) { this->serial_ = &Serial2; this->hardware_idx_ = 2; } @@ -97,6 +98,8 @@ void LibreTinyUARTComponent::setup() { if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { this->tx_pin_->setup(); } + bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); + bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); #else this->serial_ = &Serial; @@ -133,7 +136,7 @@ void LibreTinyUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); } ESP_LOGCONFIG(TAG, - " Baud Rate: %u baud\n" + " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" " Stop bits: %u", diff --git a/esphome/components/udp/packet_transport/__init__.py b/esphome/components/udp/packet_transport/__init__.py index b6957a372b..e725276717 100644 --- a/esphome/components/udp/packet_transport/__init__.py +++ b/esphome/components/udp/packet_transport/__init__.py @@ -1,12 +1,11 @@ import esphome.codegen as cg -from esphome.components.api import CONF_ENCRYPTION from esphome.components.packet_transport import ( CONF_PING_PONG_ENABLE, PacketTransport, new_packet_transport, transport_schema, ) -from esphome.const import CONF_BINARY_SENSORS, CONF_SENSORS +from esphome.const import CONF_BINARY_SENSORS, CONF_ENCRYPTION, CONF_SENSORS from esphome.cpp_types import PollingComponent from .. import UDP_SCHEMA, register_udp_client, udp_ns diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 1380c34284..bafdb5d853 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -4,13 +4,23 @@ #include "esphome/core/log.h" #include +#include +#include namespace esphome::ufm01 { static const char *const TAG = "ufm01"; static constexpr uint8_t COMMAND_ACK = 0xE5; -static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t POST_RESET_DELAY_MS = 2000; +static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; +static constexpr uint32_t STARTUP_RETRY_MS = 3000; +static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; +static constexpr uint32_t ACTIVE_STALE_MS = 5000; +static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; @@ -18,12 +28,14 @@ static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; // Active-mode frame layout (datasheet Table 7) static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; @@ -55,7 +67,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp return false; } -static bool validate_data(uint8_t data[FRAME_SIZE]) { +static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { uint8_t sum = 0; for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) sum += data[i]; @@ -68,13 +80,43 @@ static bool validate_data(uint8_t data[FRAME_SIZE]) { check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); } -static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { +static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += data[i]; + return data[21] == (sum & 0xFF); +} + +static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { + std::memset(active, 0, FRAME_SIZE); + active[0] = FRAME_START_BYTE_1; + active[1] = FRAME_START_BYTE_2; + active[7] = 0x01; + active[8] = passive[2]; + for (size_t i = 0; i < 6; ++i) + active[9 + i] = passive[3 + i]; + active[15] = passive[9]; + for (size_t i = 0; i < 5; ++i) + active[16 + i] = passive[10 + i]; + active[21] = FRAME_FLAG_RESERVED_SECTION; + active[24] = passive[15]; + for (size_t i = 0; i < 3; ++i) + active[25 + i] = passive[16 + i]; + active[28] = passive[19]; + active[29] = passive[20]; + active[30] = passive[21]; + active[31] = FRAME_STOP_BYTE; +} + +static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); } -static float read_flow(uint8_t data[FRAME_SIZE]) { +static float read_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + to_float(data[16]) * 0.01f) * @@ -86,7 +128,7 @@ static void log_hex(const uint8_t *data, size_t len) { ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } -static float read_temperature(uint8_t data[FRAME_SIZE]) { +static float read_temperature(const uint8_t data[FRAME_SIZE]) { // happens sometimes before getting a real reading if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { return NAN; @@ -106,19 +148,39 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; } -bool UFM01Component::send_command_(const std::array &command) { +void UFM01Component::flush_rx_() { + while (this->available()) { + uint8_t byte; + this->read_byte(&byte); + } + this->read_index_ = 0; +} + +void UFM01Component::send_command_no_wait_(const std::array &command) { + this->flush_rx_(); this->write_array(command); this->flush(); +} + +// Drains whatever is currently in the RX buffer, looking for a command ACK. +bool UFM01Component::consume_ack_() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + return false; + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + return false; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->send_command_no_wait_(command); const uint32_t start = millis(); while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->available()) { - uint8_t byte; - if (this->read_byte(&byte)) { - if (byte == COMMAND_ACK) - return true; - ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); - } - } + if (this->consume_ack_()) + return true; delay(1); } return false; @@ -130,14 +192,12 @@ bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEA bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } -float UFM01Component::get_setup_priority() const { return setup_priority::IO; } +float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); - if (!this->set_active_mode_()) { - ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); - this->mark_failed(); - } + this->startup_wait_ms_ = STARTUP_DELAY_MS; + this->set_startup_phase_(StartupPhase::WAIT); } void UFM01Component::dump_config() { @@ -154,12 +214,9 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); - if (this->is_failed()) { - ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); - } } -void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -189,10 +246,14 @@ void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif + this->last_valid_frame_ms_ = millis(); + this->status_clear_warning(); + this->status_clear_error(); } -void UFM01Component::loop() { - // Drain the UART buffer each loop, reading one byte at a time into the frame +bool UFM01Component::process_active_stream_() { + bool got_valid_frame = false; + while (this->available()) { if (!this->read_byte(&this->data_[this->read_index_])) { ESP_LOGW(TAG, "unable to read byte"); @@ -201,23 +262,22 @@ void UFM01Component::loop() { } if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { - ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + ESP_LOGD(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); this->read_index_ = 0; continue; } if (++this->read_index_ < static_cast(FRAME_SIZE)) continue; - // Full frame received - if (validate_data(this->data_)) { - this->on_data_(this->data_); + if (validate_active_frame(this->data_)) { + this->on_active_frame_(this->data_); this->read_index_ = 0; + got_valid_frame = true; continue; } - // Invalid frame: try to resync on the next start marker within the buffer log_hex(this->data_, sizeof(this->data_)); - ESP_LOGE(TAG, "unable to read data"); + ESP_LOGW(TAG, "unable to read data"); for (int32_t i = 2; i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { @@ -229,6 +289,190 @@ void UFM01Component::loop() { if (this->read_index_ == static_cast(FRAME_SIZE)) this->read_index_ = 0; } + + return got_valid_frame; +} + +void UFM01Component::set_startup_phase_(StartupPhase phase) { + this->startup_phase_ = phase; + this->phase_start_ms_ = millis(); +} + +void UFM01Component::enter_active_stream_(const char *reason) { + ESP_LOGI(TAG, "UFM-01 active stream %s", reason); + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->passive_read_pending_ = false; +} + +void UFM01Component::start_passive_read_() { + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); +} + +// Accumulates the reply to a passive read request across loop iterations. +PassiveReadResult UFM01Component::continue_passive_read_() { + while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + + if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) + continue; + if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + // The mismatched byte may itself be the start of the real frame + this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; + continue; + } + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < PASSIVE_FRAME_SIZE) { + if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + } + + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + } + + uint8_t active_frame[FRAME_SIZE]; + passive_no_id_to_active_frame(this->passive_frame_, active_frame); + this->on_active_frame_(active_frame); + return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +} + +void UFM01Component::loop_startup_() { + const uint32_t elapsed = millis() - this->phase_start_ms_; + + switch (this->startup_phase_) { + case StartupPhase::WAIT: + // Pick up an already-streaming device without resetting it + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < this->startup_wait_ms_) + return; + ESP_LOGD(TAG, "Running startup sequence"); + this->status_set_warning("initializing UFM-01"); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::RESET_WAIT_ACK: + if (this->consume_ack_()) { + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + return; + } + if (elapsed < COMMAND_ACK_TIMEOUT_MS) + return; + if (!this->reset_retried_) { + ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + } else { + ESP_LOGW(TAG, "Reset failed during startup"); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + } + return; + + case StartupPhase::RESET_RETRY_WAIT: + if (elapsed < RESET_RETRY_DELAY_MS) + return; + this->reset_retried_ = true; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::POST_RESET_WAIT: + if (elapsed < POST_RESET_DELAY_MS) + return; + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + return; + + case StartupPhase::ACTIVE_WAIT_FRAME: + // The command ACK (0xE5) is consumed by the frame parser as noise + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) + return; + this->start_passive_read_(); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + return; + + case StartupPhase::PASSIVE_WAIT_REPLY: + switch (this->continue_passive_read_()) { + case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + return; + case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + ESP_LOGI(TAG, "UFM-01 using passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = millis(); + return; + case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); + this->startup_wait_ms_ = STARTUP_RETRY_MS; + this->set_startup_phase_(StartupPhase::WAIT); + return; + } + } +} + +void UFM01Component::loop_active_stream_() { + this->process_active_stream_(); + if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = 0; + this->status_set_warning("UFM-01 passive poll"); + } +} + +void UFM01Component::loop_passive_poll_() { + if (this->passive_read_pending_) { + const PassiveReadResult result = this->continue_passive_read_(); + if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + return; + this->passive_read_pending_ = false; + if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) + this->status_set_warning("UFM-01 passive poll failed"); + return; + } + + if (this->process_active_stream_()) { + this->enter_active_stream_("resumed"); + return; + } + + if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = millis(); + this->start_passive_read_(); + this->passive_read_pending_ = true; + } +} + +void UFM01Component::loop() { + switch (this->operating_mode_) { + case OperatingMode::STARTUP: + this->loop_startup_(); + return; + case OperatingMode::ACTIVE_STREAM: + this->loop_active_stream_(); + return; + case OperatingMode::PASSIVE_POLL: + this->loop_passive_poll_(); + return; + } } } // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index e759de9169..0a39dcc9af 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -11,12 +11,39 @@ #include "esphome/components/uart/uart.h" #include +#include // component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf namespace esphome::ufm01 { +namespace testing { +class TestableUFM01; +} // namespace testing + static constexpr size_t FRAME_SIZE = 32; +static constexpr size_t PASSIVE_FRAME_SIZE = 23; + +enum class OperatingMode : uint8_t { + STARTUP = 0, + ACTIVE_STREAM = 1, + PASSIVE_POLL = 2, +}; + +enum class StartupPhase : uint8_t { + WAIT = 0, + RESET_WAIT_ACK = 1, + RESET_RETRY_WAIT = 2, + POST_RESET_WAIT = 3, + ACTIVE_WAIT_FRAME = 4, + PASSIVE_WAIT_REPLY = 5, +}; + +enum class PassiveReadResult : uint8_t { + PASSIVE_READ_RESULT_PENDING = 0, + PASSIVE_READ_RESULT_SUCCESS = 1, + PASSIVE_READ_RESULT_FAILURE = 2, +}; class UFM01Component : public uart::UARTDevice, public Component { #ifdef USE_SENSOR @@ -48,10 +75,37 @@ class UFM01Component : public uart::UARTDevice, public Component { private: bool send_command_(const std::array &command); + void send_command_no_wait_(const std::array &command); + bool consume_ack_(); + void flush_rx_(); + bool process_active_stream_(); + void on_active_frame_(uint8_t data[FRAME_SIZE]); + + void loop_startup_(); + void loop_active_stream_(); + void loop_passive_poll_(); + void set_startup_phase_(StartupPhase phase); + void enter_active_stream_(const char *reason); + void start_passive_read_(); + PassiveReadResult continue_passive_read_(); + + OperatingMode operating_mode_{OperatingMode::STARTUP}; + StartupPhase startup_phase_{StartupPhase::WAIT}; + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + bool reset_retried_{false}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + + bool passive_read_pending_{false}; + uint32_t passive_start_ms_{0}; + size_t passive_index_{0}; + uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; int32_t read_index_ = 0; uint8_t data_[FRAME_SIZE]; - void on_data_(uint8_t data[FRAME_SIZE]); + + friend class testing::TestableUFM01; }; } // namespace esphome::ufm01 diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 8e71fc61b2..d8eb91586a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,6 +7,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include #include "freertos/ringbuf.h" #include "tinyusb_cdc_acm.h" @@ -96,10 +97,26 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented rather than std::atomic because GCC on Xtensa + // generates an indirect function call for atomic ops instead of inlining + // them; atomic inlines correctly on all platforms. + std::atomic usb_tx_busy_{0}; + // Running total of bytes dropped by write_array() (never reset), and the timestamp + // of the last "buffer full" log line (throttled so a sustained host stall doesn't + // flood the log). + uint32_t tx_dropped_bytes_{0}; + uint32_t tx_dropped_log_ms_{0}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 859d6cbaea..e46369660d 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -2,6 +2,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -24,6 +25,13 @@ static constexpr size_t USB_CDC_MAX_LOG_BYTES = 168; static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; +// Upper bound on how long flush() may block in total: the TX ring buffer drain and +// the final TinyUSB flush share this budget. +static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; + +// Minimum interval between repeated warnings while a host stall persists. +static constexpr uint32_t LOG_THROTTLE_MS = 1000; + static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -186,11 +194,21 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; + // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs + // immediately (unsigned arithmetic keeps this wrap-safe). + uint32_t stall_log_ms = millis() - LOG_THROTTLE_MS; while (true) { + // Not holding any data while blocked waiting for more. + this->usb_tx_busy_ = 0; + // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + // Raise the busy flag before pulling data out of the ring buffer, so at every + // instant flush() sees pending bytes in the ring buffer count or in this flag. + this->usb_tx_busy_ = 1; + // When we do wake up, we can be sure there is data in the ring buffer esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); @@ -224,11 +242,50 @@ void USBCDCACMInstance::usb_tx_task() { esp_err_t flush_ret = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(10)); - if (flush_ret != ESP_OK) { - ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); - tud_cdc_n_write_clear(this->itf_); - break; + if (flush_ret == ESP_OK) { + continue; } + + // Bytes not yet handed to TinyUSB plus bytes still sitting in its transmit FIFO. + // tud_cdc_n_write_occupied() is not public API in the pinned TinyUSB release, so + // derive the occupancy from the FIFO depth TinyUSB itself is configured with. + const size_t pending = tx_data_size + (CFG_TUD_CDC_TX_BUFSIZE - tud_cdc_n_write_available(this->itf_)); + + // A flush timeout only means TinyUSB's transmit FIFO did not fully drain within + // the wait window; the queued bytes are untouched and TinyUSB keeps sending them + // from its transfer-complete callback once the host polls again. Clearing the + // FIFO here would discard the tail of a frame whose head is already on the wire, + // corrupting the stream mid-frame. Hold the data and retry instead; sustained + // backpressure then propagates to the ring buffer, which drops whole writes with + // a warning instead of splitting a frame. + // + // Gate the retry on DTR (tud_cdc_n_connected()) rather than tud_ready(): an + // enumerated-but-idle host (no application holding the port open) never polls + // the IN endpoint, so retrying on tud_ready() alone would wedge this task -- and + // stall every write_array()/flush() caller behind a full ring buffer -- for as + // long as the board sits plugged into an idle PC. DTR means an application has + // the port open and is expected to eventually read. + if (flush_ret == ESP_ERR_TIMEOUT && tud_cdc_n_connected(this->itf_)) { + const uint32_t now = millis(); + if ((now - stall_log_ms) >= LOG_THROTTLE_MS) { + stall_log_ms = now; + ESP_LOGW(TAG, "USB TX itf=%d: host not reading; %zu bytes pending", this->itf_, pending); + } + continue; + } + + if (flush_ret == ESP_ERR_TIMEOUT) { + // No application has the port open (DTR deasserted) or the device is detached, + // so the data cannot be delivered. TinyUSB does not clear its transmit FIFO on + // bus reset; drop the data here so a stale partial frame is not replayed when + // the port is (re)opened. + ESP_LOGW(TAG, "USB TX itf=%d: not connected; dropping %zu bytes", this->itf_, pending); + } else { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed (%s); dropping %zu bytes", this->itf_, esp_err_to_name(flush_ret), + pending); + } + tud_cdc_n_write_clear(this->itf_); + break; } } } @@ -245,7 +302,19 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + // During a sustained host stall the ring buffer stays full (that is the intended + // backpressure), so this path runs for every write; throttle the warning so the + // log stays readable. The counter is a running total that is never reset: each + // line reports all bytes dropped so far, so bytes dropped in the tail of one + // stall are still accounted for by the next line, whenever that is. It also makes + // the very first drop since boot detectable, which is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += len; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } return; } @@ -326,27 +395,54 @@ size_t USBCDCACMInstance::available() { return waiting + (this->has_peek_ ? 1 : 0); } +// True while TX bytes have not yet reached TinyUSB's FIFO: still counted in the ring +// buffer, or held by the TX task (usb_tx_busy_) between pulling them from the ring +// buffer and handing them to TinyUSB -- there they are in neither the ring buffer +// count nor TinyUSB's FIFO. +bool USBCDCACMInstance::tx_pending_() { + UBaseType_t waiting = 0; + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + return waiting != 0 || this->usb_tx_busy_ != 0; +} + uart::UARTFlushResult USBCDCACMInstance::flush() { - // Wait for TX ring buffer to be empty if (this->usb_tx_ringbuf_ == nullptr) { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } - UBaseType_t waiting = 1; - while (waiting > 0) { - vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); - if (waiting > 0) { - vTaskDelay(pdMS_TO_TICKS(1)); + // Bound the wait: when the host stalls or disconnects, the TX task holds on to + // pending data rather than discarding it, so the ring buffer may not drain for as + // long as the host stays away. flush() runs on the caller's (typically the main + // loop) task and must not block indefinitely. Signed tick differences keep the + // deadline arithmetic wrap-safe. + TickType_t now = xTaskGetTickCount(); + const TickType_t deadline = now + pdMS_TO_TICKS(FLUSH_TIMEOUT_MS); + while (this->tx_pending_()) { + if (static_cast(now - deadline) >= 0) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; } + vTaskDelay(pdMS_TO_TICKS(1)); + now = xTaskGetTickCount(); } - // Also wait for USB to finish transmitting - esp_err_t err = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(100)); - if (err == ESP_OK) - return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; - if (err == ESP_ERR_TIMEOUT) - return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; - return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + // Also wait for USB to finish transmitting, within whatever remains of the budget. + // Floor at one tick: a zero-tick timeout takes esp_tinyusb's non-blocking branch, + // whose return contract is that library's internal detail and may differ between + // releases. One tick keeps the call on the blocking branch (ESP_OK/ESP_ERR_TIMEOUT) + // at the cost of at most one tick over budget. + const int32_t remaining = static_cast(deadline - now); + const TickType_t flush_ticks = remaining > 0 ? static_cast(remaining) : 1; + switch (tinyusb_cdcacm_write_flush(static_cast(this->itf_), flush_ticks)) { + case ESP_OK: + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + case ESP_ERR_TIMEOUT: + // ESP_ERR_NOT_FINISHED is the non-blocking branch's "still draining" result; + // mapped like a timeout in case a future esp_tinyusb release returns it here. + case ESP_ERR_NOT_FINISHED: + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + default: + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } } void USBCDCACMInstance::check_logger_conflict() {} diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index 862fac302f..08d3685d1f 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -22,13 +22,13 @@ veml3235_ns = cg.esphome_ns.namespace("veml3235") VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -VEML3235IntegrationTime = veml3235_ns.enum("VEML3235IntegrationTime") +VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235IntegrationTime.VEML3235_INTEGRATION_TIME_800MS, + "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, + "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, + "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, + "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, + "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, } VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") DIGITAL_GAINS = { @@ -40,10 +40,18 @@ GAINS = { "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, - "AUTO": VEML3235ComponentGain.VEML3235_GAIN_AUTO, } -CONFIG_SCHEMA = ( + +def _validate_auto_gain_thresholds(config): + if config[CONF_AUTO_GAIN_THRESHOLD_LOW] >= config[CONF_AUTO_GAIN_THRESHOLD_HIGH]: + raise cv.Invalid( + f"'{CONF_AUTO_GAIN_THRESHOLD_LOW}' must be less than '{CONF_AUTO_GAIN_THRESHOLD_HIGH}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( VEML3235Sensor, unit_of_measurement=UNIT_LUX, @@ -67,7 +75,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x10)) + .extend(i2c.i2c_device_schema(0x10)), + _validate_auto_gain_thresholds, ) diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 59892936b0..b3170469b9 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -6,6 +6,16 @@ namespace esphome::veml3235 { static const char *const TAG = "veml3235.sensor"; +// ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot +// be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead +static const uint16_t CLIPPED_COUNTS = 64224; + +// Maximum sensitivity multiplier: integration time 800 ms (16x) * gain 4x * digital gain 2x +static const uint16_t MAX_SENSITIVITY_FACTOR = 128; + +// At most one restart from clipping plus one proportional adjustment per update cycle +static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; + void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { @@ -22,186 +32,156 @@ void VEML3235Sensor::setup() { } } -bool VEML3235Sensor::refresh_config_reg(bool force_on) { - uint16_t data = this->power_on_ || force_on ? 0 : SHUTDOWN_BITS; +bool VEML3235Sensor::refresh_config_reg() { + uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) - data |= (uint16_t(this->integration_time_ << CONFIG_REG_IT_BIT)); - data |= (uint16_t(this->digital_gain_ << CONFIG_REG_DG_BIT)); - data |= (uint16_t(this->gain_ << CONFIG_REG_G_BIT)); - data |= 0x1; // mandatory 1 here per RM + data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); + data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); + data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); return this->write_byte_16(CONFIG_REG, data); } -float VEML3235Sensor::read_lx_() { - if (!this->power_on_) { // if off, turn on - if (!this->refresh_config_reg(true)) { - ESP_LOGW(TAG, "Turning on failed"); - this->status_set_warning(); - return NAN; - } - delay(4); // from RM: a wait time of 4 ms should be observed before the first measurement is picked up, to allow - // for a correct start of the signal processor and oscillator +void VEML3235Sensor::update() { + if (this->measurement_in_progress_) { + ESP_LOGV(TAG, "'%s': Previous measurement still in progress; skipping update", this->get_name().c_str()); + return; } + this->measurement_in_progress_ = true; + this->read_and_publish_(MAX_ADJUSTMENTS_PER_UPDATE); +} +void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { this->status_set_warning(); - return NAN; + this->publish_state(NAN); + this->measurement_in_progress_ = false; + return; } this->status_clear_warning(); - float als_raw_value_multiplier = LUX_MULTIPLIER_BASE; - uint16_t als_raw_value = encode_uint16(als_regs[1], als_regs[0]); - // determine multiplier value based on gains and integration time - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_1X) { - als_raw_value_multiplier *= 2; - } - switch (this->gain_) { - case VEML3235_GAIN_1X: - als_raw_value_multiplier *= 4; - break; - case VEML3235_GAIN_2X: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - als_raw_value_multiplier *= 16; - break; - case VEML3235_INTEGRATION_TIME_100MS: - als_raw_value_multiplier *= 8; - break; - case VEML3235_INTEGRATION_TIME_200MS: - als_raw_value_multiplier *= 4; - break; - case VEML3235_INTEGRATION_TIME_400MS: - als_raw_value_multiplier *= 2; - break; - default: - break; - } - // finally, determine and return the actual lux value - float lx = float(als_raw_value) * als_raw_value_multiplier; - ESP_LOGVV(TAG, "'%s': ALS raw = %u, multiplier = %.5f", this->get_name().c_str(), als_raw_value, - als_raw_value_multiplier); - ESP_LOGD(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lx); + uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); - if (!this->power_on_) { // turn off if required - if (!this->refresh_config_reg()) { - ESP_LOGW(TAG, "Turning off failed"); - this->status_set_warning(); + if (this->auto_gain_ && adjustments_left > 0) { + // A sample integrated with the previous settings may still be in the data register after the + // configuration changes, so wait out the old integration period plus two new ones before re-reading + const uint32_t old_integration_time_ms = this->integration_time_ms_(); + if (this->adjust_sensitivity_(als_counts)) { + const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); + this->set_timeout("reread", wait_ms, + [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); + return; } } - if (this->auto_gain_) { - this->adjust_gain_(als_raw_value); - } - - return lx; + float lux = this->counts_to_lux_(als_counts); + ESP_LOGVV(TAG, "'%s': ALS counts = %u, sensitivity = %ux", this->get_name().c_str(), als_counts, + this->sensitivity_factor_()); + ESP_LOGV(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lux); + this->publish_state(lux); + this->measurement_in_progress_ = false; } -void VEML3235Sensor::adjust_gain_(const uint16_t als_raw_value) { - if ((als_raw_value > UINT16_MAX * this->auto_gain_threshold_low_) && - (als_raw_value < UINT16_MAX * this->auto_gain_threshold_high_)) { - return; +float VEML3235Sensor::counts_to_lux_(uint16_t counts) const { + float resolution = LUX_MULTIPLIER_BASE * (float(MAX_SENSITIVITY_FACTOR) / float(this->sensitivity_factor_())); + return float(counts) * resolution; +} + +uint8_t VEML3235Sensor::gain_factor_() const { + switch (this->gain_) { + case VEML3235_GAIN_4X: + return 4; + case VEML3235_GAIN_2X: + return 2; + default: + return 1; + } +} + +uint16_t VEML3235Sensor::sensitivity_factor_() const { + const uint8_t digital_gain_factor = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; + return (1 << this->integration_time_) * this->gain_factor_() * digital_gain_factor; +} + +void VEML3235Sensor::set_sensitivity_factor_(uint16_t factor) { + // The factor is a power of two in [1, 128]. Prefer integration time (improves the signal-to-noise ratio), + // then analog gain; digital gain is a plain doubling of the output and is used only as a last resort. + uint8_t it_exponent = 0; // integration time is 2^n * 50 ms + while (it_exponent < VEML3235_INTEGRATION_TIME_800MS && (1u << it_exponent) < factor) { + it_exponent++; + } + this->integration_time_ = static_cast(it_exponent); + factor >>= it_exponent; + + if (factor >= 4) { + this->gain_ = VEML3235_GAIN_4X; + factor >>= 2; + } else if (factor == 2) { + this->gain_ = VEML3235_GAIN_2X; + factor >>= 1; + } else { + this->gain_ = VEML3235_GAIN_1X; } - if (als_raw_value >= UINT16_MAX * 0.9) { // over-saturated, reset all gains and start over - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->integration_time_ = VEML3235_INTEGRATION_TIME_50MS; - this->refresh_config_reg(); - return; + this->digital_gain_ = factor >= 2 ? VEML3235_DIGITAL_GAIN_2X : VEML3235_DIGITAL_GAIN_1X; +} + +bool VEML3235Sensor::adjust_sensitivity_(uint16_t counts) { + // Test for clipping before the window test: with an upper threshold configured at or above the clip + // point, a saturated reading would otherwise count as "in window" and sensitivity would never recover + const bool clipped = counts >= CLIPPED_COUNTS; + const uint16_t low = uint16_t(UINT16_MAX * this->auto_gain_threshold_low_); + const uint16_t high = uint16_t(UINT16_MAX * this->auto_gain_threshold_high_); + if (!clipped && counts >= low && counts <= high) { + return false; } - if (this->gain_ != VEML3235_GAIN_4X) { // increase gain if possible - switch (this->gain_) { - case VEML3235_GAIN_1X: - this->gain_ = VEML3235_GAIN_2X; - break; - case VEML3235_GAIN_2X: - this->gain_ = VEML3235_GAIN_4X; - break; - default: - break; + const uint16_t current_factor = this->sensitivity_factor_(); + uint16_t new_factor; + if (clipped) { + new_factor = 1; + } else if (counts == 0) { + new_factor = MAX_SENSITIVITY_FACTOR; + } else { + // Counts scale linearly with the sensitivity factor: in one step, pick the power of two that puts the + // next reading closest below the middle of the configured window. Rounding down means the target is + // never overshot, which also keeps the sensitivity stable when the window is narrower than one step. + float desired = float(current_factor) * ((float(low) + float(high)) * 0.5f / float(counts)); + desired = clamp(desired, 1.0f, float(MAX_SENSITIVITY_FACTOR)); + new_factor = 1; + while (new_factor * 2 <= uint16_t(desired)) { + new_factor *= 2; } - this->refresh_config_reg(); - return; } - // gain is maxed out; reset it and try to increase digital gain - if (this->digital_gain_ != VEML3235_DIGITAL_GAIN_2X) { // increase digital gain if possible - this->digital_gain_ = VEML3235_DIGITAL_GAIN_2X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + if (new_factor == current_factor) { + return false; } - // digital gain is maxed out; reset it and try to increase integration time - if (this->integration_time_ != VEML3235_INTEGRATION_TIME_800MS) { // increase integration time if possible - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_100MS; - break; - case VEML3235_INTEGRATION_TIME_100MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_200MS; - break; - case VEML3235_INTEGRATION_TIME_200MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_400MS; - break; - case VEML3235_INTEGRATION_TIME_400MS: - this->integration_time_ = VEML3235_INTEGRATION_TIME_800MS; - break; - default: - break; - } - this->digital_gain_ = VEML3235_DIGITAL_GAIN_1X; - this->gain_ = VEML3235_GAIN_1X; - this->refresh_config_reg(); - return; + + const VEML3235ComponentIntegrationTime old_integration_time = this->integration_time_; + const VEML3235ComponentGain old_gain = this->gain_; + const VEML3235ComponentDigitalGain old_digital_gain = this->digital_gain_; + + this->set_sensitivity_factor_(new_factor); + if (!this->refresh_config_reg()) { + // Keep our state consistent with the device, which still has the old configuration + this->integration_time_ = old_integration_time; + this->gain_ = old_gain; + this->digital_gain_ = old_digital_gain; + this->status_set_warning(); + return false; } + + ESP_LOGV(TAG, "'%s': Sensitivity adjusted from %ux to %ux (ALS counts = %u)", this->get_name().c_str(), + current_factor, new_factor, counts); + return true; } void VEML3235Sensor::dump_config() { - uint8_t digital_gain = 1; - uint8_t gain = 1; - uint16_t integration_time = 0; - - if (this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X) { - digital_gain = 2; - } - switch (this->gain_) { - case VEML3235_GAIN_2X: - gain = 2; - break; - case VEML3235_GAIN_4X: - gain = 4; - break; - default: - break; - } - switch (this->integration_time_) { - case VEML3235_INTEGRATION_TIME_50MS: - integration_time = 50; - break; - case VEML3235_INTEGRATION_TIME_100MS: - integration_time = 100; - break; - case VEML3235_INTEGRATION_TIME_200MS: - integration_time = 200; - break; - case VEML3235_INTEGRATION_TIME_400MS: - integration_time = 400; - break; - case VEML3235_INTEGRATION_TIME_800MS: - integration_time = 800; - break; - default: - break; - } + const uint8_t digital_gain = this->digital_gain_ == VEML3235_DIGITAL_GAIN_2X ? 2 : 1; LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); @@ -212,8 +192,9 @@ void VEML3235Sensor::dump_config() { ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { ESP_LOGCONFIG(TAG, - " Auto-gain upper threshold: %f%%\n" - " Auto-gain lower threshold: %f%%\n" + " Auto-gain thresholds:\n" + " Upper: %.0f%%\n" + " Lower: %.0f%%\n" " Values below will be used as initial values only", this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } @@ -221,7 +202,7 @@ void VEML3235Sensor::dump_config() { " Digital gain: %uX\n" " Gain: %uX\n" " Integration time: %ums", - digital_gain, gain, integration_time); + digital_gain, this->gain_factor_(), this->integration_time_ms_()); } } // namespace esphome::veml3235 diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index cda6d177aa..c19fc17b65 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -16,6 +15,10 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // +// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit +// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word +// bits 15:8 here and datasheet high-byte bits are word bits 7:0. +// static const uint8_t CONFIG_REG_IT_BIT = 12; static const uint8_t CONFIG_REG_DG_BIT = 5; static const uint8_t CONFIG_REG_G_BIT = 3; @@ -23,18 +26,17 @@ static const uint8_t CONFIG_REG_G_BIT = 3; // Other important constants // static const uint8_t DEVICE_ID = 0x35; -static const uint16_t SHUTDOWN_BITS = 0x0018; -// Base multiplier value for lux computation +// Resolution (lx/count) at maximum sensitivity (integration time 800 ms, gain 4x, digital gain 2x) // -static const float LUX_MULTIPLIER_BASE = 0.00213; +static const float LUX_MULTIPLIER_BASE = 0.00213f; // Enum for conversion/integration time settings for the VEML3235. // // Specific values of the enum constants are register values taken from the VEML3235 datasheet. // Longer times mean more accurate results, but will take more energy/more time. // -enum VEML3235ComponentIntegrationTime { +enum VEML3235ComponentIntegrationTime : uint8_t { VEML3235_INTEGRATION_TIME_50MS = 0b000, VEML3235_INTEGRATION_TIME_100MS = 0b001, VEML3235_INTEGRATION_TIME_200MS = 0b010, @@ -45,7 +47,7 @@ enum VEML3235ComponentIntegrationTime { // Enum for digital gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentDigitalGain { +enum VEML3235ComponentDigitalGain : uint8_t { VEML3235_DIGITAL_GAIN_1X = 0b0, VEML3235_DIGITAL_GAIN_2X = 0b1, }; @@ -53,7 +55,7 @@ enum VEML3235ComponentDigitalGain { // Enum for gain settings for the VEML3235. // Higher values are better for low light situations, but can increase noise. // -enum VEML3235ComponentGain { +enum VEML3235ComponentGain : uint8_t { VEML3235_GAIN_1X = 0b00, VEML3235_GAIN_2X = 0b01, VEML3235_GAIN_4X = 0b11, @@ -63,7 +65,7 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub public: void setup() override; void dump_config() override; - void update() override { this->publish_state(this->read_lx_()); } + void update() override; // Used by ESPHome framework. Does NOT actually set the value on the device. void set_auto_gain(bool auto_gain) { this->auto_gain_ = auto_gain; } @@ -73,7 +75,6 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub void set_auto_gain_threshold_low(float auto_gain_threshold_low) { this->auto_gain_threshold_low_ = auto_gain_threshold_low; } - void set_power_on(bool power_on) { this->power_on_ = power_on; } void set_digital_gain(VEML3235ComponentDigitalGain digital_gain) { this->digital_gain_ = digital_gain; } void set_gain(VEML3235ComponentGain gain) { this->gain_ = gain; } void set_integration_time(VEML3235ComponentIntegrationTime integration_time) { @@ -88,19 +89,32 @@ class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, pub VEML3235ComponentIntegrationTime integration_time() { return this->integration_time_; } // Updates the configuration register on the device - bool refresh_config_reg(bool force_on = false); + bool refresh_config_reg(); protected: - float read_lx_(); - void adjust_gain_(uint16_t als_raw_value); + // One measurement pass: reads the ALS counts, possibly adjusts the sensitivity and schedules a re-read, + // otherwise publishes the result + void read_and_publish_(uint8_t adjustments_left); + // Chooses a new sensitivity for the given ALS reading and writes it to the device. + // Returns true only if the device configuration was changed. + bool adjust_sensitivity_(uint16_t counts); + float counts_to_lux_(uint16_t counts) const; - bool auto_gain_{true}; - bool power_on_{true}; + // Overall sensitivity multiplier (1x-128x, always a power of two) relative to the least sensitive + // configuration (integration time 50 ms, gain 1x, digital gain 1x). ALS counts scale linearly with it. + uint16_t sensitivity_factor_() const; + void set_sensitivity_factor_(uint16_t factor); + uint8_t gain_factor_() const; + uint16_t integration_time_ms_() const { return 50 << this->integration_time_; } + + // Members are ordered largest to smallest to minimize padding float auto_gain_threshold_high_{0.9}; float auto_gain_threshold_low_{0.2}; VEML3235ComponentDigitalGain digital_gain_{VEML3235_DIGITAL_GAIN_1X}; VEML3235ComponentGain gain_{VEML3235_GAIN_1X}; VEML3235ComponentIntegrationTime integration_time_{VEML3235_INTEGRATION_TIME_50MS}; + bool auto_gain_{true}; + bool measurement_in_progress_{false}; }; } // namespace esphome::veml3235 diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index f13ea39fa2..dba9b925d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -248,7 +248,13 @@ void VoiceAssistant::stream_api_audio_() { msg.data2_len = available2; } - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + // Keep the chunk exposed and retry next pass, the same shape as + // APIConnection::try_send_camera_image_(): the slice is only lost if + // the ring buffer overflows before the TCP buffer clears, instead of + // on every refusal. The api layer already reports the refusal at V. + return; + } this->audio_source_->consume(available); if (this->audio_source2_ != nullptr) { @@ -477,7 +483,9 @@ void VoiceAssistant::loop() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } break; } } @@ -741,7 +749,9 @@ void VoiceAssistant::signal_stop_() { ESP_LOGD(TAG, "Signaling stop"); api::VoiceAssistantRequest msg; msg.start = false; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Stop request"); + } } void VoiceAssistant::start_playback_timeout_() { @@ -753,7 +763,9 @@ void VoiceAssistant::start_playback_timeout_() { return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } }); } @@ -978,11 +990,12 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { void VoiceAssistant::on_audio(const api::VoiceAssistantAudio &msg) { #ifdef USE_SPEAKER // We should never get to this function if there is no speaker anyway if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) { - if (this->speaker_buffer_index_ + msg.data_len < SPEAKER_BUFFER_SIZE) { + if (this->speaker_buffer_index_ + msg.data_len <= SPEAKER_BUFFER_SIZE) { memcpy(this->speaker_buffer_ + this->speaker_buffer_index_, msg.data, msg.data_len); this->speaker_buffer_index_ += msg.data_len; this->speaker_buffer_size_ += msg.data_len; this->speaker_bytes_received_ += msg.data_len; + this->write_speaker_(); ESP_LOGV(TAG, "Received audio: %u bytes from API", msg.data_len); } else { ESP_LOGE(TAG, "Cannot receive audio, buffer is full"); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index dd9d205aff..d46b089c2e 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -81,12 +81,6 @@ struct Timer { this->id.c_str(), this->name.c_str(), this->total_seconds, this->seconds_left, YESNO(this->is_active)); return buffer.data(); } - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { // NOLINT - char buffer[TO_STR_BUFFER_SIZE]; - return this->to_str(buffer); - } }; struct WakeWord { diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index ddf3433e7d..cef60c54f8 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -3,6 +3,7 @@ #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" #else @@ -27,7 +28,7 @@ class WakeOnLanButton final : public button::Button, public Component { #endif void press_action() override; uint16_t port_{9}; - uint8_t macaddr_[6]; + uint8_t macaddr_[MAC_ADDRESS_SIZE]; }; } // namespace esphome::wake_on_lan diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index f3eec16a40..6bb2b2f8fe 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -76,7 +76,7 @@ def water_heater_schema( @setup_entity("water_heater") async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up the core water heater properties in C++.""" - visual = config[CONF_VISUAL] + visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index a1e1ca10a6..995b815440 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -90,10 +90,6 @@ class WaterHeaterCall { float get_target_temperature() const { return this->target_temperature_; } float get_target_temperature_low() const { return this->target_temperature_low_; } float get_target_temperature_high() const { return this->target_temperature_high_; } - /// Get state flags value - ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0") - uint32_t get_state() const { return this->state_; } - optional get_away() const { if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; @@ -187,6 +183,9 @@ class WaterHeaterTraits { const WaterHeaterModeMask &get_supported_modes() const { return this->supported_modes_; } bool supports_mode(WaterHeaterMode mode) const { return this->supported_modes_.count(mode); } + TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; } + void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; } + protected: // Ordered to minimize padding: 4-byte members first uint32_t feature_flags_{0}; @@ -194,6 +193,7 @@ class WaterHeaterTraits { float max_temperature_{0.0f}; float target_temperature_step_{0.0f}; WaterHeaterModeMask supported_modes_; + TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; }; class WaterHeater : public EntityBase { diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2587d13b9e..c1887cc3fc 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import gzip import logging import re @@ -406,10 +407,21 @@ async def to_code(config): # The scheme is fixed at build time so the unused Basic/Digest code path is compiled # out. Basic is the current default (the absence of this define); an explicit # 'type: digest' opts in early. Default changes to digest in 2027.1.0. - if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + is_digest = auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST + if is_digest: cg.add_define("USE_WEBSERVER_AUTH_DIGEST") - cg.add(paren.set_auth_username(auth[CONF_USERNAME])) - cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + if is_digest or CORE.is_esp32: + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + else: + # Every non-ESP32 basic auth build takes this path. The ESP8266 and RP2040 + # core base64 encoders wrap output every 72 chars, which breaks + # ESPAsyncWebServer's basic auth compare for long credentials. + # Precompute the hash here and let C++ compare the raw header payload. + basic_hash = base64.b64encode( + f"{auth[CONF_USERNAME]}:{auth[CONF_PASSWORD]}".encode() + ).decode() + cg.add(paren.set_auth_basic_hash(basic_hash)) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1e6c4e8c62..9e50b7a394 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -510,7 +510,7 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; response->addHeader(ESPHOME_F("Private-Network-Access-ID"), get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } @@ -558,26 +558,19 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J size_t device_len = device_name ? strlen(device_name) : 0; #endif - // Single stack buffer for both id formats - ArduinoJson copies the string before we overwrite + // Stack buffer for the id - ArduinoJson copies the string before it goes out of scope // Buffer sizes use constants from entity_base.h validated in core/config.py // Note: Device name uses ESPHOME_FRIENDLY_NAME_MAX_LEN (sub-device max 120), not ESPHOME_DEVICE_NAME_MAX_LEN // (hostname) - // Without USE_DEVICES: legacy id ({prefix}-{object_id}) is the largest format - // With USE_DEVICES: name_id ({prefix}/{device}/{name}) is the largest format - static constexpr size_t LEGACY_ID_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + OBJECT_ID_MAX_LEN; #ifdef USE_DEVICES static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, - LEGACY_ID_SIZE); + ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #else - static constexpr size_t ID_BUF_SIZE = - std::max(ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1, LEGACY_ID_SIZE); + static constexpr size_t ID_BUF_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #endif char id_buf[ID_BUF_SIZE]; memcpy(id_buf, prefix, prefix_len); // NOLINT(bugprone-not-null-terminated-result) - // name_id: new format {prefix}/{device?}/{name} - frontend should prefer this - // Remove in 2026.8.0 when id switches to new format permanently char *p = id_buf + prefix_len; *p++ = '/'; #ifdef USE_DEVICES @@ -589,12 +582,6 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("name_id")] = id_buf; - - // id: old format {prefix}-{object_id} for backward compatibility - // Will switch to new format in 2026.8.0 - reuses prefix already in id_buf - id_buf[prefix_len] = '-'; - obj->write_object_id_to(id_buf + prefix_len + 1, ID_BUF_SIZE - prefix_len - 1); root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { @@ -1128,6 +1115,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail if (obj->get_traits().get_supports_tilt()) root[ESPHOME_F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { + root[ESPHOME_F("assumed_state")] = obj->get_traits().get_is_assumed_state(); this->add_sorting_info_(root, obj); } @@ -1907,7 +1895,7 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "alarm-control-panel", + set_json_icon_state_value(root, obj, "alarm_control_panel", json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index ccfc04f674..873c5b5a49 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -7,7 +7,7 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { #ifdef USE_WEBSERVER_AUTH - if (!credentials_.username.empty()) { + if (credentials_.is_set()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 9657853a73..c647a13b50 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) -#include #include #include "esphome/core/progmem.h" @@ -46,9 +45,20 @@ class MiddlewareHandler : public AsyncWebHandler { }; #ifdef USE_WEBSERVER_AUTH +// All fields point to string literals in generated code; nothing is copied. struct Credentials { - std::string username; - std::string password; +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + const char *username{nullptr}; + const char *password{nullptr}; + bool is_set() const { return username != nullptr; } +#else + // base64("username:password"), precomputed at codegen time. Used by every non-ESP32 basic + // auth build. The ESP8266 and RP2040 core libb64 wraps base64 output every 72 chars, so + // letting the library encode and compare fails for long credentials; instead the header + // payload is compared against this hash. + const char *basic_auth_hash{nullptr}; + bool is_set() const { return basic_auth_hash != nullptr; } +#endif }; class AuthMiddlewareHandler : public MiddlewareHandler { @@ -57,10 +67,14 @@ class AuthMiddlewareHandler : public MiddlewareHandler { : MiddlewareHandler(next), credentials_(credentials) {} bool check_auth(AsyncWebServerRequest *request) { - bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials_->username, credentials_->password); +#else + bool success = request->authenticate(credentials_->basic_auth_hash); +#endif if (!success) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. #if USE_ESP32 request->requestAuthentication(); #elif defined(USE_WEBSERVER_AUTH_DIGEST) @@ -125,8 +139,12 @@ class WebServerBase final { AsyncWebServer *get_server() const { return this->server_; } #ifdef USE_WEBSERVER_AUTH - void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } - void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + void set_auth_username(const char *auth_username) { credentials_.username = auth_username; } + void set_auth_password(const char *auth_password) { credentials_.password = auth_password; } +#else + void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } +#endif #endif void add_handler(AsyncWebHandler *handler); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 8068e1b022..4bb6629da1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -15,6 +15,7 @@ from esphome.components.esp32 import ( ) from esphome.components.network import ( add_use_address, + get_network_priority, has_high_performance_networking, ip_address_literal, ) @@ -602,6 +603,10 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + + prio = get_network_priority("wifi") + if prio is not None: + cg.set_setup_priority(var, prio) add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 650b06cae1..127eb50df1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -824,6 +824,15 @@ void WiFiComponent::loop() { this->status_clear_warning(); this->last_connected_ = now; +#ifdef USE_WIFI_CONNECT_STATE_LISTENERS + // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the + // state machine ever leaving STA_CONNECTED, so the notification the + // connected event marked pending would never be flushed by + // check_connecting_finished(). Cheap when nothing is pending: the + // method returns immediately on a single flag test. + this->notify_connect_state_listeners_(); +#endif + // Post-connect roaming: check for better AP if (this->post_connect_roaming_) { if (this->roaming_state_ == RoamingState::SCANNING) { @@ -1108,7 +1117,7 @@ void WiFiComponent::connect_soon_() { void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; int8_t priority = 0; if (ap.has_bssid()) { @@ -1667,7 +1676,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->clear_all_bssid_priorities_(); #ifdef USE_WIFI_FAST_CONNECT - this->save_fast_connect_settings_(); + this->save_fast_connect_settings_(this->wifi_bssid(), get_wifi_channel()); #endif this->release_scan_results_(); @@ -2059,7 +2068,7 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", bssid_s, old_priority, new_priority); @@ -2292,9 +2301,7 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { return false; } -void WiFiComponent::save_fast_connect_settings_() { - bssid_t bssid = wifi_bssid(); - uint8_t channel = get_wifi_channel(); +void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel) { // selected_sta_index_ is always valid here (called only after successful connection) // Fallback to 0 is defensive programming for robustness int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0; @@ -2407,6 +2414,25 @@ void WiFiComponent::clear_roaming_state_() { this->roaming_state_ = RoamingState::IDLE; } +#ifdef USE_ESP32 +void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { + // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the state + // machine ever leaving STA_CONNECTED, so check_connecting_finished() never runs. + // Redo its post-connect bookkeeping here. roaming_state_ is deliberately left + // untouched so an in-flight roaming scan is not orphaned. The BSSID and + // channel both come from the connected event so the saved pair is consistent: + // the radio may be off-channel during a roaming scan, and a later queued + // event may have moved the driver on again by the time this one is processed. + this->roaming_last_check_ = App.get_loop_component_start_time(); + this->roaming_attempts_ = 0; + this->roaming_scan_end_ = 0; + this->clear_all_bssid_priorities_(); +#ifdef USE_WIFI_FAST_CONNECT + this->save_fast_connect_settings_(bssid, channel); +#endif +} +#endif + void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 03946e0a17..ea043fd5c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -65,6 +65,12 @@ extern "C" { #include #endif +#ifdef USE_ESP32 +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this widely-included header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::wifi { /// Sentinel value for RSSI when WiFi is not connected @@ -469,6 +475,12 @@ class WiFiComponent final : public Component { bool is_connected() const { return this->connected_; } +#ifdef USE_ESP32 + /// esp_netif handle of the station interface, used by network for default-route + /// arbitration. nullptr until wifi_lazy_init_() has run. + esp_netif_t *get_esp_netif_sta(); +#endif + void set_power_save_mode(WiFiPowerSaveMode power_save); void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } @@ -781,13 +793,19 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); - void save_fast_connect_settings_(); + void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); void clear_roaming_state_(); +#ifdef USE_ESP32 + /// Redo post-connect bookkeeping after a driver-initiated roam (e.g. 802.11v BTM) + /// @param bssid The new AP's BSSID, taken from the connected event + /// @param channel The new AP's channel, taken from the connected event + void handle_driver_roam_(const bssid_t &bssid, uint8_t channel); +#endif /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). bool roaming_suppressed_() const { @@ -831,7 +849,7 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); - void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); + void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); #endif #ifdef USE_LIBRETINY diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e082b2c8c1..719a276bf9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -516,7 +516,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { (const char *) it.ssid); global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d78cd21380..245390b097 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -140,7 +140,7 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi } void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; if (has_custom_mac_address()) { get_mac_address_raw(mac); set_mac_address(mac); @@ -620,6 +620,8 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return true; } +esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } + network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { if (!this->has_sta()) return {}; @@ -825,6 +827,19 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); #endif s_sta_connected = true; + if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED) { + // Driver-initiated roam: the WIFI_REASON_ROAMING disconnect was ignored, + // so the state machine never left STA_CONNECTED. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO + char roam_bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, roam_bssid_s); + ESP_LOGI(TAG, "Roamed ssid='%.*s' bssid=" LOG_SECRET("%s") " channel=%u", it.ssid_len, (const char *) it.ssid, + roam_bssid_s, it.channel); +#endif + bssid_t roam_bssid; + std::copy(it.bssid, it.bssid + 6, roam_bssid.begin()); + this->handle_driver_roam_(roam_bssid, it.channel); + } #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Defer listener notification until state machine reaches STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations @@ -847,7 +862,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); return; } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ce9c4eb6ce..66c397a8ad 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -81,7 +81,7 @@ struct LTWiFiEvent { uint8_t scan_id; } scan_done; struct { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; int rssi; } ap_probe_req; } data; @@ -391,7 +391,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { auto &it = info.wifi_ap_probereqrecved; - memcpy(to_send->data.ap_probe_req.mac, it.mac, 6); + memcpy(to_send->data.ap_probe_req.mac, it.mac, MAC_ADDRESS_SIZE); to_send->data.ap_probe_req.rssi = it.rssi; break; } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 00a07d4085..69af9e9a4e 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -109,10 +109,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving // up to 20 additional seconds of blocking per attempt. auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str()); - if (ret == WL_IDLE_STATUS) - return false; - - return true; + return ret != WL_IDLE_STATUS; } bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); } @@ -169,11 +166,11 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { } int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { - global_wifi_component->wifi_scan_result(env, result); + global_wifi_component->wifi_scan_result_(env, result); return 0; } -void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { +void WiFiComponent::wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result) { s_scan_result_count++; // CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated. @@ -286,7 +283,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { // Filter out AP interface addresses — addrList includes all lwIP netifs. // The AP netif IP lingers even after the AP radio is disabled. IPAddress ap_ip = WiFi.softAPIP(); - for (auto addr : addrList) { + for (const auto &addr : addrList) { IPAddress ip(addr.ipFromNetifNum()); if (ip == ap_ip) { continue; @@ -355,12 +352,11 @@ bool WiFiComponent::wifi_loop_() { // Detect IP address changes (only when connected) if (is_connected) { - bool has_ip = false; - // Check for any IP address (IPv4 or IPv6) - for (auto addr : addrList) { - has_ip = true; - break; - } + // Check for any IP address (IPv4 or IPv6). The iterator comparison + // operators take non-const references, so the temporaries need names. + auto addr_it = addrList.begin(); + auto addr_end = addrList.end(); + bool has_ip = addr_it != addr_end; if (has_ip && !s_sta_had_ip) { // Just got IP address diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py index 51971a1220..089a4fa99a 100644 --- a/esphome/components/wifi/wpa2_eap.py +++ b/esphome/components/wifi/wpa2_eap.py @@ -58,7 +58,7 @@ def wrapped_load_pem_private_key(value, password): def read_relative_config_path(value): # pylint: disable=unspecified-encoding - return Path(CORE.relative_config_path(value)).read_text() + return Path(CORE.relative_config_path(value)).read_text(encoding="utf-8") def _validate_load_certificate(value): diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index b5ebfd7390..5d4e77eaad 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -1,5 +1,6 @@ #include "wifi_info_text_sensor.h" #ifdef USE_WIFI +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #ifdef USE_ESP8266 @@ -125,7 +126,7 @@ void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_lis void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } void BSSIDWiFiInfo::on_wifi_connect_state(StringRef ssid, std::span bssid) { - char buf[18] = "unknown"; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 7ade170c02..eecedee133 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -87,7 +87,7 @@ class PowerSaveModeWiFiInfo final : public Component, class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); } void dump_config() override; diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 31de6639da..ea9e5a3b0c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -6,7 +6,17 @@ import esphome.codegen as cg from esphome.components import time from esphome.components.esp32 import CORE, add_idf_sdkconfig_option import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_REBOOT_TIMEOUT, CONF_TIME_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + CONF_REBOOT_TIMEOUT, + CONF_TIME_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) from esphome.core import TimePeriod CONF_NETMASK = "netmask" @@ -57,30 +67,41 @@ def _cidr_network(value): return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(Wireguard), - cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), - cv.Required(CONF_ADDRESS): cv.ipv4address, - cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, - cv.Required(CONF_PRIVATE_KEY): cv.sensitive(_wireguard_key), - cv.Required(CONF_PEER_ENDPOINT): cv.string, - cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, - cv.Optional(CONF_PEER_PORT, default=51820): cv.port, - cv.Optional(CONF_PEER_PRESHARED_KEY): cv.sensitive(_wireguard_key), - cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( - _cidr_network - ), - cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( - cv.positive_time_period_seconds, - cv.Range(max=TimePeriod(seconds=65535)), - ), - cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Wireguard), + cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Required(CONF_ADDRESS): cv.ipv4address, + cv.Optional(CONF_NETMASK, default="255.255.255.255"): cv.ipv4address, + cv.Required(CONF_PRIVATE_KEY): cv.sensitive(_wireguard_key), + cv.Required(CONF_PEER_ENDPOINT): cv.string, + cv.Required(CONF_PEER_PUBLIC_KEY): _wireguard_key, + cv.Optional(CONF_PEER_PORT, default=51820): cv.port, + cv.Optional(CONF_PEER_PRESHARED_KEY): cv.sensitive(_wireguard_key), + cv.Optional(CONF_PEER_ALLOWED_IPS, default=["0.0.0.0/0"]): cv.ensure_list( + _cidr_network + ), + cv.Optional(CONF_PEER_PERSISTENT_KEEPALIVE, default="0s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(max=TimePeriod(seconds=65535)), + ), + cv.Optional( + CONF_REBOOT_TIMEOUT, default="15min" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_REQUIRE_CONNECTION_TO_PROCEED, default=False): cv.boolean, + } + ).extend(cv.polling_component_schema("10s")), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) async def to_code(config): diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index e0724aa94a..5150cda2a5 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -13,7 +13,7 @@ #include #endif -#ifdef USE_BK72XX +#ifdef USE_LIBRETINY #include #endif diff --git a/esphome/components/wled/wled_light_effect.h b/esphome/components/wled/wled_light_effect.h index bed897f5a6..07abb7c674 100644 --- a/esphome/components/wled/wled_light_effect.h +++ b/esphome/components/wled/wled_light_effect.h @@ -8,7 +8,14 @@ #include #include +#if defined(USE_RP2) || defined(USE_LIBRETINY) +namespace arduino { class UDP; +} // namespace arduino +using arduino::UDP; // NOLINT(google-global-names-in-headers) +#else +class UDP; +#endif namespace esphome::wled { diff --git a/esphome/components/xiaomi_ble/__init__.py b/esphome/components/xiaomi_ble/__init__.py index 541a0e7894..7f5045f1ce 100644 --- a/esphome/components/xiaomi_ble/__init__.py +++ b/esphome/components/xiaomi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble") XiaomiListener = xiaomi_ble_ns.class_( - "XiaomiListener", esp32_ble_tracker.ESPBTDeviceListener + "XiaomiListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(XiaomiListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0961df2bd6..06c3a7ab7a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -2,14 +2,21 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - #include + +// AES-CCM backend for encrypted-payload (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code. +#ifdef USE_ESP32 #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define XIAOMI_CRYPTO_PSA +#endif +#endif +#ifndef XIAOMI_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::xiaomi_ble { @@ -166,7 +173,7 @@ bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult return success; } -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data) { +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data) { XiaomiParseResult result; if (!service_data.uuid.contains(0x95, 0xFE)) { ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes."); @@ -286,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c return false; } - uint8_t mac_reverse[6] = {0}; + uint8_t mac_reverse[MAC_ADDRESS_SIZE] = {0}; mac_reverse[5] = (uint8_t) (address >> 40); mac_reverse[4] = (uint8_t) (address >> 32); mac_reverse[3] = (uint8_t) (address >> 24); @@ -318,7 +325,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1) memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef XIAOMI_CRYPTO_PSA // PSA AEAD expects ciphertext + tag concatenated uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)]; memcpy(ct_with_tag, vector.ciphertext, vector.datasize); @@ -344,24 +351,14 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c psa_destroy_key(key_id); bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize); #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, vector.key, vector.keysize * 8); - if (ret) { - ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize, - vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize); - mbedtls_ccm_free(&ctx); - bool decrypt_ok = (ret == 0); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + bool decrypt_ok = ble_device_base::aes_ccm_auth_decrypt(vector.key, vector.iv, vector.ivsize, vector.authdata, + vector.authsize, vector.ciphertext, vector.datasize, + vector.plaintext, vector.tag, vector.tagsize); #endif if (!decrypt_ok) { - uint8_t mac_address[6] = {0}; + uint8_t mac_address[MAC_ADDRESS_SIZE] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); memcpy(mac_address + 2, mac_reverse + 3, 1); @@ -448,7 +445,7 @@ bool report_xiaomi_results(const optional &result, const char return true; } -bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Previously the message was parsed twice per packet, once by XiaomiListener::parse_device() // and then again by the respective device class's parse_device() function. Parsing the header // here and then for each device seems to be unnecessary and complicates the duplicate packet filtering. @@ -460,5 +457,3 @@ bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_ble - -#endif diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index 1ebcf0e2f5..2f3a14c150 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -1,12 +1,10 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_ble { struct XiaomiParseResult { @@ -68,15 +66,13 @@ struct XiaomiAESVector { bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result); bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult &result); -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data); +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data); bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::xiaomi_ble - -#endif diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index e11ddac19d..7206f023d7 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgd1") XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGD1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGD1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgd1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGD1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 948e02be46..0159314f4d 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { static const char *const TAG = "xiaomi_cgd1"; @@ -21,7 +19,7 @@ void XiaomiCGD1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGD1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGD1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h index 1c510c7eb4..afa88738f0 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { -class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGD1 : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListen }; } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index c7ec13f6e0..0e7535cd76 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,18 +17,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] -xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") -XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGDK2", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +xiaomi_cgdk2_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") +XiaomiCGDK2 = xiaomi_cgdk2_ns.class_( + "XiaomiCGDK2", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgdk2"), cv.Schema( { - cv.GenerateID(): cv.declare_id(XiaomiCGD1), + cv.GenerateID(): cv.declare_id(XiaomiCGDK2), cv.Required(CONF_BINDKEY): cv.bind_key, cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index ff9036db14..01912c3778 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { static const char *const TAG = "xiaomi_cgdk2"; @@ -21,7 +19,7 @@ void XiaomiCGDK2::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGDK2::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGDK2::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 36068ae227..a27d41cea5 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDevic }; } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 1a6ed2b7da..6273d8549b 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgg1_ns = cg.esphome_ns.namespace("xiaomi_cgg1") XiaomiCGG1 = xiaomi_cgg1_ns.class_( - "XiaomiCGG1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGG1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgg1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGG1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index ef4ef46424..679cb76198 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { static const char *const TAG = "xiaomi_cgg1"; @@ -21,7 +19,7 @@ void XiaomiCGG1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGG1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGG1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index 7633458cb8..ef666ab6d2 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDevice }; } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/esphome/components/xiaomi_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 0606c93dbe..3fdcd983b0 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,18 +18,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_cgpr1_ns = cg.esphome_ns.namespace("xiaomi_cgpr1") XiaomiCGPR1 = xiaomi_cgpr1_ns.class_( "XiaomiCGPR1", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgpr1"), binary_sensor.binary_sensor_schema(XiaomiCGPR1, device_class=DEVICE_CLASS_MOTION) .extend( { @@ -57,15 +57,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 3203f358b9..019de54ad8 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { static const char *const TAG = "xiaomi_cgpr1"; @@ -16,7 +14,7 @@ void XiaomiCGPR1::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGPR1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -62,5 +60,3 @@ bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGPR1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 0fa6c76e54..9e1d1c4482 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { class XiaomiCGPR1 final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_level(sensor::Sensor *battery_level) { battery_level_ = battery_level; } @@ -33,5 +31,3 @@ class XiaomiCGPR1 final : public Component, }; } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 6c9ad2e361..f430cbdd10 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_gcls002_ns = cg.esphome_ns.namespace("xiaomi_gcls002") XiaomiGCLS002 = xiaomi_gcls002_ns.class_( - "XiaomiGCLS002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiGCLS002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_gcls002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiGCLS002), @@ -58,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 11ea98045b..27effd64bb 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_gcls002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { static const char *const TAG = "xiaomi_gcls002"; @@ -15,7 +13,7 @@ void XiaomiGCLS002::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiGCLS002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index 668133f364..969218c220 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 90a8753412..2c2e88b75f 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,15 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccjcy01_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy01") XiaomiHHCCJCY01 = xiaomi_hhccjcy01_ns.class_( - "XiaomiHHCCJCY01", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY01", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy01"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY01), @@ -68,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 1d872c68c1..5e2369a6d9 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccjcy01.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { static const char *const TAG = "xiaomi_hhccjcy01"; @@ -16,7 +14,7 @@ void XiaomiHHCCJCY01::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY01::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -61,5 +59,3 @@ bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index cb53b47f6f..ce573b73c1 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -32,5 +30,3 @@ class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index d6a4a4adb2..56eeda484e 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_hhccjcy10_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy10") XiaomiHHCCJCY10 = xiaomi_hhccjcy10_ns.class_( - "XiaomiHHCCJCY10", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY10", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy10"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY10), @@ -67,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index c6ebd5ff74..680eb04e77 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy10 { static const char *const TAG = "xiaomi_hhccjcy10"; @@ -17,7 +15,7 @@ void XiaomiHHCCJCY10::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -63,5 +61,3 @@ bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index fa2f461534..ce6dc2081e 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -31,5 +29,3 @@ class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index adc64f6650..50b10777bb 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -13,15 +13,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccpot002_ns = cg.esphome_ns.namespace("xiaomi_hhccpot002") XiaomiHHCCPOT002 = xiaomi_hhccpot002_ns.class_( - "XiaomiHHCCPOT002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCPOT002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccpot002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCPOT002), @@ -40,15 +40,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index bbca9faaa6..fc8d15228d 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccpot002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { static const char *const TAG = "xiaomi_hhccpot002"; @@ -13,7 +11,7 @@ void XiaomiHHCCPOT002 ::dump_config() { LOG_SENSOR(" ", "Conductivity", this->conductivity_); } -bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCPOT002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,5 +50,3 @@ bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 3eda1b9859..e472178baa 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_moisture(sensor::Sensor *moisture) { moisture_ = moisture; } @@ -26,5 +24,3 @@ class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 5890ed6b63..7467f08785 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_jqjcy01ym_ns = cg.esphome_ns.namespace("xiaomi_jqjcy01ym") XiaomiJQJCY01YM = xiaomi_jqjcy01ym_ns.class_( - "XiaomiJQJCY01YM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiJQJCY01YM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_jqjcy01ym"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiJQJCY01YM), @@ -59,15 +59,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index c0f4de3d06..f7a1318d7c 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -1,8 +1,6 @@ #include "xiaomi_jqjcy01ym.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { static const char *const TAG = "xiaomi_jqjcy01ym"; @@ -15,7 +13,7 @@ void XiaomiJQJCY01YM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiJQJCY01YM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index 122c6776c9..955ee41880 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index ef6aebe6c0..c455961e7e 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd02_ns = cg.esphome_ns.namespace("xiaomi_lywsd02") XiaomiLYWSD02 = xiaomi_lywsd02_ns.class_( - "XiaomiLYWSD02", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index 75909738c8..d465f2fec0 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsd02.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { static const char *const TAG = "xiaomi_lywsd02"; @@ -14,7 +12,7 @@ void XiaomiLYWSD02::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index 09256047ae..0c1035bf1d 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 813429a6c5..000460b333 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@juanluss31"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_lywsd02mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd02mmc") XiaomiLYWSD02MMC = xiaomi_lywsd02mmc_ns.class_( - "XiaomiLYWSD02MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index 79610ee266..dca5f73909 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { static const char *const TAG = "xiaomi_lywsd02mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD02MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD02MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index efd758b972..e00afffe0a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index bf2de3756c..6362f26524 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd03mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd03mmc") XiaomiLYWSD03MMC = xiaomi_lywsd03mmc_ns.class_( - "XiaomiLYWSD03MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD03MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd03mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD03MMC), @@ -54,15 +54,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index 7aa4809e24..356a4ffd4e 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { static const char *const TAG = "xiaomi_lywsd03mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD03MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD03MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD03MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index ecdbd412cb..a4f6e53215 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 5d964ea22a..0fbe4fcda9 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsdcgq_ns = cg.esphome_ns.namespace("xiaomi_lywsdcgq") XiaomiLYWSDCGQ = xiaomi_lywsdcgq_ns.class_( - "XiaomiLYWSDCGQ", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSDCGQ", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsdcgq"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSDCGQ), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 56efaaef51..1ddf7ec235 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsdcgq.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { static const char *const TAG = "xiaomi_lywsdcgq"; @@ -14,7 +12,7 @@ void XiaomiLYWSDCGQ::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSDCGQ::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index 86afef4571..5cecc2f78a 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 86c4d6699f..de1b3ea4b8 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc303_ns = cg.esphome_ns.namespace("xiaomi_mhoc303") XiaomiMHOC303 = xiaomi_mhoc303_ns.class_( - "XiaomiMHOC303", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC303", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc303"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC303), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 74626ed0a5..9706e50861 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mhoc303.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { static const char *const TAG = "xiaomi_mhoc303"; @@ -14,7 +12,7 @@ void XiaomiMHOC303::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC303::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index 042a5034f1..a15b58f8ed 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index 7161e88da5..4604af218e 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,15 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@vevsvevs"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc401_ns = cg.esphome_ns.namespace("xiaomi_mhoc401") XiaomiMHOC401 = xiaomi_mhoc401_ns.class_( - "XiaomiMHOC401", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC401", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc401"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC401), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 958ac59bde..d725978418 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { static const char *const TAG = "xiaomi_mhoc401"; @@ -21,7 +19,7 @@ void XiaomiMHOC401::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC401::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiMHOC401::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 3570f70a16..3978e557f0 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index 14e5c1d376..8a2ac6bbb3 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, @@ -15,14 +15,15 @@ from esphome.const import ( UNIT_OHM, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_miscale_ns = cg.esphome_ns.namespace("xiaomi_miscale") XiaomiMiscale = xiaomi_miscale_ns.class_( - "XiaomiMiscale", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMiscale", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_miscale"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMiscale), @@ -43,15 +44,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 2b1492129c..482c0ed395 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,9 +1,7 @@ #include "xiaomi_miscale.h" -#include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { static const char *const TAG = "xiaomi_miscale"; @@ -14,7 +12,7 @@ void XiaomiMiscale::dump_config() { LOG_SENSOR(" ", "Impedance", this->impedance_); } -bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMiscale::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,14 +54,14 @@ bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -optional XiaomiMiscale::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional XiaomiMiscale::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; - if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { + if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { result.version = 1; - } else if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { + } else if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { result.version = 2; } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; + char uuid_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGVV(TAG, "parse_header(): Couldn't identify scale version or data size was not correct. UUID: %s, data_size: %d", service_data.uuid.to_str(uuid_buf), service_data.data.size()); @@ -167,5 +165,3 @@ bool XiaomiMiscale::report_results_(const optional &result, const c } } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index 3213f5d6de..64cc2ff567 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { struct ParseResult { @@ -16,11 +14,11 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_weight(sensor::Sensor *weight) { weight_ = weight; } void set_impedance(sensor::Sensor *impedance) { impedance_ = impedance; } @@ -32,7 +30,7 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev sensor::Sensor *impedance_{nullptr}; bool clear_impedance_{false}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool parse_message_v1_(const std::vector &message, ParseResult &result); bool parse_message_v2_(const std::vector &message, ParseResult &result); @@ -40,5 +38,3 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py index 312abc82cb..4cfc82d2c6 100644 --- a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py +++ b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -20,18 +20,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_mjyd02yla_ns = cg.esphome_ns.namespace("xiaomi_mjyd02yla") XiaomiMJYD02YLA = xiaomi_mjyd02yla_ns.class_( "XiaomiMJYD02YLA", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mjyd02yla"), binary_sensor.binary_sensor_schema( XiaomiMJYD02YLA, device_class=DEVICE_CLASS_MOTION ) @@ -63,15 +63,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index a7b2554aad..233f5f5783 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { static const char *const TAG = "xiaomi_mjyd02yla"; @@ -17,7 +15,7 @@ void XiaomiMJYD02YLA::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMJYD02YLA::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiMJYD02YLA::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index da02dee003..ba2fe1b62c 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { class XiaomiMJYD02YLA final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_idle_time(sensor::Sensor *idle_time) { idle_time_ = idle_time; } @@ -35,5 +33,3 @@ class XiaomiMJYD02YLA final : public Component, }; } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/binary_sensor.py b/esphome/components/xiaomi_mue4094rt/binary_sensor.py index c5d93384c9..6df8dcb8ea 100644 --- a/esphome/components/xiaomi_mue4094rt/binary_sensor.py +++ b/esphome/components/xiaomi_mue4094rt/binary_sensor.py @@ -1,21 +1,21 @@ from esphome import core import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import CONF_MAC_ADDRESS, CONF_TIMEOUT, DEVICE_CLASS_MOTION -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mue4094rt_ns = cg.esphome_ns.namespace("xiaomi_mue4094rt") XiaomiMUE4094RT = xiaomi_mue4094rt_ns.class_( "XiaomiMUE4094RT", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mue4094rt"), binary_sensor.binary_sensor_schema( XiaomiMUE4094RT, device_class=DEVICE_CLASS_MOTION ) @@ -28,15 +28,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_time(config[CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index 259e0159c5..eca83c0912 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mue4094rt.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { static const char *const TAG = "xiaomi_mue4094rt"; @@ -12,7 +10,7 @@ void XiaomiMUE4094RT::dump_config() { LOG_BINARY_SENSOR(" ", "Motion", this); } -bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -51,5 +49,3 @@ bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index 4751e35e65..1ca40bf8ca 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -2,20 +2,18 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { class XiaomiMUE4094RT final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_time(uint16_t timeout) { timeout_ = timeout; } @@ -26,5 +24,3 @@ class XiaomiMUE4094RT final : public Component, }; } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index df143bac22..3e235d985f 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,19 +1,19 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["esp32_ble_tracker"] MULTI_CONF = True xiaomi_rtcgq02lm_ns = cg.esphome_ns.namespace("xiaomi_rtcgq02lm") XiaomiRTCGQ02LM = xiaomi_rtcgq02lm_ns.class_( - "XiaomiRTCGQ02LM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiRTCGQ02LM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_rtcgq02lm"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiRTCGQ02LM), @@ -21,15 +21,15 @@ CONFIG_SCHEMA = ( cv.Required(CONF_MAC_ADDRESS): cv.mac_address, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index b42a5a3700..f349dfa797 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { static const char *const TAG = "xiaomi_rtcgq02lm"; @@ -24,7 +22,7 @@ void XiaomiRTCGQ02LM::dump_config() { #endif } -bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -79,5 +77,3 @@ bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiRTCGQ02LM::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index 0d3427cc4d..d776c22d9e 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/defines.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" @@ -11,16 +11,14 @@ #include "esphome/components/xiaomi_ble/xiaomi_ble.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; #ifdef USE_BINARY_SENSOR @@ -54,5 +52,3 @@ class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/binary_sensor.py b/esphome/components/xiaomi_wx08zm/binary_sensor.py index 69facf54ed..6aaf94f48f 100644 --- a/esphome/components/xiaomi_wx08zm/binary_sensor.py +++ b/esphome/components/xiaomi_wx08zm/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -12,18 +12,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_wx08zm_ns = cg.esphome_ns.namespace("xiaomi_wx08zm") XiaomiWX08ZM = xiaomi_wx08zm_ns.class_( "XiaomiWX08ZM", binary_sensor.BinarySensor, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, cg.Component, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_wx08zm"), binary_sensor.binary_sensor_schema(XiaomiWX08ZM) .extend( { @@ -43,15 +43,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index 1bf861a6af..ae37d63096 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -1,8 +1,6 @@ #include "xiaomi_wx08zm.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { static const char *const TAG = "xiaomi_wx08zm"; @@ -14,7 +12,7 @@ void XiaomiWX08ZM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiWX08ZM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,5 +54,3 @@ bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0573959473..bbb7b66352 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -3,20 +3,18 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { class XiaomiWX08ZM final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_tablet(sensor::Sensor *tablet) { tablet_ = tablet; } @@ -29,5 +27,3 @@ class XiaomiWX08ZM final : public Component, }; } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index b41a775f35..758fa53d9e 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@medusalix"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_xmwsdj04mmc_ns = cg.esphome_ns.namespace("xiaomi_xmwsdj04mmc") XiaomiXMWSDJ04MMC = xiaomi_xmwsdj04mmc_ns.class_( - "XiaomiXMWSDJ04MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiXMWSDJ04MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_xmwsdj04mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiXMWSDJ04MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index c2b3ec1437..aba954fd91 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { static const char *const TAG = "xiaomi_xmwsdj04mmc"; @@ -21,7 +19,7 @@ void XiaomiXMWSDJ04MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiXMWSDJ04MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic void XiaomiXMWSDJ04MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index c7d20aa356..90b2c4e420 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index b98f94d37a..338d1986ea 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from pathlib import Path import textwrap from typing import TypedDict @@ -16,17 +17,36 @@ from .const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, KEY_SYSBUILD, - KEY_USER, KEY_ZEPHYR, zephyr_ns, ) CODEOWNERS = ["@tomaszduda23"] -PrjConfValueType = bool | str | int + +class HexValue: + """Wrap an integer so it is written as 0x... in prj.conf (required for hex Kconfig types).""" + + def __init__(self, value: int) -> None: + self.value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, HexValue): + return self.value == other.value + return NotImplemented + + def __repr__(self) -> str: + return f"HexValue(0x{self.value:X})" + + def __str__(self) -> str: + return f"0x{self.value:X}" + + +PrjConfValueType = bool | str | int | HexValue class Section: @@ -54,9 +74,9 @@ class ZephyrData(TypedDict): overlay: dict[str, str] extra_build_files: dict[str, Path] pm_static: list[Section] - user: dict[str, list[str]] kconfig: str sysbuild: bool + overlay_builder: list[Callable[[], str]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -67,9 +87,9 @@ def zephyr_set_core_data(config: ConfigType) -> None: overlay={ "": "", }, # set empty to make sure that overlay is cleared after config change + overlay_builder=[], extra_build_files={}, pm_static=[], - user={}, kconfig="", # When OTA is disabled, the image is built without a bootloader even if the # config says `bootloader: mcuboot`, so the image can be smaller. This was @@ -113,6 +133,12 @@ def zephyr_add_overlay(content: str, image: str = "") -> None: data[KEY_OVERLAY][image] += textwrap.dedent(content) +def zephyr_add_overlay_builder(func: Callable[[], str]) -> None: + data = zephyr_data() + if func not in data[KEY_OVERLAY_BUILDER]: + data[KEY_OVERLAY_BUILDER].append(func) + + def add_extra_build_file(filename: str, path: Path) -> bool: """Add an extra build file to the project.""" extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES] @@ -132,6 +158,8 @@ def add_extra_script(stage: str, filename: str, path: Path) -> None: def zephyr_to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ZEPHYR") cg.add_define("USE_NATIVE_64BIT_TIME") + # The settings subsystem finds stored preferences by key, so key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.set_cpp_standard("gnu++20") # c++ support zephyr_add_prj_conf("FPU", True) @@ -164,6 +192,8 @@ def zephyr_setup_preferences(): def _format_prj_conf_val(value: PrjConfValueType) -> str: if isinstance(value, bool): return "y" if value else "n" + if isinstance(value, HexValue): + return hex(value.value) if isinstance(value, int): return str(value) if isinstance(value, str): @@ -201,13 +231,6 @@ def zephyr_add_pm_static(sections: list[Section]) -> None: zephyr_data()[KEY_PM_STATIC].extend(sections) -def zephyr_add_user(key, value): - user = zephyr_data()[KEY_USER] - if key not in user: - user[key] = [] - user[key] += [value] - - def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: """Write content to path, or remove a stale file when content is empty. @@ -222,20 +245,9 @@ def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> boo def copy_files() -> None: - user = zephyr_data()[KEY_USER] - if user: - entries = " ".join( - f"{key} = {', '.join(value)};" for key, value in user.items() - ) - zephyr_add_overlay( - f""" - / {{ - zephyr,user {{ - {entries} - }}; - }}; - """ - ) + for builder_func in zephyr_data()[KEY_OVERLAY_BUILDER]: + overlay_contents = builder_func() + zephyr_add_overlay(overlay_contents) changed = False @@ -249,7 +261,7 @@ def copy_files() -> None: ) if image: - path = CORE.relative_build_path(f"sysbuild/{image}.conf") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.conf") else: path = CORE.relative_build_path("zephyr/prj.conf") @@ -257,7 +269,7 @@ def copy_files() -> None: for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: - path = CORE.relative_build_path(f"sysbuild/{image}.overlay") + path = CORE.relative_build_path(f"zephyr/sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") changed |= write_file_if_changed(path, content) diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index 497e5f3ce5..0bb8d33a1f 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -7,12 +7,12 @@ BOOTLOADER_MCUBOOT = "mcuboot" KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" +KEY_OVERLAY_BUILDER: Final = "overlay_builder" KEY_PM_STATIC: Final = "pm_static" KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" -KEY_USER: Final = "user" KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py index 7654e63700..0e6551ccf1 100644 --- a/esphome/components/zephyr/library.py +++ b/esphome/components/zephyr/library.py @@ -65,10 +65,16 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: """ build = component.data.get("build", {}) + # The library's own files live in source_path (the user's directory for a + # local library, the downloaded dir otherwise); the generated zephyr/ files + # go under component.path. Sources are already emitted as absolute paths, so + # they resolve correctly wherever source_path points. + read_path = component.source_dir + build_src_dir = build.get("srcDir") if not build_src_dir: for d in ["src", "Src", "."]: - if (component.path / Path(d)).is_dir(): + if (read_path / Path(d)).is_dir(): build_src_dir = d break @@ -77,7 +83,7 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) src_files = collect_filtered_files( - component.path / Path(build_src_dir), build_src_filter + read_path / Path(build_src_dir), build_src_filter ) src_files = sorted( str(Path(p).resolve()) @@ -91,15 +97,19 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) + # The zephyr/CMakeLists lives in a subdir, so a relative -L would resolve + # from there rather than the library root; make link dirs absolute against + # the library's own directory (source_dir), matching src/include handling. + link_directories = [str((read_path / Path(d)).resolve()) for d in link_directories] link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] include_dirs = [ - str((component.path / Path(d)).resolve()) + str((read_path / Path(d)).resolve()) for d in include_dirs - if (component.path / Path(d)).is_dir() + if (read_path / Path(d)).is_dir() ] lines = [f"zephyr_library_named({component.get_require_name()})"] diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index c26a1d6d53..ed22613625 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -58,12 +58,19 @@ void ZephyrPreferences::open() { ESP_LOGD(TAG, "Loaded %zu settings.", this->backends_.size()); } -ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t type) { +ZephyrPreferenceBackend *ZephyrPreferences::find_backend_(uint32_t type) { for (auto *backend : this->backends_) { if (backend->get_type() == type) { - return ESPPreferenceObject(backend); + return backend; } } + return nullptr; +} + +ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t type) { + if (auto *backend = this->find_backend_(type)) { + return ESPPreferenceObject(backend); + } auto *pref = new ZephyrPreferenceBackend(type); // NOLINT(cppcoreguidelines-owning-memory) char key_buf[KEY_BUFFER_SIZE]; pref->format_key(key_buf, sizeof(key_buf)); @@ -72,6 +79,13 @@ ESPPreferenceObject ZephyrPreferences::make_preference(size_t length, uint32_t t return ESPPreferenceObject(pref); } +bool ZephyrPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + // Stored settings are preloaded into backends_ at boot by settings_load_subtree(), + // so a key with no registered backend has no stored data. + auto *backend = this->find_backend_(type); + return backend != nullptr && backend->load(data, len); +} + bool ZephyrPreferences::sync() { ESP_LOGD(TAG, "Save settings"); int err = settings_save(); diff --git a/esphome/components/zephyr/preferences.h b/esphome/components/zephyr/preferences.h index 9e2555f910..b1ad95fd74 100644 --- a/esphome/components/zephyr/preferences.h +++ b/esphome/components/zephyr/preferences.h @@ -16,10 +16,13 @@ class ZephyrPreferences final : public PreferencesMixin { return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating or registering a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); protected: + ZephyrPreferenceBackend *find_backend_(uint32_t type); std::vector backends_; static int load_setting(const char *name, size_t len, settings_read_cb read_cb, void *cb_arg); diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 0ff1825bd1..1503c94274 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,8 @@ import esphome.codegen as cg +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( + HexValue, zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,12 +74,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_mcumgr_bootloader(config: ConfigType) -> None: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader != BOOTLOADER_MCUBOOT: - raise cv.Invalid(f"'{bootloader}' bootloader does not support OTA") - - KEY_ZEPHYR_BLE_SERVER = "zephyr_ble_server" @@ -89,9 +85,22 @@ def _validate_ble_server(config: ConfigType) -> None: raise cv.Invalid(f"'{KEY_ZEPHYR_BLE_SERVER}' component is required for BLE OTA") +def _validate_bootloader(config: ConfigType) -> None: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: + return + if bootloader not in BOOTLOADER_CONFIG: + raise cv.Invalid(f"{bootloader} does not support OTA") + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + raise cv.Invalid( + "OTA with Adafruit_nRF52_Bootloader requires at least SDK 2.9.2" + ) + + def _final_validate(config: ConfigType) -> None: - _validate_mcumgr_bootloader(config) _validate_ble_server(config) + _validate_bootloader(config) FINAL_VALIDATE_SCHEMA = _final_validate @@ -152,3 +161,70 @@ async def to_code(config: ConfigType) -> None: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver >= cv.Version(2, 9, 2): zephyr_data()[KEY_SYSBUILD] = True + + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader != BOOTLOADER_MCUBOOT: + sections = BOOTLOADER_CONFIG[bootloader] + # Derive partition addresses from the SoftDevice and bootloader sections so + # that the DTS flash map matches what the Partition Manager produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + mcuboot_size = 0x9000 + sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) + bl_start = next(s.address for s in sections if "Adafruit" in s.name) + slot0_start = sd_end + mcuboot_size + # Align slot size down to a 4 KB sector boundary + slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot1_start = slot0_start + slot_size + + def _mcuboot_partition_overlay() -> str: + def part(name, start, size): + return f""" + {name}: partition@{start:x} {{ + reg = <0x{start:x} 0x{size:x}>; + }};""" + + return f""" + /delete-node/ &boot_partition; + /delete-node/ &storage_partition; + /delete-node/ &code_partition; + /delete-node/ &reserved_partition_0; + + &flash0 {{ + partitions {{ + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + {part("slot0_partition", slot0_start, slot_size)} + {part("slot1_partition", slot1_start, slot_size)} + }}; + }}; + """ + + def _code_partition_overlay() -> str: + return """ + / { + chosen { + zephyr,code-partition = &slot0_partition; + }; + }; + """ + + zephyr_add_overlay(_mcuboot_partition_overlay()) + zephyr_add_overlay(_mcuboot_partition_overlay(), "mcuboot") + zephyr_add_overlay(_code_partition_overlay()) + zephyr_add_overlay(_code_partition_overlay(), "mcuboot") + # mcuboot is second bootloader. It's only task is to swap partitions. + # recovery can be done by first bootloader. Keep it small. + zephyr_add_overlay( + """ + &zephyr_udc0 { + status = "disabled"; + }; + """, + "mcuboot", + ) + zephyr_add_prj_conf("USB_DEVICE_STACK", False, image="mcuboot") + zephyr_add_prj_conf("CONSOLE", False, image="mcuboot") + zephyr_add_prj_conf( + "PM_PARTITION_SIZE_MCUBOOT", HexValue(mcuboot_size), image="mcuboot" + ) diff --git a/esphome/components/zephyr_pwm/__init__.py b/esphome/components/zephyr_pwm/__init__.py new file mode 100644 index 0000000000..4bcce84845 --- /dev/null +++ b/esphome/components/zephyr_pwm/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@wiomoc"] diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py new file mode 100644 index 0000000000..54c04473e3 --- /dev/null +++ b/esphome/components/zephyr_pwm/output.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass, field + +from esphome import pins +import esphome.codegen as cg +from esphome.components import output +from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf +import esphome.config_validation as cv +from esphome.const import ( + CONF_ALLOW_OTHER_USES, + CONF_FREQUENCY, + CONF_ID, + CONF_INVERTED, + CONF_NUMBER, + CONF_OUTPUT, + CONF_PIN, + CONF_PLATFORM, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +DEPENDENCIES = ["zephyr"] +DOMAIN = "zephyr_pwm" + +zephyr_pwm_ns = cg.esphome_ns.namespace("zephyr_pwm") +ZephyrPWMChannel = zephyr_pwm_ns.class_( + "ZephyrPWMChannel", output.FloatOutput, cg.Component +) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=3.815, max=1e7)) + + +def _pin_schema(value): + value = pins.internal_gpio_output_pin_schema(value) + if value.get(CONF_ALLOW_OTHER_USES, False): + raise cv.Invalid("allow_other_uses is not supported for zephyr_pwm pins") + return value + + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(ZephyrPWMChannel), + cv.Required(CONF_PIN): _pin_schema, + cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + +PWM_BLOCK_COUNT = 4 +PWM_CHANNELS_PER_BLOCK = 4 + + +@dataclass +class PWMBlock: + id: int + period_ns: int + pins: list[int] + + +@dataclass +class ZephyrPWMData: + pwm_blocks: list[PWMBlock] = field(default_factory=list) + + +def _get_data() -> ZephyrPWMData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ZephyrPWMData() + return CORE.data[DOMAIN] + + +def _allocate_blocks() -> None: + full_config = fv.full_config.get() + zephyr_pwm_conf = [ + cfg + for cfg in full_config.get(CONF_OUTPUT, []) + if cfg.get(CONF_PLATFORM) == DOMAIN + ] + + pwm_blocks: list[PWMBlock] = [] + for cfg in zephyr_pwm_conf: + pin_number = cfg[CONF_PIN][CONF_NUMBER] + period_ns = int(1e9 / cfg[CONF_FREQUENCY]) + pwm_block = next( + ( + block + for block in pwm_blocks + if block.period_ns == period_ns + and len(block.pins) < PWM_CHANNELS_PER_BLOCK + ), + None, + ) + if pwm_block is None: + if len(pwm_blocks) >= PWM_BLOCK_COUNT: + raise cv.Invalid( + f"Only {PWM_BLOCK_COUNT} PWM blocks with a distinct frequency and {PWM_CHANNELS_PER_BLOCK} channels each are supported by nrf52" + ) + pwm_block = PWMBlock(id=len(pwm_blocks), period_ns=period_ns, pins=[]) + pwm_blocks.append(pwm_block) + pwm_block.pins.append(pin_number) + + _get_data().pwm_blocks = pwm_blocks + + +def _final_validate(config: ConfigType) -> ConfigType: + _allocate_blocks() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _overlay_pwm(): + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + + assert CORE.is_nrf52 + + overlay_parts = [] + + overlay_parts.extend( + f""" + &pwm{block.id} {{ + status = "okay"; + pinctrl-0 = <&pwm{block.id}_default_custom>; + pinctrl-1 = <&pwm{block.id}_sleep_custom>; + pinctrl-names = "default", "sleep"; + }};""" + for block in pwm_blocks + ) + + pinctls = [] + for block in pwm_blocks: + psels = ", ".join( + f"" + for channel_id, pin in enumerate(block.pins) + ) + pinctls.append(f""" + pwm{block.id}_default_custom: pwm{block.id}_default_custom {{ + group1 {{ + psels = {psels}; + }}; + }}; + pwm{block.id}_sleep_custom: pwm{block.id}_sleep_custom {{ + group1 {{ + psels = {psels}; + low-power-enable; + }}; + }};""") + + overlay_parts.append(f""" + &pinctrl {{ + {"\n".join(pinctls)} + }};""") + return "\n".join(overlay_parts) + + +async def to_code(config): + zephyr_add_prj_conf("PWM", True) + pin = config[CONF_PIN] + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + pwm_block = next( + (block for block in pwm_blocks if pin[CONF_NUMBER] in block.pins), None + ) + channel_id = pwm_block.pins.index(pin[CONF_NUMBER]) + + zephyr_add_overlay_builder(_overlay_pwm) + + pin_inverted = pin.get(CONF_INVERTED, False) + var = cg.new_Pvariable( + config[CONF_ID], + cg.RawExpression(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm{pwm_block.id}))"), + channel_id, + pin_inverted, + pwm_block.period_ns, + ) + await cg.register_component(var, config) + await output.register_output(var, config) diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp new file mode 100644 index 0000000000..aa1393388a --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -0,0 +1,39 @@ +#ifdef USE_ZEPHYR + +#include "zephyr_pwm.h" + +#include + +namespace esphome::zephyr_pwm { + +static const char *const TAG = "zephyr_pwm"; + +void ZephyrPWMChannel::setup() { + if (!device_is_ready(this->device_)) { + ESP_LOGE(TAG, "PWM is not ready."); + this->mark_failed(); + return; + } +} + +void ZephyrPWMChannel::dump_config() { + ESP_LOGCONFIG(TAG, + "Zephyr PWM:\n" + " Channel: %u\n" + " Period: %u ns", + this->channel_, this->period_ns_); + LOG_FLOAT_OUTPUT(this); +} +void HOT ZephyrPWMChannel::write_state(float state) { + uint32_t pulse_width_ns = state * this->period_ns_; + pwm_flags_t flags = this->pin_inverted_ ? PWM_POLARITY_INVERTED : PWM_POLARITY_NORMAL; + int err = pwm_set(this->device_, this->channel_, this->period_ns_, pulse_width_ns, flags); + if (err != 0) { + ESP_LOGE(TAG, "Failed to set PWM output: channel=%u, period=%u ns, pulse_width=%u ns, error=%d", this->channel_, + this->period_ns_, pulse_width_ns, err); + } +} + +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.h b/esphome/components/zephyr_pwm/zephyr_pwm.h new file mode 100644 index 0000000000..cfec0049a5 --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.h @@ -0,0 +1,31 @@ +#pragma once + +#ifdef USE_ZEPHYR +#include "esphome/core/defines.h" +#include "esphome/components/output/float_output.h" + +#include + +namespace esphome::zephyr_pwm { + +class ZephyrPWMChannel : public output::FloatOutput, public Component { + public: + explicit ZephyrPWMChannel(const struct device *device, uint8_t channel, bool pin_inverted, uint32_t period_ns) + : device_(device), channel_(channel), pin_inverted_(pin_inverted), period_ns_(period_ns) {} + + void setup() override; + void dump_config() override; + /// HARDWARE setup_priority + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + void write_state(float state) override; + + const struct device *device_; + uint8_t channel_; + bool pin_inverted_; + uint32_t period_ns_; +}; +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 775fb35140..47913b34d7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -13,7 +13,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME +from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -108,6 +108,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), + cv.Optional(CONF_ON_START): automation.validate_automation({}), cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All( cv.Any( cv.boolean, @@ -175,6 +176,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( _CALLBACK_AUTOMATIONS = [ automation.CallbackAutomation(CONF_ON_JOIN, "add_on_join_callback", [(bool, "x")]), + automation.CallbackAutomation(CONF_ON_START, "add_on_start_callback", []), ] diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index cfd23b9eb2..d922ae372f 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -14,12 +14,17 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_CENTIMETER, + UNIT_CUBIC_METER, + UNIT_CUBIC_METER_PER_HOUR, UNIT_DECIBEL, UNIT_HECTOPASCAL, UNIT_HERTZ, UNIT_HOUR, UNIT_KELVIN, UNIT_KILOMETER, + UNIT_KILOPASCAL, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE, UNIT_KILOWATT, UNIT_KILOWATT_HOURS, UNIT_LITRE_PER_SECOND, @@ -37,6 +42,7 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PASCAL, UNIT_PERCENT, + UNIT_PH, UNIT_SECOND, UNIT_VOLT, UNIT_WATT, @@ -90,10 +96,13 @@ BACNET_UNITS = { UNIT_OHM: 4, UNIT_WATT: 47, UNIT_KILOWATT: 48, + UNIT_KILOVOLT_AMPS: 9, + UNIT_KILOVOLT_AMPS_REACTIVE: 12, UNIT_WATT_HOURS: 18, UNIT_KILOWATT_HOURS: 19, UNIT_PASCAL: 53, UNIT_HECTOPASCAL: 133, + UNIT_KILOPASCAL: 54, UNIT_HERTZ: 27, UNIT_MILLIMETER: 30, UNIT_CENTIMETER: 118, @@ -110,6 +119,9 @@ BACNET_UNITS = { UNIT_LUX: 37, UNIT_DECIBEL: 199, UNIT_PERCENT: 98, + UNIT_CUBIC_METER: 80, + UNIT_CUBIC_METER_PER_HOUR: 135, + UNIT_PH: 234, } BACNET_UNIT_NO_UNITS = 95 diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index d7176e6ca5..1fb8d1abe4 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -9,16 +9,18 @@ namespace esphome::zigbee { static const char *const TAG = "zigbee.attribute"; void ZigbeeAttribute::set_attr_() { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_started()) { return; } if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); + // cleared before report_() so it can disable the loop + // when the report has to wait for join + this->set_attr_requested_ = false; if (this->force_report_) { this->report_(true); } - this->set_attr_requested_ = false; // Check for error if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); @@ -28,7 +30,14 @@ void ZigbeeAttribute::set_attr_() { } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected() || !this->report_enabled) { + if (!this->report_enabled) { + return; + } + if (!this->zb_->is_joined()) { + this->report_requested_ = true; + if (!this->set_attr_requested_) { + this->disable_loop(); + } return; } if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { @@ -44,6 +53,7 @@ void ZigbeeAttribute::report_(bool has_lock) { cmd.payload.attr_id = this->attr_id_; ezb_zcl_report_attr_cmd_req(&cmd); + this->report_requested_ = false; if (!has_lock) { esp_zigbee_lock_release(); } @@ -55,6 +65,11 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; } + this->zb_->add_on_join_callback([this](bool) { + if (this->report_requested_) { + this->enable_loop(); + } + }); } void ZigbeeAttribute::loop() { @@ -62,7 +77,11 @@ void ZigbeeAttribute::loop() { this->set_attr_(); } - if (!this->set_attr_requested_) { + if (this->report_requested_) { + this->report_(false); + } + + if (!this->report_requested_ && !this->set_attr_requested_) { this->disable_loop(); } } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index e5f8c8b1cf..fc229b4e95 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -66,6 +66,7 @@ class ZigbeeAttribute final : public Component { float scale_; void *value_p_{nullptr}; bool set_attr_requested_{false}; + bool report_requested_{false}; bool force_report_{false}; }; diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 2ed3dddb67..c2001c66d6 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -83,7 +83,7 @@ ep_configs: dict[str, dict[str, Any]] = { } -def get_next_ep_num(eps: list[int]) -> int: +def _get_next_ep_num(eps: list[int]) -> int: try: ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] eps.append(ep_num) @@ -94,7 +94,7 @@ def get_next_ep_num(eps: list[int]) -> int: return ep_num -def compare_clusters( +def _compare_clusters( existing_ep: dict[str, Any], ep: dict[str, Any], ) -> tuple[str | int, str] | None: @@ -105,12 +105,12 @@ def compare_clusters( return None -def merge_endpoints( +def _merge_endpoints( existing_ep: dict[str, Any], ep: dict[str, Any], use_type: bool | None, ) -> bool: - if compare_clusters(existing_ep, ep): + if _compare_clusters(existing_ep, ep): return False if ( ep.get(DEVICE_TYPE) @@ -134,7 +134,12 @@ def merge_endpoints( return True -def validate_endpoints(ep_dict: dict[int, dict]) -> None: +def _validate_endpoints(ep_dict: dict[int, dict]) -> None: + """Validate endpoint device type selection before endpoint creation. + + This resolves any deferred device type selections stored in CONF_USE_DEVICE_TYPE, + ensuring each endpoint has at most one active device type. + """ for num, ep in ep_dict.items(): types_dict = ep.get(CONF_USE_DEVICE_TYPE) if not types_dict: @@ -157,10 +162,18 @@ def validate_endpoints(ep_dict: dict[int, dict]) -> None: def create_ep(router: bool) -> None: + """Finalize Zigbee endpoint creation and normalize endpoint storage. + + Validate endpoints, merge endpoints, and assign numbers to endpoints without an explicit number. + This is called from final_validate. + + Args: + router: Whether the device is acting as a Zigbee router. + """ zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) - validate_endpoints(ep_dict) + _validate_endpoints(ep_dict) # create dummy endpoint if list is empty if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" @@ -173,7 +186,7 @@ def create_ep(router: bool) -> None: for ep in ep_list: added = False for existing_ep in ep_list_new: - if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): + if _merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): added = True break if not added: @@ -182,7 +195,7 @@ def create_ep(router: bool) -> None: # Add endpoints with no number to the endpoint dict with a new number eps = list(ep_dict.keys()) for ep in ep_list_new: - ep_num = get_next_ep_num(eps) + ep_num = _get_next_ep_num(eps) ep_dict[ep_num] = ep # clear list so that it is not processed again @@ -195,6 +208,14 @@ def create_ep(router: bool) -> None: def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + """Add a Zigbee endpoint configuration to CORE.data. + + Args: + ep: Endpoint configuration dictionary. + ep_num: Optional explicit endpoint number. + use_type: Optional boolean indicating whether this component's device type should be + used for the endpoint (True claims it, False drops it, None leaves it as a candidate). + """ zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) if use_type is False: ep.pop(DEVICE_TYPE, None) @@ -208,7 +229,7 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if ep_num in ep_dict: # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] - if cl := compare_clusters( + if cl := _compare_clusters( existing_ep, ep, ): diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 3e0f6cd745..482995e2c5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,6 +36,17 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } +void ZigbeeComponent::factory_reset() { + esp_zigbee_lock_acquire(portMAX_DELAY); + if (this->joined_) { + // send leave request and trigger EZB_ZDO_SIGNAL_LEAVE + ezb_bdb_reset_via_local_action(); + } else { + esp_zigbee_factory_reset(); // triggers a reboot + } + esp_zigbee_lock_release(); +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); @@ -53,6 +64,8 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { switch (signal_type) { case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); + global_zigbee->started_ = true; + global_zigbee->enable_loop_soon_any_context(); ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; case EZB_BDB_SIGNAL_DEVICE_FIRST_START: @@ -60,14 +73,14 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); if (status == EZB_BDB_STATUS_SUCCESS) { ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); - global_zigbee->started = true; if (ezb_bdb_is_factory_new()) { - global_zigbee->factory_new = true; + global_zigbee->factory_new_ = true; ESP_LOGD(TAG, "Start network steering"); ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); - global_zigbee->joined = true; + global_zigbee->joined_ = true; + global_zigbee->join_pending_ = true; global_zigbee->enable_loop_soon_any_context(); } } else { @@ -85,7 +98,8 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ezb_nwk_get_extended_panid(&extended_pan_id); ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); - global_zigbee->joined = true; + global_zigbee->joined_ = true; + global_zigbee->join_pending_ = true; global_zigbee->enable_loop_soon_any_context(); } else { ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); @@ -105,7 +119,29 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { const ezb_zdo_signal_leave_params_t *leave_params = (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { - esp_zigbee_factory_reset(); + esp_zigbee_factory_reset(); // triggers a reboot + } + global_zigbee->joined_ = false; + } break; + case EZB_NWK_SIGNAL_NETWORK_STATUS: { + const ezb_nwk_signal_network_status_params_t *network_status_params = + (const ezb_nwk_signal_network_status_params_t *) ezb_app_signal_get_params(app_signal); + if (network_status_params->status == EZB_NWK_NETWORK_STATUS_PARENT_LINK_FAILURE) { + global_zigbee->joined_ = false; + ESP_LOGW(TAG, "Parent link failure, attempting rejoin"); + ezb_zdo_nwk_mgmt_leave_req_t leave_req = { + .dst_nwk_addr = ezb_nwk_get_short_address(), + .field = + { + .remove_children = false, + .rejoin = true, + }, + }; + // Send leave request to the network to rejoin + // triggers EZB_ZDO_SIGNAL_LEAVE signal first, then EZB_BDB_SIGNAL_DEVICE_REBOOT + ezb_zdo_nwk_mgmt_leave_req(&leave_req); + } else { + ESP_LOGD(TAG, "Zigbee APP Signal NETWORK_STATUS: 0x%02x", network_status_params->status); } } break; default: @@ -303,9 +339,13 @@ void ZigbeeComponent::setup() { } void ZigbeeComponent::loop() { - if (this->joined.exchange(false)) { - this->connected_ = true; - this->join_cb_.call(this->factory_new); + if (!this->start_reported_ && this->started_) { + this->start_cb_.call(); + this->start_reported_ = true; + } + if (this->join_pending_.exchange(false)) { + this->join_cb_.call(this->factory_new_); + this->factory_new_ = false; } this->disable_loop(); } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index f4bafac294..c19fc3ad63 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -54,20 +54,19 @@ class ZigbeeComponent final : public Component { static bool app_signal_handler(const ezb_app_signal_t *app_signal); static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); - void factory_reset() { - esp_zigbee_lock_acquire(portMAX_DELAY); - esp_zigbee_factory_reset(); // triggers a reboot - esp_zigbee_lock_release(); - } + void factory_reset(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } - bool is_started() { return this->started; } - bool is_connected() { return this->connected_; } - std::atomic started = false; - std::atomic joined = false; - std::atomic factory_new = false; + + // True after the Zigbee stack has been initialized and the device has started up. Is set before the stack started + // network commissioning or has joined a network and won't be reset until the device is rebooted. + bool is_started() { return this->started_; } + + // True if the device has joined a network and is ready to send and receive messages. + bool is_joined() { return this->joined_; } protected: struct { @@ -76,7 +75,6 @@ class ZigbeeComponent final : public Component { uint8_t *date; uint8_t power_source; } basic_cluster_data_; - bool connected_ = false; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else @@ -92,6 +90,12 @@ class ZigbeeComponent final : public Component { std::map, ZigbeeAttribute *> attributes_; ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; + LazyCallbackManager start_cb_{}; + bool start_reported_{false}; + std::atomic started_ = false; + std::atomic joined_ = false; + std::atomic join_pending_ = false; + std::atomic factory_new_ = false; }; template diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 116dce8cc5..8e63c09e67 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ACCURACY_DECIMALS, CONF_AP, CONF_DEVICE, CONF_DEVICE_CLASS, @@ -185,6 +186,7 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: unit = config.get(CONF_UNIT_OF_MEASUREMENT) apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + accuracy = config.get(CONF_ACCURACY_DECIMALS) if apptype is not None: ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { @@ -200,6 +202,15 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: CONF_TYPE: "ENUM16", }, ) + if accuracy is not None: + # Analog Input Resolution (0x006A): smallest reportable change + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x6A, + CONF_VALUE: 10**-accuracy, + CONF_TYPE: "SINGLE", + }, + ) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 81aad7dcb1..b8bb0a2036 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -30,6 +30,9 @@ void ZigbeeComponent::zboss_signal_handler_esphome(zb_bufid_t bufid) { switch (sig) { case ZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_SKIP_STARTUP, status: %d", status); + if (status == RET_OK) { + on_start_(); + } break; case ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY: ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY, status: %d", status); @@ -137,6 +140,13 @@ void ZigbeeComponent::on_join_(bool factory_new) { }); } +void ZigbeeComponent::on_start_() { + this->defer([this]() { + ESP_LOGD(TAG, "Started zigbee stack"); + this->start_cb_.call(); + }); +} + #ifdef USE_ZIGBEE_WIPE_ON_BOOT void ZigbeeComponent::erase_flash_(int area) { const struct flash_area *fap; @@ -229,6 +239,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " Radio sleep time: %us\n" " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" @@ -238,9 +249,10 @@ 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_, 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()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, this->radio_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_(); } @@ -251,6 +263,13 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { + this->radio_sleep_remainder_ += ms; + uint32_t seconds = this->radio_sleep_remainder_ / 1000; + this->radio_sleep_remainder_ -= seconds * 1000; + this->radio_sleep_time_ += seconds; +} + void ZigbeeComponent::loop() { if (this->force_report_) { this->force_report_ = false; @@ -327,6 +346,36 @@ zb_ret_t __wrap_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_re esphome::zigbee::global_zigbee->after_reporting_info(config_rep_req, attr_addr_info); return ret; } + +extern void __real_zb_trans_enter_sleep(void); +extern void __real_zb_trans_enter_receive(void); +extern zb_bool_t __real_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel); + +static uint32_t radio_sleep_start_ms = 0; + +static void stop_radio_sleep_timer() { + if (radio_sleep_start_ms) { + esphome::zigbee::global_zigbee->add_radio_sleep_time_ms(esphome::millis() - radio_sleep_start_ms); + } + radio_sleep_start_ms = 0; +} + +void __wrap_zb_trans_enter_sleep(void) { + __real_zb_trans_enter_sleep(); + radio_sleep_start_ms = esphome::millis(); +} + +void __wrap_zb_trans_enter_receive(void) { + stop_radio_sleep_timer(); + __real_zb_trans_enter_receive(); +} + +zb_bool_t __wrap_zb_trans_transmit(zb_uint8_t wait_type, zb_time_t tx_at, zb_uint8_t *tx_buf, + zb_uint8_t current_channel) { + stop_radio_sleep_timer(); + return __real_zb_trans_transmit(wait_type, tx_at, tx_buf, current_channel); +} // NOLINTEND(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) } #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 3b4a465361..cd6deb0e95 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -75,25 +75,31 @@ class ZigbeeComponent final : public Component { this->callbacks_[endpoint - 1] = std::move(cb); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } + template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } void zboss_signal_handler_esphome(zb_bufid_t bufid); void after_reporting_info(zb_zcl_configure_reporting_req_t *config_rep_req, zb_zcl_attr_addr_info_t *attr_addr_info); void factory_reset(); void force_report(); void loop() override; void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void add_radio_sleep_time_ms(uint32_t ms); protected: static void zcl_device_cb(zb_bufid_t bufid); void on_join_(bool factory_new); + void on_start_(); #ifdef USE_ZIGBEE_WIPE_ON_BOOT void erase_flash_(int area); #endif void dump_reporting_(); std::array, ZIGBEE_ENDPOINTS_COUNT> callbacks_{}; CallbackManager join_cb_; + LazyCallbackManager start_cb_; bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + uint32_t radio_sleep_time_{}; + uint32_t radio_sleep_remainder_{}; bool sleepy_{}; }; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 1647fb28ae..f47cf6bd40 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -117,6 +117,14 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") + # Wrap the transceiver sleep/receive/transmit calls to measure how long the + # radio is powered down. The span between a zb_trans_enter_sleep() and the + # following zb_trans_enter_receive() or zb_trans_transmit() is time the + # radio spent asleep. + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_sleep") + cg.add_build_flag("-Wl,--wrap=zb_trans_enter_receive") + cg.add_build_flag("-Wl,--wrap=zb_trans_transmit") + if CONF_IEEE802154_VENDOR_OUI in config: zephyr_add_prj_conf("IEEE802154_VENDOR_OUI_ENABLE", True) random_number = config[CONF_IEEE802154_VENDOR_OUI] diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 5f56861e6d..6e3f109ca1 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -166,7 +166,9 @@ void ZWaveProxy::process_uart_slow_() { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } } while (this->available()); @@ -328,7 +330,9 @@ void ZWaveProxy::send_homeid_changed_msg_(api::APIConnection *conn) { msg.data_len = this->home_id_.size(); if (conn != nullptr) { // Send to specific connection - conn->send_message(msg); + if (!conn->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } } else if (api::global_api_server != nullptr) { // We could add code to manage a second subscription type, but, since this message is // very infrequent and small, we simply send it to all clients @@ -483,7 +487,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->buffer_[0] = byte; this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } diff --git a/esphome/config.py b/esphome/config.py index 976faed447..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1108,6 +1231,7 @@ def validate_config( config: dict[str, Any], command_line_substitutions: dict[str, Any] | None, skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: result = Config() @@ -1218,11 +1342,13 @@ def validate_config( # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the - # user-supplied keys for `esphome config --no-defaults`. - result.user_config = copy.deepcopy(config) - if substitutions is not None: - result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) - result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) + # user-supplied keys for `esphome config --no-defaults`. The deep copy is + # expensive, so it is only taken when that command actually asked for it. + if snapshot_user_config: + result.user_config = copy.deepcopy(config) + if substitutions is not None: + result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions) + result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False) # 2. Load partial core config import esphome.core.config as core_config @@ -1256,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) @@ -1335,7 +1462,9 @@ class InvalidYAMLError(EsphomeError): def _load_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: """Load the configuration file.""" try: @@ -1344,7 +1473,12 @@ def _load_config( raise InvalidYAMLError(e) from e try: - return validate_config(config, command_line_substitutions, skip_external_update) + return validate_config( + config, + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except EsphomeError: raise except Exception: @@ -1353,10 +1487,16 @@ def _load_config( def load_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config: try: - return _load_config(command_line_substitutions, skip_external_update) + return _load_config( + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except vol.Invalid as err: raise EsphomeError(f"Error while parsing config: {err}") from err @@ -1497,11 +1637,17 @@ def strip_default_ids(config): def read_config( - command_line_substitutions: dict[str, Any], skip_external_update: bool = False + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, + snapshot_user_config: bool = False, ) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: - res = load_config(command_line_substitutions, skip_external_update) + res = load_config( + command_line_substitutions, + skip_external_update=skip_external_update, + snapshot_user_config=snapshot_user_config, + ) except EsphomeError as err: _LOGGER.error("Error while reading config: %s", err) return None diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c0a3b99968..c82c2b3dbe 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Collection from esphome.const import ( CONF_LEVEL, @@ -98,6 +98,19 @@ def merge_config(old, new): return new +def frameworks_for_platforms(platforms: Collection[str]) -> set[PlatformFramework]: + """All PlatformFramework members whose platform is in `platforms`. + + For FILTER_SOURCE_FILES maps that must stay in sync with a platform + registry: deriving the framework set here means a platform added to the + registry cannot validate and then fail at link on a filtered-out file. + """ + known = {pf.value[0].value for pf in PlatformFramework} + if unknown := set(platforms) - known: + raise ValueError(f"unknown platform(s): {sorted(unknown)}") + return {pf for pf in PlatformFramework if pf.value[0].value in platforms} + + def filter_source_files_from_platform( files_map: dict[str, set[PlatformFramework]], ) -> Callable[[], list[str]]: diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 713df5452a..0eebf12e66 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress -from dataclasses import dataclass from datetime import datetime from ipaddress import ( AddressValueError, @@ -88,6 +87,7 @@ from esphome.core import ( TimePeriodMinutes, TimePeriodNanoseconds, TimePeriodSeconds, + Version, ) from esphome.enum import StrEnum from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG @@ -99,7 +99,6 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) -from esphome.util import parse_esphome_version from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base @@ -408,42 +407,6 @@ class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" -@dataclass(frozen=True, order=True) -class Version: - major: int - minor: int - patch: int - extra: str = "" - - def __str__(self): - if self.extra: - return f"{self.major}.{self.minor}.{self.patch}-{self.extra}" - return f"{self.major}.{self.minor}.{self.patch}" - - @classmethod - def parse(cls, value: str) -> Version: - # The patch component is optional and defaults to 0, so "6.0" and - # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. - match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) - if match is None: - raise ValueError(f"Not a valid version number {value}") - major = int(match[1]) - minor = int(match[2]) - patch = int(match[3] or 0) - extra = match[4] or "" - return Version(major=major, minor=minor, patch=patch, extra=extra) - - @property - def is_beta(self) -> bool: - """Check if this version is a beta version.""" - return self.extra.startswith("b") - - @property - def is_dev(self) -> bool: - """Check if this version is a development version.""" - return self.extra.startswith("dev") - - def check_not_templatable(value): if isinstance(value, Lambda): raise Invalid("This option is not templatable!") @@ -1815,6 +1778,8 @@ def one_of(*values, **kwargs): - *int* (``bool``, default=False): Whether to convert the incoming values to integers. - *float* (``bool``, default=False): Whether to convert the incoming values to floats. - *space* (``str``, default=' '): What to convert spaces in the input string to. + - *underscore* (``str``, default='_'): What to convert underscores in the input string to. + - *hyphen* (``str``, default='-'): What to convert hyphens in the input string to. """ options = ", ".join(f"'{x}'" for x in values) lower = kwargs.pop("lower", False) @@ -1823,8 +1788,11 @@ def one_of(*values, **kwargs): to_int = kwargs.pop("int", False) to_float = kwargs.pop("float", False) space = kwargs.pop("space", " ") + underscore = kwargs.pop("underscore", "_") + hyphen = kwargs.pop("hyphen", "-") if kwargs: raise ValueError + separators = str.maketrans({" ": space, "_": underscore, "-": hyphen}) @schema_extractor("one_of") def validator(value): @@ -1833,7 +1801,7 @@ def one_of(*values, **kwargs): if string_: value = string(value) - value = value.replace(" ", space) + value = value.translate(separators) if to_int: value = int_(value) if to_float: @@ -2363,13 +2331,13 @@ def _validate_no_slash(value): the visually similar Unicode FRACTION SLASH (U+2044) character. """ if "/" in value: - # Remove before 2026.7.0 + # Remove before 2027.7.0 new_value = value.replace("/", FRACTION_SLASH) _LOGGER.warning( "'%s' contains '/' which is reserved as a URL path separator. " "Automatically replacing with '%s' (Unicode FRACTION SLASH). " "Please update your configuration. " - "This will become an error in ESPHome 2026.7.0.", + "This will become an error in ESPHome 2027.7.0.", value, new_value, ) @@ -2510,11 +2478,16 @@ def git_ref(value): return value +# What `refresh: never` validates to; also used to recognize a disabled +# refresh when logging (see esphome/git.py) +SOURCE_REFRESH_NEVER = "365250d" + + def source_refresh(value: str): if value.lower() == "always": return source_refresh("0s") if value.lower() == "never": - return source_refresh("365250d") + return source_refresh(SOURCE_REFRESH_NEVER) return positive_time_period_seconds(value) @@ -2638,13 +2611,30 @@ def require_framework_version( return validator -def require_esphome_version(year, month, patch): +def require_esphome_version( + year: Version | int, month: int | None = None, patch: int | None = None +): + """Validator requiring at least the given ESPHome version. + + Accepts a single ``Version`` like the sibling + ``require_framework_version``, or the legacy ``(year, month, patch)`` + ints external components already pass. + """ + if isinstance(year, Version): + required = year + elif month is None or patch is None: + raise ValueError( + "require_esphome_version needs a Version or (year, month, patch)" + ) + else: + required = Version(year, month, patch) + def validator(value): - esphome_version = parse_esphome_version() - if esphome_version < (year, month, patch): - requires_version = f"{year}.{month}.{patch}" + # A dev or beta build of the required version still satisfies it, + # matching the old tuple comparison that dropped the suffix. + if Version.parse(ESPHOME_VERSION) < required: raise Invalid( - f"This component requires at least ESPHome version {requires_version}" + f"This component requires at least ESPHome version {required}" ) return value @@ -2718,10 +2708,32 @@ SOURCE_SCHEMA = Any( ) -def rename_key(old_key, new_key): +def rename_key( + old_key, new_key, *, removed_in: str | None = None, component: str | None = None +): + """Rename a config key from ``old_key`` to ``new_key``. + + Specifying both keys is an error; otherwise only one of the two would + survive the rename and the other would be dropped silently. + + When ``removed_in`` is set, a deprecation warning is logged if the old key is + present. Pass ``component`` (the platform/component name) alongside + ``removed_in`` so the warning identifies where it originates. + """ + def validator(config: dict) -> dict: config = config.copy() if old_key in config: + has_at_most_one_key(old_key, new_key)(config) + if removed_in is not None: + prefix = f"[{component}] " if component else "" + _LOGGER.warning( + "%s'%s' is deprecated, use '%s'. Will be removed in %s", + prefix, + old_key, + new_key, + removed_in, + ) config[new_key] = config.pop(old_key) return config diff --git a/esphome/const.py b/esphome/const.py index 6e21b10df8..623d9673bc 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.4" +__version__ = "2026.8.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -121,6 +121,7 @@ PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX +BUNDLE_EXTENSION = ".esphomebundle.tar.gz" SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino"} HEADER_FILE_EXTENSIONS = {".h", ".hpp", ".tcc"} SECRETS_FILES = ("secrets.yaml", "secrets.yml") @@ -347,6 +348,7 @@ CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" +CONF_DISCOVER_IP = "discover_ip" CONF_DISCOVERY = "discovery" CONF_DISCOVERY_OBJECT_ID_GENERATOR = "discovery_object_id_generator" CONF_DISCOVERY_PREFIX = "discovery_prefix" @@ -384,6 +386,7 @@ CONF_ENABLE_PIN = "enable_pin" CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access" CONF_ENABLE_RRM = "enable_rrm" CONF_ENABLE_TIME = "enable_time" +CONF_ENCRYPTION = "encryption" CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" @@ -1248,6 +1251,7 @@ UNIT_KELVIN = "K" UNIT_KILOGRAM = "kg" UNIT_KILOMETER = "km" UNIT_KILOMETER_PER_HOUR = "km/h" +UNIT_KILOPASCAL = "kPa" UNIT_KILOVOLT_AMPS = "kVA" UNIT_KILOVOLT_AMPS_HOURS = "kVAh" UNIT_KILOVOLT_AMPS_REACTIVE = "kvar" @@ -1420,6 +1424,14 @@ KEY_FRAMEWORK_VERSION = "framework_version" KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" +# esp32 storage keys; defined here so the upload/logs fast path +# (storage_json.apply_to_core, espidf.toolchain) can use them without +# importing the esp32 component package. +KEY_ESP32 = "esp32" +# Also used by esp8266 to index its BOARDS metadata dicts, whose +# entries in boards.py spell the literal; do not change the value. +KEY_FLASH_SIZE = "flash_size" +KEY_IDF_VERSION = "idf_version" # Entity categories ENTITY_CATEGORY_NONE = "" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index bf637d4c1f..534b740a5d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1,5 +1,6 @@ from collections import defaultdict from contextlib import contextmanager +from dataclasses import dataclass import logging import math import os @@ -279,19 +280,59 @@ class TimePeriodMinutes(TimePeriod): pass +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + extra: str = "" + + def __str__(self): + if self.extra: + return f"{self.major}.{self.minor}.{self.patch}-{self.extra}" + return f"{self.major}.{self.minor}.{self.patch}" + + @classmethod + def parse(cls, value: str) -> "Version": + # The patch component is optional and defaults to 0, so "6.0" and + # "6.0-rc1" parse as 6.0.0 and 6.0.0-rc1. + match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?[-.]?(\w*)$", value) + if match is None: + raise ValueError(f"Not a valid version number {value}") + major = int(match[1]) + minor = int(match[2]) + patch = int(match[3] or 0) + extra = match[4] or "" + return Version(major=major, minor=minor, patch=patch, extra=extra) + + @property + def is_beta(self) -> bool: + """Check if this version is a beta version.""" + return self.extra.startswith("b") + + @property + def is_dev(self) -> bool: + """Check if this version is a development version.""" + return self.extra.startswith("dev") + + LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): - from esphome.cpp_generator import Expression, statement - # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value - elif isinstance(value, Expression): - self._value = str(statement(value)) + elif isinstance(value, str): + # The validated-config cache revives Lambdas from strings on the + # upload/logs fast path; keep codegen off that path. + self._value = value else: + from esphome.cpp_generator import Expression, statement + + if isinstance(value, Expression): + value = str(statement(value)) self._value = value self._parts = None self._requires_ids = None @@ -621,8 +662,8 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Dict mapping (device_id, platform, sanitized_name) -> entity metadata - self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {} + # Dict mapping (device_id, platform, name_hash) -> entity metadata + self.unique_ids: dict[tuple[str, str, int], EntityMetadata] = {} # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode @@ -844,6 +885,11 @@ class EsphomeCore: return self.relative_build_path("build", "bootloader", "bootloader.bin") return self.relative_pioenvs_path(self.name, "bootloader.bin") + @property + def is_configured(self) -> bool: + """Whether anything has set this CORE up for a target.""" + return KEY_CORE in self.data + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 27c50ebb2a..d9cfad70b9 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -144,7 +144,7 @@ std::vector base64_decode(const std::string &encoded_string) { // --- Hex/binary formatting helpers --- std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac, buf); return std::string(buf); } @@ -206,9 +206,9 @@ std::string format_bin(const uint8_t *data, size_t length) { // --- MAC address helpers --- std::string get_mac_address() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); - char buf[13]; + char buf[MAC_ADDRESS_BUFFER_SIZE]; format_mac_addr_lower_no_sep(mac, buf); return std::string(buf); } diff --git a/esphome/core/application.h b/esphome/core/application.h index 76af514511..a18a6b31c8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ - obj->configure_entity_(name, object_id_hash, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ + obj->configure_entity_(name, entity_key, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ + if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ @@ -612,6 +612,8 @@ class LoopBlockingGuard { uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(blocking_time); + // Exclude synchronous warning-log time from the next operation. + curr_time = MillisInternal::get(); } #endif return curr_time; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 38e52e44cb..276b8aa972 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // Record the owning script (if any) so the blocking warning can name it; propagates across // chained delays via the scheduler. /* source= */ App.get_current_source()); @@ -215,7 +215,7 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + /* skip_cancel= */ this->num_running_ > 1, // See the no-argument branch above: record the owning script for log attribution. /* source= */ App.get_current_source()); } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 281d7aaecd..e5fbb8ba07 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -93,36 +93,6 @@ bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } -void Component::set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(const char *name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, name); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } @@ -156,21 +126,6 @@ void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::fun bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); } -void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function &&f, float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} - -bool Component::cancel_retry(uint32_t id) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_retry(this, id); -#pragma GCC diagnostic pop -} - void Component::call_setup() { this->setup(); } void Component::call_dump_config_() { this->dump_config(); @@ -307,13 +262,6 @@ void Component::set_timeout(uint32_t timeout, std::function &&f) { // N void Component::set_interval(uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, static_cast(nullptr), interval, std::move(f)); } -void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, - float backoff_increase_factor) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); -#pragma GCC diagnostic pop -} bool Component::is_ready() const { // Bitmask check: valid states are SETUP(1), LOOP(2), LOOP_DONE(4) // (1 << state) & 0b10110 checks membership in one instruction diff --git a/esphome/core/component.h b/esphome/core/component.h index 70a051ca0b..ecaf863ecf 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -96,8 +96,6 @@ inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; // 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 }; inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms) @@ -410,41 +408,6 @@ class Component { bool cancel_interval(uint32_t id); // NOLINT bool cancel_interval(InternalSchedulerID id); // NOLINT - /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT - std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT - float backoff_increase_factor = 1.0f); // NOLINT - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const std::string &name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(const char *name); // NOLINT - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. diff --git a/esphome/core/config.py b/esphome/core/config.py index 6b24a55487..1095a4886e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -542,8 +542,10 @@ def _add_library_str(lib: str) -> None: if "@" in lib: name, vers = lib.split("@", 1) cg.add_library(name, vers) - elif "://" in lib: - # Repository... + elif "://" in lib or lib.split("=", 1)[-1].startswith("file:"): + # A repository or URL source. Also catch a ``file:`` source spelled with + # fewer than two slashes (e.g. ``file:lib_dev``) so it reaches the + # file:// handling and its clear error, rather than a registry lookup. if "=" in lib: name, repo = lib.split("=", 1) cg.add_library(name, None, repo) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7cb8dc9002..bb4960aec7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -44,6 +44,7 @@ #define USE_AREAS #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_FILTER +#define USE_BLE_DEVICE_IRK #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE @@ -56,6 +57,7 @@ #define USE_DATETIME_TIME #define USE_DEBUG #define USE_DEEP_SLEEP +#define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY #define USE_ENTITY_DEVICE_CLASS @@ -136,6 +138,8 @@ #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK +#define USE_NETWORK_DEFAULT_ROUTE +#define USE_NETWORK_PRIMARY_INTERFACE_WIFI #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE #define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START @@ -154,8 +158,20 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +// Only defined by key-lookup preference backends; the slot-based platforms +// (esp8266, rp2040) never set it in generated builds, and their preferences +// managers do not provide load_from_key(), so the PreferencesKeyLookupContract +// assert would fail their clang-tidy environments. Written as a deny-list so +// the no-platform analysis configuration (whose Preferences stub provides +// load_from_key()) keeps covering the key-lookup code paths, and so a future +// slot-based platform fails the assert loudly instead of silently losing +// analysis coverage. +#if !defined(USE_ESP8266) && !defined(USE_RP2) +#define USE_PREFERENCE_KEY_LOOKUP +#endif #define USE_PROVISIONING #define USE_QR_CODE +#define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT @@ -205,8 +221,11 @@ #define MAX_API_CONNECTIONS 6 #define USE_MD5 #define USE_SHA256 +#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT #define USE_MQTT_COVER_JSON +#define USE_WIREGUARD +#endif #define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG @@ -219,7 +238,6 @@ #define USE_WIFI #define USE_WIFI_AP #define USE_WIFI_MANUAL_IP -#define USE_WIREGUARD #endif // Arduino-specific feature flags @@ -233,6 +251,33 @@ #define USE_NATIVE_64BIT_TIME #endif +// bluetooth_proxy runs on any platform with a BLE hub (advertisement-only off +// esp32). Declared here per analysis ENVIRONMENT, not per hub platform — +// USE_LIBRETINY also covers chips with no hub, e.g. rtl87xx (the authoritative +// gate is _HUB_PLATFORMS in bluetooth_proxy/__init__.py) — so the neutral +// declarations in bluetooth_proxy.h are parsed under LibreTiny static analysis +// (the header is included by api_connection.cpp, which the tidy filter selects; +// the proxy's own .cpp is not a selected translation unit). Not declared for +// platforms whose API/network types the proxy header cannot assume. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) +#define USE_BLUETOOTH_PROXY +// Mirror the codegen values per platform: _to_code_esp32() emits the connection +// count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits +// the slot count (3 on rp2, 0 on advertisement-only hubs) — so static analysis +// checks the same instantiations a real build produces. +#ifdef USE_ESP32 +#define USE_BLE_SCANNER_STATE_CALLBACK +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS +#elif defined(USE_RP2) +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS +#else +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 +#endif +#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 +#endif + // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER @@ -241,6 +286,13 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_SIGNED_VERIFICATION_MULTI_KEY +// Stub values for tooling; a real build's codegen emits these from verification_keys. +#define OTA_TRUSTED_KEY_COUNT 1 +#define OTA_TRUSTED_KEY_DIGESTS \ + { \ + { 0 } \ + } #define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES @@ -248,9 +300,6 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 -#define USE_BLUETOOTH_PROXY -#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 -#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE @@ -267,8 +316,12 @@ #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT +#define USE_ESP32_BLE_TRACKER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 @@ -347,6 +400,7 @@ #define USE_ETHERNET_W6100 #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 +#define USE_ETHERNET_CH390 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 @@ -419,13 +473,19 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2_BLE_TRACKER +#define RP2040_BLE_SCAN_LISTENER_COUNT 1 +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 3 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET @@ -434,9 +494,28 @@ #ifndef USE_ETHERNET_SPI #define USE_ETHERNET_SPI #endif +#define USE_ETHERNET_W5500 +#define USE_WIFI_IP_STATE_LISTENERS +#define ESPHOME_WIFI_IP_STATE_LISTENERS 2 +#define USE_ETHERNET_IP_STATE_LISTENERS +#define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #endif #ifdef USE_LIBRETINY +#define USE_BK72XX_BLE +#define BK72XX_BLE_SCAN_LISTENER_COUNT 1 +#define USE_LN882H_BLE +#define LN882H_BLE_SCAN_LISTENER_COUNT 1 +// One tracker arm per build: ln882x gets its real hub; bk72xx also stands in +// for hub-less LibreTiny chips (rtl87xx) so bluetooth_proxy.h has a BLEHub +// to parse against. +#ifdef USE_LN882X +#define USE_LN882H_BLE_TRACKER +#else +#define USE_BK72XX_BLE_TRACKER +#endif +#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 32135860bb..328de05302 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui } } this->flags_.has_own_name = false; - // Dynamic name - must calculate hash at runtime - this->calc_object_id_(); + // Dynamic name - must calculate key at runtime + this->calc_entity_key_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed hash if provided - if (object_id_hash != 0) { - this->object_id_hash_ = object_id_hash; + // Static name - use pre-computed key if provided + if (entity_key != 0) { + this->entity_key_ = entity_key; } else { - this->calc_object_id_(); + this->calc_entity_key_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,9 +147,15 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate Object ID Hash directly from name using snake_case + sanitize -void EntityBase::calc_object_id_() { - this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); +// Calculate the entity key directly from the raw name (no transformations) +void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } + +// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. +// Named entities historically used the hash pre-computed by Python code generation, which +// sanitized per UTF-8 code point; entities without their own name computed the hash at +// runtime per byte. See https://github.com/esphome/backlog/issues/85 +uint32_t EntityBase::calc_old_object_id_hash_() const { + return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -166,46 +172,23 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -// Migrate preference data from old_key to new_key if they differ. -// This helper is exposed so callers with custom key computation (like TextPrefs) -// can use it for manual migration. See: https://github.com/esphome/backlog/issues/85 -// -// FUTURE IMPLEMENTATION: -// This will require raw load/save methods on ESPPreferenceObject that take uint8_t* and size. -// void EntityBase::migrate_entity_preference_(size_t size, uint32_t old_key, uint32_t new_key) { -// if (old_key == new_key) -// return; -// auto old_pref = global_preferences->make_preference(size, old_key); -// auto new_pref = global_preferences->make_preference(size, new_key); -// SmallBufferWithHeapFallback<64> buffer(size); -// if (old_pref.load(buffer.data(), size)) { -// new_pref.save(buffer.data(), size); -// } -// } - ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // This helper centralizes preference creation to enable fixing hash collisions. + // The old key hashed the sanitized object_id, so multiple entity names could collide on + // one key and overwrite each other's stored preferences; the new key hashes the raw name. // See: https://github.com/esphome/backlog/issues/85 - // - // COLLISION PROBLEM: get_preference_hash() uses fnv1_hash on sanitized object_id. - // Multiple entity names can sanitize to the same object_id: - // - "Living Room" and "living_room" both become "living_room" - // - UTF-8 names like "温度" and "湿度" both become "__" (underscores) - // This causes entities to overwrite each other's stored preferences. - // - // FUTURE MIGRATION: When implementing get_preference_hash_v2() that hashes - // the original entity name (not sanitized object_id): - // - // uint32_t old_key = this->get_preference_hash() ^ version; - // uint32_t new_key = this->get_preference_hash_v2() ^ version; - // this->migrate_entity_preference_(size, old_key, new_key); - // return global_preferences->make_preference(size, new_key); - // -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash() ^ version; -#pragma GCC diagnostic pop - return global_preferences->make_preference(size, key); + uint32_t old_key = this->old_preference_key_base_() ^ version; +#ifdef USE_PREFERENCE_KEY_LOOKUP + uint32_t new_key = this->preference_key_base_() ^ version; + auto pref = global_preferences->make_preference(size, new_key); + // All in-tree entity preferences fit the stack buffer, so migration never hits the heap + SmallBufferWithHeapFallback<64> buffer(size); + migrate_preference(pref, buffer.get(), size, old_key, new_key); + return pref; +#else + // Slot-based backends keep the old key: it is only a validity tag on a positional slot, + // so collisions cannot corrupt data there and keeping it preserves stored state. + return global_preferences->make_preference(size, old_key); +#endif } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4f708209d4..7f8e5f2630 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,8 +73,17 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique Object ID of this Entity - uint32_t get_object_id_hash() const { return this->object_id_hash_; } + // Get the unique key of this Entity: FNV-1 hash of the raw entity name. + // This is the key sent to API clients and used to route entity state. + uint32_t get_entity_key() const { return this->entity_key_; } + + /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing + /// callers keep getting stable values (for example preference keys). This is no longer + /// the key sent to API clients; that is get_entity_key(). + ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " + "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", + "2026.8.0") + uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -181,40 +190,24 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /** - * @brief Get a unique hash for storing preferences/settings for this entity. - * - * This method returns a hash that uniquely identifies the entity for the purpose of - * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), - * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness - * across multiple devices that may have entities with the same object_id. - * - * Use this method when storing or retrieving preferences/settings that should be unique - * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies - * the entity regardless of the device it belongs to. - * - * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged - * from previous versions, so existing single-device configurations will continue to work. - * - * @return uint32_t The unique hash for preferences, including device_id if available. - * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. - * See https://github.com/esphome/backlog/issues/85 - */ - ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " - "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.7.0") - uint32_t get_preference_hash() { + /// Get this entity's device id, or 0 when devices are not compiled in (main device). + uint32_t get_device_id_or_zero() const { #ifdef USE_DEVICES - // Combine object_id_hash with device_id to ensure uniqueness across devices - // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash - // This ensures backward compatibility for existing single-device configurations - return this->get_object_id_hash() ^ this->get_device_id(); + return this->get_device_id(); #else - // Without devices, just use object_id_hash as before - return this->get_object_id_hash(); + return 0; #endif } + /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. + /// Intentionally keeps the old algorithm so external callers that store preferences under + /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, + /// this method never will. + ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " + "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", + "2026.8.0") + uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) /// @param version Optional version hash XORed with preference key (change when struct layout changes) @@ -230,9 +223,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. + /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -240,13 +233,24 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// When preference hash algorithm changes, migration logic goes here. + /// Migrates preferences from the old sanitized-object_id key to the raw-name key + /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_object_id_(); + void calc_entity_key_(); + + /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. + uint32_t calc_old_object_id_hash_() const; + + /// Preference key base for this entity: raw-name entity key XOR device_id. + uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } + + /// Legacy preference key base: sanitized-object_id hash XOR device_id. + /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. + uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } StringRef name_; - uint32_t object_id_hash_{}; + uint32_t entity_key_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 38c7f3ca43..5060e32a2d 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,19 +25,86 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" +_OBJECT_ID_DOMAIN = "entity_object_ids" + + +@dataclass +class ObjectIdEntity: + """An entity tracked by the sanitized object_id its name resolves to.""" + + name: str + platform: str + config: ConfigType + + +def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: + """(device_id, platform, sanitized object_id) -> entities resolving to it.""" + return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) + + +def validate_no_object_id_conflicts( + reason: str, + conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, +) -> Callable[[ConfigType], ConfigType]: + """Create a final-validate step that rejects entities with colliding object_ids. + + Entity keys are hashed from the raw name, so names that only differ in characters + lost during sanitizing (for example two UTF-8 names) validate fine in general. + Components that still address entities by the sanitized object_id string must + reject those configs until they are migrated to raw names. + + Args: + reason: One sentence stating what the component builds from the object_id, + e.g. "mqtt builds default topics from the entity object_id" + conflict_filter: Optional predicate receiving the colliding entities and the + component config; return False when the component is not affected + + Returns: + A validator function for use as (or within) FINAL_VALIDATE_SCHEMA + """ + + def validator(config: ConfigType) -> ConfigType: + # Skip in testing_mode, which is used for grouped component testing + if CORE.testing_mode: + return config + conflicts = { + key: entities + for key, entities in _get_object_id_registry().items() + if len(entities) > 1 + and (conflict_filter is None or conflict_filter(entities, config)) + } + if not conflicts: + return config + lines = [f"{reason}, so these entities would conflict:"] + lines.extend( + f" - {platform} entities " + + ", ".join(f"'{e.name}'" for e in entities) + + (f" on device '{device_id}'" if device_id else "") + + f" share the object_id '{object_id}'" + for (device_id, platform, object_id), entities in conflicts.items() + ) + lines.append( + "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " + "to distinguish the names" + ) + raise cv.Invalid("\n".join(lines)) + + return validator + + # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" +_KEY_ENTITY_KEY = "_entity_key" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -300,7 +367,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - object_id_hash = config[_KEY_OBJECT_ID_HASH] + entity_key = config[_KEY_ENTITY_KEY] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -320,57 +387,30 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, object_id_hash, packed + var, entity_name, entity_key, packed ) else: - expr = var.configure_entity_(entity_name, object_id_hash, packed) + expr = var.configure_entity_(entity_name, entity_key, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_object_id( +def get_base_entity_name( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Calculate the base object ID for an entity that will be set via set_object_id(). + """Return the base name whose hash becomes this entity's key on the device. - This function calculates what object_id_c_str_ should be set to in C++. + Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): + entity name, then sub-device name, then friendly name, then the device name. - The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - - If !has_own_name && is_name_add_mac_suffix_enabled(): - return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - - Else: - return object_id_c_str_ ?? "" // What we set via set_object_id() - - Since we're calculating what to pass to set_object_id(), we always need to - generate the object_id the same way, regardless of name_add_mac_suffix setting. - - Args: - name: The entity name (empty string if no name) - friendly_name: The friendly name from CORE.friendly_name - device_name: The device name if entity is on a sub-device - - Returns: - The base object ID to use for duplicate checking and to pass to set_object_id() + This is a config-time approximation for duplicate checking: when + name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, + which is unknown here and identical for every entity on the device, so + ignoring it cannot change whether two entities collide with each other. """ - - if name: - # Entity has its own name (has_own_name will be true) - base_str = name - elif device_name: - # Entity has empty name and is on a sub-device - # C++ EntityBase::set_name() uses device->get_name() when device is set - base_str = device_name - elif friendly_name: - # Entity has empty name (has_own_name will be false) - # C++ uses App.get_friendly_name() which returns friendly_name or device name - base_str = friendly_name - else: - # Fallback to device name - base_str = CORE.name - - return sanitize(snake_case(base_str)) + return name or device_name or friendly_name or CORE.name def setup_entity(var_or_platform, config=None, platform=None): @@ -429,15 +469,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and object_id hash for configure_entity_() + # Pre-compute entity name and entity key for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute hash from entity name - # For empty-name entities: pass 0, C++ calculates hash at runtime from - # device name, friendly_name, or app name (bug-for-bug compatibility) + # For named entities: pre-compute the key from the raw entity name + # For empty-name entities: pass 0, C++ calculates the key at runtime from + # device name, friendly_name, or app name entity_name = config[CONF_NAME] - object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 + entity_key = fnv1_hash_name(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_OBJECT_ID_HASH] = object_id_hash + config[_KEY_ENTITY_KEY] = entity_key # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -550,14 +590,14 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Calculate what object_id will actually be used - # This handles empty names correctly by using device/friendly names - name_key = get_base_entity_object_id( - entity_name, CORE.friendly_name, device_name - ) + # Hash the same raw name the device hashes into the entity key at runtime. + # This handles empty names correctly by using device/friendly names. + base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) + name_hash = fnv1_hash_name(base_name) - # Check for duplicates - unique_key = (device_id, platform, name_key) + # Check for duplicates: two entities on the same device and platform must not + # share an entity key, since the key is what routes state to API clients + unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata existing = CORE.unique_ids[unique_key] @@ -581,14 +621,13 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Show both original names and their ASCII-only versions if they differ - sanitized_msg = "" + # Different names can only clash here through a genuine hash collision + collision_msg = "" if entity_name != existing_name: - sanitized_msg = ( - f"\n Original names: '{entity_name}' and '{existing_name}'" - f"\n Both convert to ASCII ID: '{name_key}'" - "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" - "\n to distinguish them" + collision_msg = ( + f"\n The names '{entity_name}' and '{existing_name}' produce the" + f"\n same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" ) # Skip duplicate entity name validation when testing_mode is enabled @@ -598,9 +637,22 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " "Each entity on a device must have a unique name within its platform." - f"{sanitized_msg}" + f"{collision_msg}" ) + # Components that still address entities by the sanitized object_id reject + # colliding names in final validation via validate_no_object_id_conflicts(), + # so track every entity by the object_id its name resolves to. Scoped per + # device and platform to match the strictness configs had before entity keys + # moved to raw names: same-named entities on different sub-devices were + # already accepted then, internal entities were already skipped (above), and + # overlaps between platforms that share an MQTT component type (sensor and + # text_sensor both publish under "sensor") were already possible. + object_id = sanitize(snake_case(base_name)) + _get_object_id_registry().setdefault( + (device_id, platform, object_id), [] + ).append(ObjectIdEntity(base_name, platform, config)) + # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index ee8e81225a..b53b8064a3 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) #include #include @@ -10,9 +10,10 @@ namespace esphome { // Event Pool - On-demand pool of objects to avoid heap fragmentation -// Events are allocated on first use and reused thereafter, growing to peak usage +// Events are allocated on first use and reused thereafter, growing to peak +// usage; warm() pre-creates every entry up front for malloc-free producers // @tparam T The type of objects managed by the pool (must have a release() method) -// @tparam SIZE The maximum number of objects in the pool (1-255, limited by uint8_t) +// @tparam SIZE The maximum number of objects in the pool (1-254, limited by uint8_t and the +1 free-list slot) // // SIZING: When paired with a LockFreeQueue, the pool SIZE should be // Q_SIZE - 1 (the queue's actual capacity, since the ring buffer reserves one slot). @@ -22,6 +23,11 @@ namespace esphome { // - Avoids needing release() on the producer path after a failed push(), // preserving the SPSC contract on the internal free list template class EventPool { + // The free list ring must hold all SIZE objects at once (a fully drained + // pool), and LockFreeQueue reserves one slot — so it is sized SIZE + 1, + // which caps SIZE at 254. + static_assert(SIZE < 255, "EventPool SIZE must be at most 254"); + public: EventPool() : total_created_(0) {} @@ -48,26 +54,8 @@ template class EventPool { T *event = this->free_list_.pop(); if (event != nullptr) return event; - // Need to create a new event - if (this->total_created_ >= SIZE) { - // Pool is at capacity - return nullptr; - } - - // Use internal RAM for better performance - RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); - event = allocator.allocate(1); - - if (event == nullptr) { - // Memory allocation failed - return nullptr; - } - - // Placement new to construct the object - new (event) T(); - this->total_created_++; - return event; + return this->create_(); } // Return an event to the pool for reuse @@ -79,11 +67,52 @@ template class EventPool { } } + // Pre-create every pool entry so allocate() is always a free-list pop + // (for producers that must never malloc, e.g. IRQ-context handlers). + // Call from setup(); on false the heap could not supply every entry and + // the caller should mark_failed() — an incomplete warm puts malloc() + // back on the producer path. Tops the pool up from any quiescent state + // (entries that already exist are counted, not re-created); must not run + // concurrently with allocate()/release(). + bool warm() { + // NOLINTNEXTLINE(clang-analyzer-unix.Malloc) -- ownership transfers to the free list + while (this->total_created_ < SIZE) { + T *event = this->create_(); + if (event == nullptr) + return false; + this->free_list_.push(event); + } + return true; + } + private: - LockFreeQueue free_list_; // Free events ready for reuse - uint8_t total_created_; // Total events created (high water mark, max 255) + // Create and count one new object (shared by allocate() and warm()). + // Returns nullptr at capacity or when the heap is exhausted. + T *create_() { + if (this->total_created_ >= SIZE) { + // Pool is at capacity + return nullptr; + } + // Use internal RAM for better performance + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + T *event = allocator.allocate(1); + if (event == nullptr) { + // Memory allocation failed + return nullptr; + } + // Placement new to construct the object + new (event) T(); + this->total_created_++; + return event; + } + + // SIZE + 1 slots so all SIZE objects fit when the pool is fully drained + // (the ring reserves one slot); otherwise the last release() of a + // completely returned pool would drop, permanently orphaning one object. + LockFreeQueue(SIZE + 1)> free_list_; // Free events ready for reuse + uint8_t total_created_; // Total events created (high water mark, max 254) }; } // namespace esphome -#endif // defined(USE_ESP32) +#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a7b63643a4..8c4442f1b2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -335,6 +335,72 @@ char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } +const char *json_escape_into_buffer(std::span buf, StringRef value, bool short_control_escapes) { + if (buf.empty()) + return ""; + // Reserve one byte for the null terminator. + const size_t limit = buf.size() - 1; + size_t pos = 0; + for (char ch : value) { + auto c = static_cast(ch); + // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping + // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. + char escape = '\0'; + switch (c) { + case '"': + escape = '"'; + break; + case '\\': + escape = '\\'; + break; + case '\n': + escape = 'n'; + break; + case '\r': + escape = 'r'; + break; + case '\t': + escape = 't'; + break; + case '\b': + escape = 'b'; + break; + case '\f': + escape = 'f'; + break; + default: + break; + } + // " and \ are always written as two characters, but the control characters fall through to \u00XX when the + // caller did not ask for the short forms. + if (!short_control_escapes && c < 0x20) + escape = '\0'; + if (escape != '\0') { + if (pos + 2 > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = escape; + } else if (c < 0x20) { + // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so + // the two high hex digits are always zero. + if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = format_hex_char(static_cast(c >> 4)); + buf[pos++] = format_hex_char(static_cast(c & 0x0F)); + } else { + if (pos + 1 > limit) + break; + buf[pos++] = static_cast(c); + } + } + buf[pos] = '\0'; + return buf.data(); +} + // 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) { @@ -742,13 +808,13 @@ void HighFrequencyLoopRequester::stop() { // 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]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } const char *get_mac_address_pretty_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e862d015da..d883ce146e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,6 +184,11 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; + bool empty() const { return this->len_ == 0; } + + // Conversion to std::span for compatibility with span-based APIs + operator std::span() const { return std::span(this->data(), this->len_); } + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. @@ -251,6 +256,11 @@ template class StaticVector { } } + // Converting constructor from a smaller StaticVector of the same element type + template StaticVector(const StaticVector &other) : StaticVector(other.begin(), other.end()) { + static_assert(M <= N, "Source StaticVector cannot be larger than the destination"); + } + // Minimal vector-compatible interface - only what we actually use void push_back(const T &value) { if (count_ < N) { @@ -799,6 +809,19 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; +/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), +/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. +/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 +/// encoded bytes of the name. Used to compute entity keys from raw names. +inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { + uint32_t hash = FNV1_OFFSET_BASIS; + for (size_t i = 0; i < len; i++) { + hash *= FNV1_PRIME; + hash ^= static_cast(str[i]); + } + return hash; +} + /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1003,12 +1026,20 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // 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. -/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. -/// If you modify this function, update the Python version and tests in both places. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { +/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing +/// devices already have stored; see https://github.com/esphome/backlog/issues/85. +/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character +/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, +/// which produced the hash for named entities. The per-byte form (default) matches the old +/// runtime hash for entities without their own name. Do not change either behavior. +/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a +/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; +/// such names skip migration once and fall back to their defaults. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { + if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) + continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); @@ -1268,6 +1299,22 @@ ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +/// Largest number of output bytes a single input byte can expand to when JSON escaped (a \u00XX sequence). +static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; + +/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. +/// +/// Escapes " and \ along with the control characters below 0x20. Bytes >= 0x20 are copied verbatim, so text +/// containing valid UTF-8 survives intact. The result is always null terminated; anything that would not fit is +/// dropped rather than written partially. Returns buf so the call can be used directly as an argument. +/// +/// With short_control_escapes the five control characters JSON gives a short form get it (\n \r \t \b \f) and the +/// rest become \u00XX. Pass false to write every control character as \u00XX, which some consumers expect. +/// +/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for +/// the null terminator. +const char *json_escape_into_buffer(std::span buf, StringRef value, bool short_control_escapes = true); + /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. inline char *int8_to_str(char *buf, int8_t val) { @@ -1625,6 +1672,12 @@ constexpr float celsius_to_fahrenheit(float value) { return value * 1.8f + 32.0f /// Convert degrees Fahrenheit to degrees Celsius. constexpr float fahrenheit_to_celsius(float value) { return (value - 32.0f) / 1.8f; } +enum class TemperatureUnit : uint8_t { + CELSIUS = 0, + FAHRENHEIT = 1, + KELVIN = 2, +}; + ///@} /// @name Utilities diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index 316186ea54..316d9c7928 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -1,5 +1,7 @@ #pragma once +#include "esphome/core/defines.h" + #include #include @@ -14,7 +16,9 @@ * blocking each other. * * This is a Single-Producer Single-Consumer (SPSC) lock-free ring buffer. - * Available on platforms with FreeRTOS support (ESP32, LibreTiny). + * Available on multi-threaded platforms (ESP32, LibreTiny) where another task + * produces or consumes, and on single-threaded platforms (RP2) where the + * producer runs in interrupt context. * * Common use cases: * - BLE events: BLE task produces, main loop consumes @@ -26,6 +30,64 @@ namespace esphome { +namespace lockfree_internal { +#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) || defined(ESPHOME_THREAD_SINGLE) +// Platforms where std::atomic RMW operations are unavailable or unnecessary: +// - ESPHOME_THREAD_MULTI_NO_ATOMICS: cores lacking atomic read-modify-write +// instructions (currently the ARMv5TE BK72xx SoCs — no LDREX/STREX, no +// libatomic; other LibreTiny chips such as LN882x/RTL87xx are ARMv7-M and +// keep std::atomic). +// - ESPHOME_THREAD_SINGLE: every platform on this model (ESP8266, RP2, +// nRF52) runs everything on one core (the chip may have more — RP2 is +// dual-core, but ESPHome and its interrupt producers stay on core 0), so +// the only possible concurrency is same-core interrupt preemption (on RP2 +// the BTstack packet handler runs in the CYW43 async-context low-priority +// IRQ on the core that initialized it, core 0). Using plain accesses here +// also avoids __atomic_* library calls on RP2040 (Cortex-M0+, no +// LDREX/STREX). +// For this queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit +// loads and stores are single instructions on these cores, so torn reads +// cannot occur, and on a single in-order core a compiler barrier supplies all +// the acquire/release ordering the algorithm requires. Each index has exactly +// one writer (head_: consumer, tail_: producer). The dropped counter's +// increment/exchange pair is not atomic here — a concurrent reset can lose +// counts — which is acceptable for a diagnostic drop counter. +#define ESPHOME_LFQ_COMPILER_BARRIER() __asm__ __volatile__("" ::: "memory") +template class PlainAtomic { + public: + PlainAtomic() = default; + constexpr PlainAtomic(T value) : value_(value) {} + T load(std::memory_order order = std::memory_order_seq_cst) const { + T value = value_; + if (order != std::memory_order_relaxed) + ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load + return value; + } + void store(T value, std::memory_order order = std::memory_order_seq_cst) { + if (order != std::memory_order_relaxed) + ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store + value_ = value; + } + T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) { + T value = value_; + value_ = value + amount; + return value; + } + T exchange(T desired, std::memory_order /*order*/ = std::memory_order_seq_cst) { + T value = value_; + value_ = desired; + return value; + } + + private: + volatile T value_{0}; +}; +template using AtomicIndex = PlainAtomic; +#else +template using AtomicIndex = std::atomic; +#endif +} // namespace lockfree_internal + // Base lock-free queue without task notification template class LockFreeQueue { public: @@ -126,13 +188,13 @@ template class LockFreeQueue { protected: T *buffer_[SIZE]{}; // Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset) - std::atomic dropped_count_; // 65535 max - more than enough for drop tracking + lockfree_internal::AtomicIndex dropped_count_; // 65535 max - more than enough for drop tracking // Atomic: written by consumer (pop), read by producer (push) to check if full // Using uint8_t limits queue size to 255 elements but saves memory and ensures // atomic operations are efficient on all platforms - std::atomic head_; + lockfree_internal::AtomicIndex head_; // Atomic: written by producer (push), read by consumer (pop) to check if empty - std::atomic tail_; + lockfree_internal::AtomicIndex tail_; }; #ifdef USE_ESP32 diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 34bf84409d..0622376fca 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/core/defines.h" @@ -22,8 +23,23 @@ #include "esphome/components/zephyr/preference_backend.h" #endif +// Key-lookup preference backends find stored data by key; their platforms add the +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key +// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for +// every make_preference() call and use the key only as a validity tag on that slot; +// migration is not possible there, and key collisions cannot corrupt data. + namespace esphome { +// The PreferenceBackend method surface, asserted on the alias each platform +// header binds. save() persists len bytes; load() fills dest only when the +// stored data exists and matches len. Both report success as their return. +template +concept PreferenceBackendContract = requires(T backend, const uint8_t *src, uint8_t *dest, size_t len) { + { backend.save(src, len) } -> std::same_as; + { backend.load(dest, len) } -> std::same_as; +}; + #if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. @@ -34,28 +50,69 @@ struct PreferenceBackend { #endif using ESPPreferenceBackend = PreferenceBackend; +static_assert(PreferenceBackendContract, + "The platform's preference backend is missing part of the PreferenceBackend surface"); class ESPPreferenceObject { public: ESPPreferenceObject() = default; explicit ESPPreferenceObject(PreferenceBackend *backend) : backend_(backend) {} - template bool save(const T *src) { + template bool save(const T *src) { return this->save(reinterpret_cast(src), sizeof(T)); } + + template bool load(T *dest) { return this->load(reinterpret_cast(dest), sizeof(T)); } + + /// Raw save with explicit length, for callers that only know the size at runtime. + bool save(const uint8_t *src, size_t len) { if (this->backend_ == nullptr) return false; - return this->backend_->save(reinterpret_cast(src), sizeof(T)); + return this->backend_->save(src, len); } - template bool load(T *dest) { + /// Raw load with explicit length, for callers that only know the size at runtime. + bool load(uint8_t *dest, size_t len) { if (this->backend_ == nullptr) return false; - return this->backend_->load(reinterpret_cast(dest), sizeof(T)); + return this->backend_->load(dest, len); } protected: PreferenceBackend *backend_{nullptr}; }; +// The preferences manager method surface, asserted in esphome/core/preferences.h +// on the ESPPreferences alias each platform's preferences.h binds through +// DECLARE_PREFERENCE_ALIASES. Semantics beyond the signatures: +// - make_preference: the two-argument form applies the platform's historic +// default storage; in_flash=false may fall back to flash where the platform +// has no faster storage. +// - sync: commit pending writes to flash, true on success. +// - reset: forget unsaved changes and re-initialize the permanent storage +// (usually followed by a restart), true on success. +// The template forms are what component call sites use; PreferencesMixin +// supplies them, but the derived class's non-template overloads hide them +// unless it also declares `using PreferencesMixin::make_preference;`, so +// the concept pins those too. +template +concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool in_flash) { + { prefs.make_preference(len, type, in_flash) } -> std::same_as; + { prefs.make_preference(len, type) } -> std::same_as; + { prefs.template make_preference(type, in_flash) } -> std::same_as; + { prefs.template make_preference(type) } -> std::same_as; + { prefs.sync() } -> std::same_as; + { prefs.reset() } -> std::same_as; +}; + +// Key-lookup platforms additionally provide load_from_key(), a one-shot read +// of a stored preference by key that migrate_preference() relies on; see the +// key-lookup note at the top of this file. Not part of PreferencesContract, +// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP +// is set. +template +concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { + { prefs.load_from_key(type, data, len) } -> std::same_as; +}; + /// CRTP mixin providing type-safe template make_preference() helpers. /// Platform preferences classes inherit this to avoid duplicating these templates. template class PreferencesMixin { diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp new file mode 100644 index 0000000000..8508647255 --- /dev/null +++ b/esphome/core/preferences.cpp @@ -0,0 +1,25 @@ +#include "esphome/core/preferences.h" +#include "esphome/core/log.h" +#include + +namespace esphome { + +#ifdef USE_PREFERENCE_KEY_LOOKUP +static const char *const TAG = "preferences"; + +bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, + uint32_t new_key) { + if (new_pref.load(scratch, size)) + return true; // Current data present - never overwrite newer data with the old copy + // One-shot read by key: no backend is allocated for the old key, so boots with + // nothing to migrate (for example fresh installs) cost no heap + if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) + return false; // No data stored under the old key, nothing to migrate + if (!new_pref.save(scratch, size)) { + ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); + } + return true; +} +#endif // USE_PREFERENCE_KEY_LOOKUP + +} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 1efce5af51..cfeddebda7 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -23,6 +23,7 @@ struct Preferences : public PreferencesMixin { using PreferencesMixin::make_preference; ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool load_from_key(uint32_t, uint8_t *, size_t) { return false; } /** * Commit pending writes to flash. @@ -43,3 +44,29 @@ using ESPPreferences = Preferences; extern ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome #endif + +namespace esphome { +static_assert(PreferencesContract, + "The platform's preferences manager is missing part of the ESPPreferences surface " + "(esphome/core/preference_backend.h)"); +} // namespace esphome + +#ifdef USE_PREFERENCE_KEY_LOOKUP +namespace esphome { +static_assert(PreferencesKeyLookupContract, + "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " + "load_from_key() (esphome/core/preference_backend.h)"); + +/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys +/// differ and new_pref has no data yet. scratch must hold at least size bytes. +/// Returns true when scratch holds the entity's current data (loaded or just migrated). +/// The old entry is intentionally left in place so a firmware downgrade still finds its data. +/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get +/// valid data for this boot, callers that reload from the preference fall back to their +/// defaults, and the migration simply runs again on the next boot. +/// Only available on key-lookup preference backends; slot-based backends keep their old +/// keys instead. See: https://github.com/esphome/backlog/issues/85 +bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, + uint32_t new_key); +} // namespace esphome +#endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 8449cba5e8..e9c5bf2c04 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -110,35 +110,16 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { return static_cast((static_cast(random_uint32()) * max_offset) >> 32); } -// Check if a retry was already cancelled in items_ or to_add_ -// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated -// Remove before 2026.8.0 along with all retry code -bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - for (auto *container : {&this->items_, &this->to_add_}) { - for (auto *item : *container) { - if (item != nullptr && this->is_item_removed_locked_(item) && - this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true, /* skip_removed= */ false)) { - return true; - } - } - } - return false; -} - // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel, - const LogString *source) { + std::function &&func, bool skip_cancel, const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } return; } @@ -156,23 +137,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type delay = 1; } - // Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check + // Take lock early to protect scheduler_item_pool_head_ access LockGuard guard{this->lock_}; - // For retries, check if there's a cancelled timeout first - before allocating an item. - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - // Skip check for defer (delay=0) - deferred retries bypass the cancellation check - if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) && - type == SchedulerItem::TIMEOUT && - this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { -#ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); -#endif - return; - } - // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. @@ -192,7 +159,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type new (&item->callback) std::function(std::move(func)); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use this->set_item_removed_(item, false); - item->is_retry = is_retry; // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. // Using a pointer lets both paths share the cancel + push_back epilogue. @@ -234,8 +200,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, - /* find_first= */ true); + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); } target->push_back(item); if (target == &this->to_add_) { @@ -301,125 +266,6 @@ bool HOT Scheduler::cancel_interval(const void *self) { SchedulerItem::INTERVAL); } -// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation. -// Remove before 2026.8.0 along with all retry code. -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - -struct RetryArgs { - // Ordered to minimize padding on 32-bit systems - std::function func; - Component *component; - Scheduler *scheduler; - // Union for name storage - only one is used based on name_type - union { - const char *static_name; // For STATIC_STRING - uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID - } name_; - uint32_t current_interval; - float backoff_increase_factor; - Scheduler::NameType name_type; // Discriminator for name_ union - uint8_t retry_countdown; -}; - -void retry_handler(const std::shared_ptr &args) { - RetryResult const retry_result = args->func(--args->retry_countdown); - if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) - return; - // second execution of `func` happens after `initial_wait_time` - // args->name_ is owned by the shared_ptr - // which is captured in the lambda and outlives the SchedulerItem - const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr; - uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0; - args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id, - args->current_interval, [args]() { retry_handler(args); }, - /* is_retry= */ true); - // backoff_increase_factor applied to third & later executions - args->current_interval *= args->backoff_increase_factor; -} - -void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->cancel_retry_(component, name_type, static_name, hash_or_id); - - if (initial_wait_time == SCHEDULER_DONT_RUN) - return; - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - { - SchedulerNameLog name_log; - ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)", - name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts, - backoff_increase_factor); - } -#endif - - if (backoff_increase_factor < 0.0001f) { - ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, - (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); - backoff_increase_factor = 1; - } - - auto args = std::make_shared(); - args->func = std::move(func); - args->component = component; - args->scheduler = this; - args->name_type = name_type; - if (name_type == NameType::STATIC_STRING) { - args->name_.static_name = static_name; - } else { - args->name_.hash_or_id = hash_or_id; - } - args->current_interval = initial_wait_time; - args->backoff_increase_factor = backoff_increase_factor; - args->retry_countdown = max_attempts; - - // First execution of `func` immediately - use set_timer_common_ with is_retry=true - this->set_timer_common_( - component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); }, - /* is_retry= */ true); -} - -void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func), - backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id) { - return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - /* match_retry= */ true); -} -bool HOT Scheduler::cancel_retry(Component *component, const char *name) { - return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0); -} - -void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, - uint8_t max_attempts, std::function func, - float backoff_increase_factor) { - this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time, - max_attempts, std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) { - return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name)); -} - -void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor) { - this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts, - std::move(func), backoff_increase_factor); -} - -bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { - return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id); -} - -#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings - optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which @@ -806,11 +652,11 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { // Common implementation for cancel operations - handles locking bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry) { + SchedulerItem::Type type) { LockGuard guard{this->lock_}; // Public cancel path uses default find_first=false to cancel ALL matches because // DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key. - return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry); + return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } // Helper to cancel matching items - must be called with lock held. @@ -822,11 +668,10 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, - bool find_first) { + SchedulerItem::Type type, bool find_first) { size_t count = 0; for (auto *item : container) { - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type)) { this->set_item_removed_(item, true); if (find_first) return 1; @@ -837,8 +682,7 @@ size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vectormark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); if (find_first && total_cancelled > 0) return true; } @@ -863,7 +707,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Only the main loop in call() should recycle items after execution completes. { 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); + hash_or_id, type, find_first); total_cancelled += heap_cancelled; this->to_remove_add_locked_(heap_cancelled); if (find_first && total_cancelled > 0) @@ -872,7 +716,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // Cancel items in to_add_ total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name, - hash_or_id, type, match_retry, find_first); + hash_or_id, type, find_first); return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c7743e5b2a..8ef3499a11 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -16,14 +16,8 @@ namespace esphome { class Component; -struct RetryArgs; - -// Forward declaration of retry_handler - needs to be non-static for friend declaration -void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members for internal retry mechanism - friend void ::esphome::retry_handler(const std::shared_ptr &args); // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays @@ -79,32 +73,6 @@ class Scheduler { SchedulerItem::INTERVAL); } - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - // Remove before 2026.8.0 - ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", - "2026.2.0") - void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, - std::function func, float backoff_increase_factor = 1.0f); - - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const std::string &name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, const char *name); - // Remove before 2026.8.0 - ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") - bool cancel_retry(Component *component, uint32_t id); - /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t millis_64() { return esphome::millis_64(); } @@ -202,19 +170,17 @@ class Scheduler { // std::atomic inlines correctly on all platforms. std::atomic remove{0}; - // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 3 bits padding + // 4 bits padding #else // Single-threaded or multi-threaded without atomics: can pack all fields together - // Bit-packed fields (6 bits used, 2 bits padding in 1 byte) + // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; NameType name_type_ : 3; // Discriminator for name_ union (0–4, see NameType enum) - bool is_retry : 1; // True if this is a retry timeout - // 2 bits padding + // 3 bits padding #endif // Constructor @@ -226,13 +192,11 @@ class Scheduler { #ifdef ESPHOME_THREAD_MULTI_ATOMICS // remove is initialized in the member declaration type(TIMEOUT), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #else type(TIMEOUT), remove(false), - name_type_(NameType::STATIC_STRING), - is_retry(false) { + name_type_(NameType::STATIC_STRING) { #endif name_.static_name = nullptr; } @@ -306,19 +270,8 @@ class Scheduler { // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false, const LogString *source = nullptr); - - // Common implementation for retry - Remove before 2026.8.0 - // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - uint32_t initial_wait_time, uint8_t max_attempts, std::function func, - float backoff_increase_factor); -#pragma GCC diagnostic pop - // Common implementation for cancel_retry - bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); + uint32_t hash_or_id, uint32_t delay, std::function &&func, bool skip_cancel = false, + const LogString *source = nullptr); // 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 (ESP32, Host, Zephyr, RP2040 — see @@ -374,11 +327,11 @@ class Scheduler { // mode where skip_cancel=true allows multiple items with the same key). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id bool cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false, bool find_first = false); + SchedulerItem::Type type, bool find_first = false); // Common implementation for cancel operations - handles locking bool cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry = false); + SchedulerItem::Type type); // Helper to check if two static string names match inline bool HOT names_match_static_(const char *name1, const char *name2) const { @@ -394,7 +347,7 @@ class Scheduler { // IMPORTANT: Must be called with scheduler lock held inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, - bool match_retry, bool skip_removed = true) const { + bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded // platforms, items can be nulled in defer_queue_ during processing. // Fixes: https://github.com/esphome/esphome/issues/11940 @@ -403,7 +356,7 @@ class Scheduler { // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they // match by the `this` key alone. if (item->get_component() != component || item->type != type || - (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { + (skip_removed && this->is_item_removed_locked_(item))) { return false; } // Name type must match @@ -448,13 +401,6 @@ class Scheduler { // IMPORTANT: Must not be inlined - called only for intervals, keeping it out of the hot path saves flash. uint32_t __attribute__((noinline)) calculate_interval_offset_(uint32_t delay); - // Helper to check if a retry was already cancelled - extracted to reduce code size of set_timer_common_ - // Remove before 2026.8.0 along with all retry code. - // IMPORTANT: Must not be inlined - retry path is cold and deprecated. - // IMPORTANT: Caller must hold the scheduler lock before calling this function. - bool __attribute__((noinline)) - is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id); - #ifdef ESPHOME_DEBUG_SCHEDULER // Helper for debug logging in set_timer_common_ - extracted to reduce code size void debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, @@ -556,19 +502,21 @@ class Scheduler { // Inlined: the fast path (empty container) avoids calling the out-of-line scan. inline size_t HOT mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + uint32_t hash_or_id, SchedulerItem::Type type, bool find_first = false) { if (container.empty()) return 0; return this->mark_matching_items_removed_slow_locked_(container, component, name_type, static_name, hash_or_id, - type, match_retry, find_first); + type, find_first); } // Out-of-line slow path for mark_matching_items_removed_locked_ when container is non-empty. // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_( - std::vector &container, Component *component, NameType name_type, const char *static_name, - uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first); + __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, + uint32_t hash_or_id, + SchedulerItem::Type type, bool find_first); Mutex lock_; std::vector items_; diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 34ba2474b2..33459f48af 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -76,6 +76,13 @@ class StringRef { constexpr bool empty() const { return len_ == 0; } constexpr const_reference operator[](size_type pos) const { return *(base_ + pos); } + /// True if the view begins with the given prefix (std::string::starts_with-like) + bool starts_with(const StringRef &prefix) const { + return len_ >= prefix.len_ && std::memcmp(base_, prefix.base_, prefix.len_) == 0; + } + bool starts_with(const char *prefix) const { return this->starts_with(StringRef(prefix)); } + bool starts_with(const std::string &prefix) const { return this->starts_with(StringRef(prefix)); } + /// Copy characters to destination buffer (std::string::copy-like, but returns 0 instead of throwing on out-of-range) size_type copy(char *dest, size_type count, size_type pos = 0) const { if (pos >= len_) diff --git a/esphome/core/wake/wake_rp2.cpp b/esphome/core/wake/wake_rp2.cpp index 101c87c818..ac1deba726 100644 --- a/esphome/core/wake/wake_rp2.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -20,7 +20,7 @@ volatile bool g_main_loop_woke = false; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static volatile bool s_delay_expired = false; -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { +static int64_t alarm_callback(alarm_id_t id, void *user_data) { (void) id; (void) user_data; s_delay_expired = true; @@ -43,7 +43,7 @@ void wakeable_delay(uint32_t ms) { return; } s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback, nullptr, true); if (alarm <= 0) { delay(ms); return; diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b035e28a7a..53b59cb124 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass, field import logging @@ -136,6 +137,67 @@ async def _generate_component_source_table() -> None: ) +_SLOT_COUNTER_DOMAIN = "slot_counter" + + +@dataclass +class _SlotCounterState: + """Per-run slot counter state: requested counts and already-emitted defines.""" + + counts: dict[str, int] = field(default_factory=dict) + emitted: set[str] = field(default_factory=set) + + +def _get_slot_counter_state() -> _SlotCounterState: + """Get or create the slot counter state from CORE.data.""" + if _SLOT_COUNTER_DOMAIN not in CORE.data: + CORE.data[_SLOT_COUNTER_DOMAIN] = _SlotCounterState() + return CORE.data[_SLOT_COUNTER_DOMAIN] + + +def get_slot_count(define: str) -> int: + """Number of slots requested so far for `define`.""" + return _get_slot_counter_state().counts.get(define, 0) + + +def slot_counter(define: str) -> Callable[[], None]: + """Create a request_slot function for codegen-sized storage. + + The pattern behind a StaticVector listener array: a consumer's to_code + calls the returned function once per slot it will occupy at runtime, and + at FINAL priority — after every consumer's to_code has run — `define` is + emitted with the requested count. No requests, no define: the guarded + storage and its registration method compile out entirely. + + The counts live in a table under CORE.data, which clears between runs. + A request arriving after the define was already emitted raises instead of + silently undercounting: the define would keep the stale smaller value and + StaticVector::push_back would drop the extra listener at runtime. + """ + + @coroutine_with_priority(CoroPriority.FINAL) + async def emit_job() -> None: + state = _get_slot_counter_state() + state.emitted.add(define) + # Scheduled only by the first request, so the count is always >= 1 here. + add_define(define, state.counts[define]) + + def request_slot() -> None: + state = _get_slot_counter_state() + if define in state.emitted: + raise ValueError( + f"slot_counter('{define}'): slot requested after the count " + f"define was emitted; request slots from to_code, not from a " + f"job running after FINAL emission" + ) + counts = state.counts + counts[define] = (count := counts.get(define, 0) + 1) + if count == 1: + CORE.add_job(emit_job) + + return request_slot + + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. @@ -151,6 +213,17 @@ async def gpio_pin_expression(conf): return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf) +def set_setup_priority(var, priority: float) -> None: + """Emit a setup-priority override for the given component. + + Pairs the ``set_setup_priority()`` call with the ``USE_SETUP_PRIORITY_OVERRIDE`` + define that compiles in the core override support, so callers cannot emit one + without the other. + """ + add_define("USE_SETUP_PRIORITY_OVERRIDE") + add(var.set_setup_priority(priority)) + + async def register_component(var, config): """Register the given obj as a component. @@ -168,8 +241,7 @@ async def register_component(var, config): ) CORE.component_ids.remove(id_) if CONF_SETUP_PRIORITY in config: - add_define("USE_SETUP_PRIORITY_OVERRIDE") - add(var.set_setup_priority(config[CONF_SETUP_PRIORITY])) + set_setup_priority(var, config[CONF_SETUP_PRIORITY]) if CONF_UPDATE_INTERVAL in config: add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/esphome/espidf/__init__.py b/esphome/espidf/__init__.py index e69de29bb2..079eede1f1 100644 --- a/esphome/espidf/__init__.py +++ b/esphome/espidf/__init__.py @@ -0,0 +1,11 @@ +"""ESP-IDF direct build support. + +Deliberately light: the upload fast path imports submodules of this +package without the esp32 component package, so nothing here may pull +in codegen or validation. +""" + + +def variant_to_idf_target(variant: str) -> str: + """Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name.""" + return variant.lower().replace("-", "") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 88ecda60b9..c91db775a3 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -141,10 +141,15 @@ idf_component_register( def _setup_core(work_dir: Path, settings: _Settings) -> None: """Point CORE at the tidy project + IDF version, without any YAML config.""" - from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT - import esphome.config_validation as cv - from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM - from esphome.core import CORE + from esphome.const import ( + KEY_CORE, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + from esphome.core import CORE, Version CORE.name = TIDY_PROJECT_NAME # config_path's parent is the data dir root for per-run artifacts (idedata, @@ -153,7 +158,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) - esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version) + esp32[KEY_IDF_VERSION] = Version.parse(settings.idf_version) esp32[KEY_VARIANT] = settings.variant # The target framework drives the PlatformIO-library -> IDF-component # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers. diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 09213b14e3..aa6f10c261 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -12,6 +12,7 @@ import os from pathlib import Path from esphome.core import CORE, Library +from esphome.espidf import variant_to_idf_target from esphome.helpers import write_file_if_changed from esphome.platformio.library import ( DEFAULT_BUILD_FLAGS, @@ -46,20 +47,21 @@ def _apply_extra_script(component: IDFComponent) -> None: extra_script = component.data.get("build", {}).get("extraScript") if not extra_script: return - # Resolve and confine to the component dir so a malicious library.json - # can't escape (e.g. ``"extraScript": "../../etc/passwd"``). - library_root = component.path.resolve() - script_path = (component.path / extra_script).resolve() + # Resolve and confine to the library's source dir so a malicious + # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). + source_path = component.source_dir + library_root = source_path.resolve() + script_path = (source_path / extra_script).resolve() if not script_path.is_relative_to(library_root) or not script_path.is_file(): return from esphome.components.esp32 import get_esp32_variant from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script - idf_target = get_esp32_variant().lower().replace("-", "") + idf_target = variant_to_idf_target(get_esp32_variant()) result = run_extra_script( - script_path, library_dir=component.path, idf_target=idf_target + script_path, library_dir=source_path, idf_target=idf_target ) - extra_flags = captured_as_build_flags(result, library_dir=component.path) + extra_flags = captured_as_build_flags(result, library_dir=source_path) if not extra_flags: return flags = component.data.setdefault("build", {}).setdefault("flags", []) @@ -100,11 +102,17 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # which Windows accepts too, so the generated CMakeLists is portable. return f'"{str(p).replace(os.sep, "/")}"' + # The library's own files live in source_path (the user's directory for a + # local library, the downloaded dir otherwise). When it differs from the + # component dir the CMakeLists must reference sources by absolute path. + read_path = component.source_dir + external = read_path.resolve() != component.path.resolve() + # Extract the values build_src_dir = component.data.get("build", {}).get("srcDir", None) if not build_src_dir: for d in ["src", "Src", "."]: - if (component.path / Path(d)).is_dir(): + if (read_path / Path(d)).is_dir(): build_src_dir = d break @@ -137,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: # List all sources files build_src_files = collect_filtered_files( - component.path / Path(build_src_dir), build_src_filter + read_path / Path(build_src_dir), build_src_filter ) # Only bake library.json-declared deps here. Project-managed and @@ -149,8 +157,12 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: dependency.get_require_name() for dependency in component.dependencies } - # Only keep sources - build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] + # Only keep sources. Reference them absolutely when they live outside the + # component dir (a local library), relative otherwise. + if external: + build_src_files = [str(Path(p).resolve()) for p in build_src_files] + else: + build_src_files = [os.path.relpath(p, component.path) for p in build_src_files] build_src_files = [ f for f in build_src_files if Path(f).suffix in SRC_FILE_EXTENSIONS ] @@ -165,13 +177,24 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) + # A local library's relative -L paths are relative to its own directory; + # resolve them against it so they still work from the component cache dir. + # (read_path / d yields d unchanged when d is already absolute.) + if external: + link_directories = [ + str((read_path / Path(d)).resolve()) for d in link_directories + ] # Split include directories from build_flags # Only keep an include directory if it exists build_include_dirs = [build_include_dir, build_src_dir] + include_dir_flags build_include_dirs = [ - d for d in build_include_dirs if (component.path / Path(d)).is_dir() + d for d in build_include_dirs if (read_path / Path(d)).is_dir() ] + if external: + build_include_dirs = [ + str((read_path / Path(d)).resolve()) for d in build_include_dirs + ] # Split build_flags list into private and public lists private_build_flags, public_build_flags = split_list_by_condition( diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 4d06fb842a..487fef7cc1 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -107,7 +107,7 @@ def run_extra_script( script shouldn't block the build. """ env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") - code = compile(script_path.read_text(), str(script_path), "exec") + code = compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec") old_cwd = Path.cwd() try: os.chdir(library_dir) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index cfbae9ea46..0f6ef873b8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,12 +9,11 @@ from pathlib import Path import platform import re import shutil -from typing import NoReturn +from typing import Any, NoReturn import platformdirs -from esphome.config_validation import Version -from esphome.core import CORE +from esphome.core import CORE, Version from esphome.framework_helpers import ( PathType, archive_extract_all, @@ -49,6 +48,10 @@ STAMP_SCHEMA_VERSION = "0" ESPHOME_IDF_DEFAULT_TARGETS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS", "all") ) +# An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides the per-variant +# targets a caller requests, so a builder image can still pre-warm every +# target with one env var. +_IDF_DEFAULT_TARGETS_EXPLICIT = bool(os.environ.get("ESPHOME_IDF_DEFAULT_TARGETS")) ESPHOME_IDF_DEFAULT_TOOLS = str_to_lst_of_str( os.environ.get("ESPHOME_IDF_DEFAULT_TOOLS", "cmake;ninja") @@ -199,7 +202,35 @@ def _get_python_env_path(version: str) -> Path: return get_idf_tools_path() / "penvs" / f"{version}" -def _check_stamp(file: PathType, data: dict[str, str]) -> bool: +def _read_stamp(file: PathType) -> dict | None: + """Return a stamp file's dict contents, or None if missing or invalid. + + A missing stamp is the normal first-install case and stays silent; the + other branches indicate a real fault that forces a full reinstall on + every build, so they warn. + """ + try: + with Path(file).open(encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except json.JSONDecodeError as e: + _LOGGER.warning("Ignoring corrupt stamp file %s: %s", file, e) + return None + except OSError as e: + _LOGGER.warning("Could not read stamp file %s: %s", file, e) + return None + if not isinstance(data, dict): + _LOGGER.warning( + "Ignoring stamp file %s with unexpected type %s", + file, + type(data).__name__, + ) + return None + return data + + +def _check_stamp(file: PathType, data: dict[str, Any]) -> bool: """ Check if a stamp file contains the expected data. @@ -210,17 +241,43 @@ def _check_stamp(file: PathType, data: dict[str, str]) -> bool: Returns: True if file exists and contains expected data, False otherwise """ - if not Path(file).is_file(): + return _read_stamp(file) == data + + +def _stamps_match_except_targets(stored: dict, requested: dict) -> bool: + """Whether two stamps agree on every field other than ``targets``. + + Compares whole dicts (minus ``targets``) rather than named keys so any + stamp field added later participates in invalidation by default instead + of being silently ignored. + """ + + def _strip(stamp: dict) -> dict: + return {k: v for k, v in stamp.items() if k != "targets"} + + return _strip(stored) == _strip(requested) + + +def _stamp_covers(stored: dict | None, requested: dict) -> bool: + """Return True if a stored framework stamp already covers this request. + + Every field except ``targets`` must match exactly. ``targets`` may be a + superset of the requested ones: ``idf_tools.py install`` accumulates + targets in idf-env.json across runs, so a framework installed for more + targets than this build needs is still valid. A stored ``all`` covers + every target. + """ + if stored is None: return False - - try: - with Path(file).open(encoding="utf-8") as f: - return json.load(f) == data - except (json.JSONDecodeError, OSError): + if not _stamps_match_except_targets(stored, requested): return False + stored_targets = stored.get("targets") + if not isinstance(stored_targets, list): + return False + return "all" in stored_targets or set(requested["targets"]) <= set(stored_targets) -def _write_stamp(file: PathType, data: dict[str, str]): +def _write_stamp(file: PathType, data: dict[str, Any]): """ Write data to a stamp file in JSON format. @@ -401,11 +458,16 @@ def _clone_idf_with_submodules( key = f"{git_url}@{ref}" if ref else git_url _LOGGER.info("Cloning ESP-IDF from %s", key) - run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + run_git_command( + ["git", "clone", "--depth=1", "--", git_url, str(framework_path)], + network=True, + retry_cleanup=framework_path, + ) if ref: run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=framework_path, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], @@ -541,40 +603,90 @@ def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None: ) -def _patch_tools_json_demote_openocd(framework_path: Path) -> None: - """Demote openocd-esp32 from ``install: always`` to ``install: on_request``. +# Tools marked ``install: always`` in tools.json that no ESPHome build ever +# runs. openocd-esp32 is a JTAG debug server (its post-install check also +# fails outright on systems without libusb-1.0, #17685). The gdb bundles are +# debuggers used only by ``idf.py gdb``/``idf.py monitor`` flows ESPHome never +# invokes; stack decoding uses addr2line from the compiler toolchains instead. +# esp32ulp-elf is the ULP coprocessor toolchain, and ESPHome excludes the IDF +# ``ulp`` component from every build. esp-rom-elfs stays required: the cmake +# gdbinit generation reads ESP_ROM_ELF_DIR during every configure and warns +# when it is missing. +_UNUSED_IDF_TOOLS: tuple[str, ...] = ( + "esp32ulp-elf", + "openocd-esp32", + "riscv32-esp-elf-gdb", + "xtensa-esp-elf-gdb", +) - ``idf_tools.py install required`` installs every tool marked ``always`` in - tools.json and validates each one after extraction by running its version - command. openocd links against libusb-1.0, which minimal systems (bare LXC - containers, slim images) often lack, so that one validation aborted the - whole framework install and left it permanently retrying (#17685) — even - though ESPHome never runs openocd (it is a JTAG debugging tool). Demoting - it drops it from the ``required`` set: it is no longer downloaded or - validated, and the tool-path export treats a missing ``on_request`` tool - as fine. A user who wants it can still name ``openocd-esp32`` explicitly - in ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type - filtering. +# tools.json also lists riscv32-esp-elf as supported on the xtensa chips +# because the S2/S3 ULP coprocessor is a RISC-V core, so installing for an +# S2/S3 target pulls in the whole riscv compiler (~290MB download, 2GB disk) +# just for ULP programs — which ESPHome never builds (the IDF ``ulp`` +# component is excluded by default; a user who re-enables it via +# ``include_builtin_idf_components: [ulp]`` on an S2/S3 and hits a missing +# riscv compiler can set ESPHOME_IDF_DEFAULT_TARGETS=all to install it). +# Removing the xtensa chips from its supported targets keeps it out of +# xtensa-only installs; building a RISC-V variant still installs it. Add any +# future Xtensa chip here; a missing entry only costs the download, while a +# wrongly listed RISC-V chip would strip its own compiler. +_XTENSA_TARGETS: tuple[str, ...] = ("esp32", "esp32s2", "esp32s3") - Because this runs on every install check, an install stuck in the - failing state (which never wrote its stamp file) heals on the next - build without a clean. + +def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None: + """Demote tools ESPHome never runs from ``install: always`` to ``on_request``. + + ``idf_tools.py install required`` downloads every tool marked ``always`` + in tools.json and validates each one after extraction by running its + version command. Demoting the tools in ``_UNUSED_IDF_TOOLS`` drops them + from the ``required`` set: they are no longer downloaded or validated, + and the tool-path export treats a missing ``on_request`` tool as fine. + Besides the download and disk savings, this makes the openocd libusb + validation failure (#17685) impossible; because this runs on every + install check, an install stuck in that failing state (which never wrote + its stamp file) heals on the next build without a clean. A user who + wants one of these tools can still name it explicitly in + ESPHOME_IDF_DEFAULT_TOOLS; explicit names bypass install-type filtering. + + Also removes the xtensa chips from riscv32-esp-elf's supported targets + (see ``_XTENSA_TARGETS``) so xtensa-only installs don't pull in the + RISC-V compiler for ULP programs ESPHome never builds. """ def apply_patch(data: dict) -> bool: changed = False for tool in data.get("tools", []): - if tool.get("name") == "openocd-esp32" and tool.get("install") == "always": + if ( + tool.get("name") in _UNUSED_IDF_TOOLS + and tool.get("install") == "always" + ): tool["install"] = "on_request" changed = True + if tool.get("name") == "riscv32-esp-elf": + targets = tool.get("supported_targets") + # Guard the type so unexpected JSON here cannot abort the + # other demotions; this patch is best-effort. Log it so a + # silently resumed riscv download is diagnosable. + if not isinstance(targets, list): + _LOGGER.warning( + "Unexpected supported_targets for riscv32-esp-elf " + "in tools.json (%s); not excluding it from xtensa " + "installs", + type(targets).__name__, + ) + continue + if any(t in targets for t in _XTENSA_TARGETS): + tool["supported_targets"] = [ + t for t in targets if t not in _XTENSA_TARGETS + ] + changed = True return changed _patch_tools_json( framework_path, apply_patch, - "Patched %s to make openocd-esp32 optional (not needed for " - "building, and its install check fails on systems without " - "libusb-1.0).", + "Patched %s to skip installing tools ESPHome does not use " + "(openocd, gdb, ULP toolchains).", ) @@ -670,7 +782,9 @@ def _check_esphome_idf_framework_install( the URL. Returns: - tuple of (framework_path, install_flag) + tuple of (framework_path, fresh_extract_flag). The flag is True only + when the framework tree was downloaded and extracted this run, not + when tools were installed into an existing tree. """ # Sanitize inputs @@ -703,8 +817,8 @@ def _check_esphome_idf_framework_install( # avoids post-extraction renames that race with antivirus on Windows. # Tool install state is tracked separately by the stamp file in step 3, # so we only re-extract when extraction itself is missing or incomplete. - install = force or not extracted_marker.is_file() - if install: + fresh_extract = force or not extracted_marker.is_file() + if fresh_extract: rmdir(framework_path, msg=f"Clean up ESP-IDF {version} framework") git_source = _parse_git_source(source_url) if source_url else None @@ -769,15 +883,17 @@ def _check_esphome_idf_framework_install( # a pre-patch tools.json get fixed up without forcing a clean. _patch_tools_json_for_linux_arm64(framework_path) - # Drop openocd-esp32 from the required tool set on every invocation so - # an install that previously failed on its libusb check recovers on the - # next build. - _patch_tools_json_demote_openocd(framework_path) + # Drop tools ESPHome never runs from the required tool set on every + # invocation, so an install that previously failed on the openocd libusb + # check recovers on the next build. + _patch_tools_json_demote_unused_tools(framework_path) # 3. Check if the framework tools are the same and correctly installed + stored_stamp = None if fresh_extract else _read_stamp(env_stamp_file) + install = fresh_extract if not install: install = True - if _check_stamp(env_stamp_file, stamp_info): + if _stamp_covers(stored_stamp, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) # Validate via the managed tool-path resolution, not ``idf_tools.py check``: # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a @@ -818,9 +934,35 @@ def _check_esphome_idf_framework_install( ) raise RuntimeError(f"ESP-IDF {version} framework installation failure") + # idf_tools.py extracts tool archives from /dist into tools/; the + # archives are not needed afterward and, already compressed, dominate the cached install. + # Best-effort: a failure to prune must not fail an otherwise successful install. + try: + rmdir( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + except RuntimeError as err: + _LOGGER.debug("Could not remove ESP-IDF tool download cache: %s", err) + + # Record the union of every target installed so far, not just this + # build's. idf_tools.py accumulates targets in idf-env.json and the + # ``required`` metapackage installs tools for all of them, so the + # union is what is actually on disk — and it keeps two variants + # alternating between builds from re-running the installer each time. + # Merge only when everything except targets matches: a reinstall + # triggered by a schema or tools change ran the installer for this + # build's targets alone, so carrying the old targets forward would + # let later builds of those variants skip the reinstall they need. + if ( + stored_stamp + and isinstance(stored_stamp.get("targets"), list) + and _stamps_match_except_targets(stored_stamp, stamp_info) + ): + merged = set(stamp_info["targets"]) | set(stored_stamp["targets"]) + stamp_info["targets"] = ["all"] if "all" in merged else sorted(merged) _write_stamp(env_stamp_file, stamp_info) - return framework_path, install + return framework_path, fresh_extract def _check_esp_idf_python_env_install( @@ -966,7 +1108,11 @@ def check_esp_idf_install( env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" - targets = targets or ESPHOME_IDF_DEFAULT_TARGETS + # An explicit ESPHOME_IDF_DEFAULT_TARGETS wins over the caller's + # per-variant request (builder-image pre-warm); otherwise the caller's + # targets are used, falling back to the default when none were given. + if _IDF_DEFAULT_TARGETS_EXPLICIT or not targets: + targets = ESPHOME_IDF_DEFAULT_TARGETS # Determine which tools need to be installed if not provided if tools is None: @@ -979,15 +1125,18 @@ def check_esp_idf_install( tools.append(tool) # 1) Framework - framework_path, installed = _check_esphome_idf_framework_install( + framework_path, fresh_extract = _check_esphome_idf_framework_install( version, targets, tools, force=force, env=env, source_url=source_url ) features = features or ESPHOME_IDF_DEFAULT_FEATURES - # 2) Python env - python_env_path, installed = _check_esp_idf_python_env_install( - version, features, force=force or installed, env=env + # 2) Python env. Only a freshly extracted framework forces a rebuild — + # the venv depends on the framework version and features, not on which + # toolchains are installed, so adding a target to an existing tree must + # not wipe it. It still self-validates against its own stamp. + python_env_path, _ = _check_esp_idf_python_env_install( + version, features, force=force or fresh_extract, env=env ) return framework_path, python_env_path diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 0ed357a759..0047d568e2 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -6,7 +6,7 @@ toolchain has no such command, but its CMake build emits turns that file into the same fields consumers (IDE integration, clang-tidy) expect: - {cxx_path, cxx_flags, defines, includes: {build, toolchain}} + {cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}} """ from __future__ import annotations @@ -197,6 +197,28 @@ def _get_toolchain_includes(cxx_path: str) -> list[str]: return includes +def _cc_path_from_cxx(cxx_path: str) -> str: + """Derive the C compiler path from the C++ compiler path. + + compile_commands.json only names the C++ compiler, but consumers reach the + rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of + ``cc_path``, so they need the ``gcc``-suffixed name. + """ + stem, suffix = ( + (cxx_path[: -len(".exe")], ".exe") + if cxx_path.endswith(".exe") + else (cxx_path, "") + ) + # Rewrite the program name only when it is g++ itself, or a toolchain + # prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc. + # Requiring a separator before the "g++" keeps names that merely end in + # those three characters intact: "clang++" must not become "clangcc". + head = stem[: -len("g++")] + if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))): + stem = f"{head}gcc" + return f"{stem}{suffix}" + + def idedata_from_build(compile_commands: Path) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. @@ -218,6 +240,7 @@ def idedata_from_build(compile_commands: Path) -> dict: build_includes.setdefault(inc, None) return { + "cc_path": _cc_path_from_cxx(cxx_path), "cxx_path": cxx_path, "cxx_flags": cxx_flags, "defines": defines, diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7c568db7be..7ed11d7554 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -90,6 +90,7 @@ def main() -> int: sys.path.pop(0) # ---- end sys.path fix-up ----------------------------------------------- + import contextlib import os from pathlib import Path import re @@ -143,12 +144,14 @@ def main() -> int: * ``isatty()`` unconditionally returns True, tricking downstream code into emitting TTY-format output. - * Input is split on ``\\n`` / ``\\r`` via - ``str.splitlines(keepends=True)`` and any complete line whose + * Input is split with ``str.splitlines(keepends=True)``, which + breaks on more than ``\\n`` and ``\\r``; form feed and a few + other control characters count too. Any piece whose ANSI-stripped, right-stripped form matches one of ``filter_lines`` is dropped. - * Incomplete trailing chunks are held in a buffer until a - terminator arrives. + * Only the final piece can still be waiting for more text, so + that one is held until a ``\\n`` or ``\\r`` arrives. A piece + that ended on one of the other breaks goes out as it is. Mirrors the matching semantics of ``esphome.util.RedirectText`` so filter patterns behave identically in both the PlatformIO @@ -179,6 +182,44 @@ def main() -> int: def flush(self) -> None: self._stream.flush() + def _emit(self, line: str) -> None: + if self._filter_pattern is not None: + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + return + self._stream.write(line) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + idf.py and CMake do not always end their last line with a + newline, and a build that dies part way through can stop mid + line. Without this the user is left staring at a build that + ended with no explanation. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit(line + "\n") + self._stream.flush() + except (OSError, ValueError) as err: + # We are called from cleanup, so raising would replace the + # build's real exit code. Saying so must not raise either: + # under the dashboard our stdout and stderr are the same + # pipe, so whatever broke the write has most likely broken + # the report, and ``sys.__stderr__`` is None on some + # interpreters. Carry the line along; it is usually the + # message saying why the build failed. + if (real_stderr := sys.__stderr__) is not None: + with contextlib.suppress(OSError, ValueError): + print( + f"Could not write out remaining output ({err}): {line}", + file=real_stderr, + ) + def write(self, data) -> int: # Text streams normally hand us ``str``; decode in case # somebody writes bytes directly. @@ -186,21 +227,32 @@ def main() -> int: data = data.decode(errors="replace") if self._filter_pattern is None: - self._stream.write(data) - return len(data) + # Nothing to match against, so no need to wait for a full line. + self._emit(data) + else: + lines = (self._line_buffer + data).splitlines(keepends=True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one + # can still be waiting for more text. Hold that one, write + # out the rest. + # + # Some of those breaks are not line endings to us, a form + # feed for one, so a piece can go out without ending in a + # newline. That beats what we did before, which was to stop + # at the first such piece and drop every complete line + # behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: + self._line_buffer = "" + for line in lines: + self._emit(line) - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break - self._line_buffer = "" - - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + # We tell idf.py it is talking to a terminal, so it sends progress + # bars and cursor moves. Our own stdout is usually a pipe, which is + # block buffered, so without this the build looks frozen until + # 8 KiB of output piles up. + self._stream.flush() return len(data) if len(sys.argv) < 2: @@ -217,8 +269,8 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] + stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -236,8 +288,19 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. - runpy.run_path(script_path, run_name="__main__") + # fall through and exit with 0. Either way the streams get a chance to + # release a last line that never got its terminator. Drain the shims we + # made rather than sys.stdout, which the script is free to replace, and + # report instead of raising so cleanup cannot bury the real exit code. + try: + runpy.run_path(script_path, run_name="__main__") + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_shim.drain() + finally: + stderr_shim.drain() return 0 diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 3ba0bf3b4d..7a5305ff0c 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -57,7 +57,7 @@ def _find_app_partition_size(partitions_csv: Path) -> int: """ if not partitions_csv.is_file(): raise ValueError(f"partitions.csv not found at {partitions_csv}") - for row in csv.reader(partitions_csv.read_text().splitlines()): + for row in csv.reader(partitions_csv.read_text(encoding="utf-8").splitlines()): cells = [c.strip() for c in row] if not cells or cells[0].startswith("#") or len(cells) < 5: continue @@ -89,7 +89,7 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: _LOGGER.debug("Skipping size summary: %s not found", size_json) return try: - data = json.loads(size_json.read_text()) + data = json.loads(size_json.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index b8196d2fda..e1688f4170 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -9,14 +9,18 @@ import re import shutil import subprocess -from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, CONF_FRAMEWORK, CONF_SOURCE, + KEY_ESP32, + KEY_FLASH_SIZE, + KEY_IDF_VERSION, + KEY_VARIANT, ) from esphome.core import CORE, EsphomeError +from esphome.espidf import variant_to_idf_target from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary from esphome.helpers import add_git_ceiling_directory @@ -56,6 +60,27 @@ def _get_framework_source_override() -> str | None: return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE) +def _get_configured_targets() -> list[str] | None: + """Return the IDF install target for the configured variant, if known. + + Limiting the toolchain install to the variant being built skips the other + architecture's compiler entirely (several hundred MB of download and 1-2GB + of disk). idf_tools.py accumulates targets across runs, so building a + second variant later installs just its toolchain incrementally. None (no + variant stored, e.g. tooling outside a build) falls back to the default + inside check_esp_idf_install. + + CI always installs every target (None falls through to the "all" + default): runners share one toolchain cache across jobs that build + different variants, so a full install keeps the cached tree identical + everywhere instead of per-variant supersets invalidating each other. + """ + if os.environ.get("CI"): + return None + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + return [variant_to_idf_target(variant)] if variant else None + + def _get_esphome_esp_idf_paths( version: str | None = None, ) -> tuple[os.PathLike, os.PathLike]: @@ -63,7 +88,9 @@ def _get_esphome_esp_idf_paths( paths = _cache().paths if version not in paths: paths[version] = check_esp_idf_install( - version, source_url=_get_framework_source_override() + version, + targets=_get_configured_targets(), + source_url=_get_framework_source_override(), ) return paths[version] @@ -99,6 +126,14 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: def _get_cmake_output(build_dir) -> str: cmake_output_cache = _cache().cmake_output if build_dir not in cmake_output_cache: + # Check the build before resolving the env: _get_idf_env() runs + # check_esp_idf_install(), which can download and install the whole + # framework. Never start that for a build that isn't there. Callers + # such as the log stack-trace decoder run against devices that were + # never compiled on this machine. + if not (Path(build_dir) / "CMakeCache.txt").is_file(): + raise EsphomeError(f"No ESP-IDF build found in {build_dir}") + cmd = ["cmake", "-LA", "-N", "."] env = _get_idf_env() @@ -477,9 +512,16 @@ def get_idedata() -> dict | None: cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: try: - return json.loads(cache.read_text(encoding="utf-8")) + cached = json.loads(cache.read_text(encoding="utf-8")) except ValueError: pass + else: + # Caches written before cc_path was emitted stay newer than + # compile_commands.json forever, so rebuild them on the field rather + # than on the timestamp. Check the type too: a corrupted cache can + # still be valid JSON, and "in" would match a substring of a string. + if isinstance(cached, dict) and "cc_path" in cached: + return cached data = idedata_from_build(compile_commands) data["prog_path"] = str(get_elf_path()) diff --git a/esphome/expression.py b/esphome/expression.py index d425d822a4..13da3b6a06 100644 --- a/esphome/expression.py +++ b/esphome/expression.py @@ -1,4 +1,4 @@ -"""Helpers for detecting substitution variables and Jinja expressions.""" +"""Helpers for detecting and matching substitution variables and Jinja expressions.""" import re @@ -8,7 +8,7 @@ SUBSTITUTION_VARIABLE_PROG = re.compile( rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})" ) -_JINJA_RE = re.compile( +JINJA_PROG = re.compile( r"<%.+?%>" # Block: <% ... %> r"|\$\{[^}]+\}", # Braced: ${ ... } flags=re.MULTILINE, @@ -17,7 +17,7 @@ _JINJA_RE = re.compile( def has_jinja(value: str) -> bool: """Check if a string contains Jinja expressions.""" - return _JINJA_RE.search(value) is not None + return JINJA_PROG.search(value) is not None def has_substitution_or_expression(value: str) -> bool: diff --git a/esphome/external_files.py b/esphome/external_files.py index 69423d3999..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,27 +1,74 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import write_file from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -92,6 +139,10 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -125,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -134,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -157,13 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] + ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -182,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -204,49 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, description: str = "remote file(s)", ) -> None: - """Run `download_content` for each (url, path) pair concurrently. + """Run `download_content` for each `RemoteFile` concurrently. - `description` names the kind of files in the progress log line, e.g. - "wake word manifest(s)". - - Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. All workers run to - completion before this returns; every `cv.Invalid` raised by a worker - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return - _LOGGER.info("Checking %d %s for updates", len(seen), description) - if len(seen) == 1: - path, url = next(iter(seen.items())) - download_content(url, path, timeout) + ensure_happy_eyeballs() + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -259,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -278,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 202d4a2bfb..6ed608b171 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -13,6 +13,7 @@ import sys import time from typing import IO, TYPE_CHECKING +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import ProgressBar, rmtree if TYPE_CHECKING: @@ -755,6 +756,8 @@ def download_with_resume( from esphome.core import EsphomeError + ensure_happy_eyeballs() + dest = Path(dest) part = dest.with_name(dest.name + ".part") meta = part.with_name(part.name + ".meta") @@ -922,6 +925,8 @@ def download_from_mirrors( from esphome.core import EsphomeError + ensure_happy_eyeballs() + # 1. Classify the target: filesystem path or open file object path_target: Path | None = None f: IO[bytes] | None = None diff --git a/esphome/git.py b/esphome/git.py index b5abf39a24..9815377f51 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -5,6 +5,7 @@ from enum import Enum, auto import errno import hashlib import logging +import math import os from pathlib import Path import re @@ -16,7 +17,12 @@ import urllib.parse import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds -from esphome.helpers import add_git_ceiling_directory, rmtree, write_file +from esphome.helpers import ( + add_git_ceiling_directory, + format_duration, + rmtree, + write_file, +) if TYPE_CHECKING: from filelock import FileLock @@ -26,6 +32,11 @@ _LOGGER = logging.getLogger(__name__) # Special value to indicate never refresh NEVER_REFRESH = TimePeriodSeconds(seconds=-1) +# `refresh: never` validates to a huge interval rather than the NEVER_REFRESH +# sentinel; treat any interval at least that long as refresh disabled instead +# of logging a countdown of hundreds of years +_REFRESH_DISABLED_SECONDS = cv.source_refresh(cv.SOURCE_REFRESH_NEVER).total_seconds + # revert() runs on an already-failing path; bound its wait for the cache # entry lock so that recovery cannot hang forever behind another process. _REVERT_LOCK_TIMEOUT_SECONDS = 60 @@ -67,6 +78,45 @@ _GIT_REPO_SCOPING_ENV = frozenset( } ) +# Substrings (matched case-insensitively against git's full stderr) that +# identify transient network failures worth retrying. Auth failures, +# missing repositories, and bad refs must fail immediately. Patterns are +# phrase-anchored so a repository URL quoted back in stderr never matches. +_TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = ( + "unable to access", + "could not resolve host", + "could not connect", + "failed to connect", + "timed out", + "connection reset", + "connection refused", + "early eof", + "rpc failed", + "certificate verification failed", + # Anchored to curl's diagnostic prefix so repository URLs containing + # "ssl_" tokens never classify as transient + "openssl ssl_", + "ssl routines", + "ssl connect error", + "gnutls recv error", + "gnutls_handshake", + "unexpected disconnect", + "remote end hung up unexpectedly", +) + +# git quotes HTTP failures in two forms: curl's "The requested URL returned +# error: " and smart-HTTP's "RPC failed; HTTP curl ". 4xx is +# permanent (rejected credentials, missing repository) except 429 rate +# limiting; 408/425 are also treated as permanent, a deliberate trade for a +# simple rule since git hosts rarely emit them. +_PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d") + +# Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x +# the command's own duration plus 6s of sleep, held under the cache entry +# lock; peers with a complete entry fall back to it after +# _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS. +_NETWORK_MAX_ATTEMPTS = 3 + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -77,7 +127,18 @@ class GitNotInstalledError(GitException): class GitCommandError(GitException): - """Exception raised when a git command fails.""" + """Exception raised when a git command fails. + + ``stderr`` holds git's full stderr output; the exception message is + usually only the last ``fatal:`` line, but transient network markers + (``RPC failed``, ``GnuTLS``, ...) often appear on earlier lines. + Empty when git produced no stderr, so classification never reads the + command line (which embeds the user-supplied repository URL). + """ + + def __init__(self, message: str, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr class GitRepositoryError(GitException): @@ -93,8 +154,23 @@ def _redact_url_credentials(text: str) -> str: return re.sub(r"://[^/@\s]+@", "://***@", text) +def _is_transient_git_error(stderr: str) -> bool: + """Return True when git's stderr looks like a transient network failure.""" + lowered = stderr.lower() + if _PERMANENT_HTTP_ERROR_RE.search(lowered): + return False + if "authentication failed" in lowered: + return False + return any(pattern in lowered for pattern in _TRANSIENT_GIT_ERROR_PATTERNS) + + def run_git_command( - cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None + cmd: list[str], + git_dir: Path | None = None, + *, + cwd: Path | None = None, + network: bool = False, + retry_cleanup: Path | None = None, ) -> str: """Run a git command and return its stdout. @@ -103,7 +179,50 @@ def run_git_command( to that repository and runs the command there; ``cwd`` alone runs the command in that directory with GIT_CEILING_DIRECTORIES capping repository discovery at its parent. + + ``network=True`` marks a command that talks to a remote (clone, fetch, + submodule update): transient network failures (DNS, TLS, dropped + connections) are retried with a short backoff so a momentary blip does + not fail the whole build. Local-only commands must not set it. + ``retry_cleanup`` names a directory to remove before each retry, for + commands like clone that can leave a partial destination behind. """ + attempts = _NETWORK_MAX_ATTEMPTS if network else 1 + attempt = 0 + while True: + try: + return _run_git_command_once(cmd, git_dir, cwd=cwd) + except GitCommandError as err: + attempt += 1 + if attempt >= attempts or not _is_transient_git_error(err.stderr): + raise + if retry_cleanup is not None and retry_cleanup.is_dir(): + try: + rmtree(retry_cleanup) + except OSError as cleanup_err: + # A retry would fail on the leftover directory anyway; + # give up and keep the git error as the reported cause. + _LOGGER.warning( + "Could not remove %s before retry (%s); not retrying", + retry_cleanup, + cleanup_err, + ) + raise err from None + delay = 2**attempt + _LOGGER.warning( + "Git command failed: %s. Retrying in %d seconds... (attempt %d/%d)", + _redact_url_credentials(str(err)), + delay, + attempt, + attempts, + ) + time.sleep(delay) + + +def _run_git_command_once( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Single attempt of ``run_git_command``; see its docstring.""" # Every invocation starts from an environment with the repository-scoping # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI # wrapper invoking ESPHome can never redirect these commands to its own @@ -158,11 +277,15 @@ def run_git_command( if ret.returncode != 0: if ret.stderr: - err_str = ret.stderr.decode("utf-8") + # errors="replace": git can emit locale-encoded (non-UTF-8) bytes + # in stderr; the error path must never raise UnicodeDecodeError. + err_str = ret.stderr.decode("utf-8", errors="replace") lines = [x.strip() for x in err_str.splitlines()] if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + raise GitCommandError(lines[-1][len("fatal: ") :], stderr=err_str) + raise GitCommandError(err_str, stderr=err_str) + # No stderr (e.g. git killed by a signal): nothing to classify, + # never retried. raise GitCommandError( f"git exited with code {ret.returncode}: " f"{_redact_url_credentials(' '.join(cmd))}" @@ -399,6 +522,7 @@ def update_submodules(repo_dir: Path, key: str) -> None: run_git_command( ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], cwd=repo_dir, + network=True, ) @@ -595,7 +719,7 @@ def _clone_or_update_locked( try: cmd = ["git", "clone", "--depth=1"] cmd += ["--", url, str(repo_dir)] - run_git_command(cmd) + run_git_command(cmd, network=True, retry_cleanup=repo_dir) if ref is not None: # We need to fetch the PR branch first, otherwise git will complain @@ -604,6 +728,7 @@ def _clone_or_update_locked( run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=repo_dir, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir @@ -674,7 +799,57 @@ def _clone_or_update_locked( cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) - run_git_command(cmd, git_dir=repo_dir) + fetch_head = Path(repo_dir) / ".git" / "FETCH_HEAD" + try: + fetch_head_stat = fetch_head.stat() + except OSError: + # Missing (or unreadable): no pre-fetch FETCH_HEAD + fetch_head_stat = None + try: + run_git_command(cmd, git_dir=repo_dir, network=True) + except GitCommandError as err: + if not _is_transient_git_error(err.stderr): + raise + # Verified clone, untouched worktree, network-only + # failure: keep the clone instead of destroying it via + # recovery, which would re-clone on the same dead + # network. The marker must be restored or the next run + # removes the entry as an incomplete clone. + # + # A failed fetch still freshens FETCH_HEAD's mtime, + # which would suppress refresh attempts for the whole + # refresh window; restore it so the next run retries. + try: + if fetch_head_stat is not None: + os.utime( + fetch_head, + (fetch_head_stat.st_atime, fetch_head_stat.st_mtime), + ) + else: + fetch_head.unlink(missing_ok=True) + except OSError as stamp_err: + # Cannot keep the fallback honest; let the git error + # route through the recovery below instead. + _LOGGER.warning( + "Could not restore the refresh timestamp for %s (%s)", + safe_key, + stamp_err, + ) + raise err from None + _LOGGER.warning( + "Could not refresh %s (%s); using the existing clone " + "at %s (last updated %s ago)", + safe_key, + _redact_url_credentials(str(err)), + old_sha, + # age_seconds is inf when neither FETCH_HEAD nor HEAD + # could be stat'ed; format_duration would overflow + format_duration(age_seconds) + if math.isfinite(age_seconds) + else "unknown time", + ) + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) + return repo_dir, None # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) run_git_command( @@ -709,7 +884,7 @@ def _clone_or_update_locked( _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", safe_key, - err, + _redact_url_credentials(str(err)), ) _LOGGER.info("Removing broken repository at %s", repo_dir) _remove_repo_dir(repo_dir) @@ -803,6 +978,17 @@ def _clone_or_update_locked( return True return repo_dir, revert + if refresh.total_seconds >= _REFRESH_DISABLED_SECONDS: + # refresh: never + _LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key) + else: + _LOGGER.info( + "Skipping update for %s, will refresh on the next run after %s " + "(refresh: %s); use refresh: always to update now", + safe_key, + format_duration(refresh.total_seconds - age_seconds), + format_duration(refresh.total_seconds), + ) return repo_dir, None diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py new file mode 100644 index 0000000000..ebfb94f1f9 --- /dev/null +++ b/esphome/happy_eyeballs.py @@ -0,0 +1,136 @@ +"""Happy Eyeballs (RFC 8305) connection support for requests/urllib3. + +urllib3 tries each resolved address in sequence with the full connect +timeout, so a network advertising IPv6 DNS without IPv6 connectivity stalls +every download for the whole timeout before IPv4 is tried. +``ensure_happy_eyeballs()`` swaps urllib3's ``create_connection`` for one +that races address families with a short stagger via aiohappyeyeballs, run +on a daemon-thread event loop so callers stay synchronous. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# RFC 8305 recommended delay between staggered connection attempts. +HAPPY_EYEBALLS_DELAY = 0.25 + +# Extra seconds the connect thread gets beyond the connect timeout before +# the caller gives up waiting for it. +_THREAD_WAIT_BUFFER = 5.0 + + +def ensure_happy_eyeballs() -> None: + """Make urllib3 (and therefore requests) connect with Happy Eyeballs. + + Idempotent; call before performing requests-based downloads. + """ + stock: Callable[..., socket.socket] | None = None + try: + import urllib3.util.connection + + stock = urllib3.util.connection.create_connection + if getattr(stock, "_esphome_patched", False): + return + + urllib3.util.connection.create_connection = _make_create_connection() + except (ImportError, AttributeError) as err: # urllib3 internals moved + # WARNING: degraded mode brings back the stalls this module prevents. + _LOGGER.warning( + "Happy Eyeballs unavailable (%s); downloads use the slower stock " + "urllib3 connect", + err, + ) + _LOGGER.debug("Happy Eyeballs fallback traceback", exc_info=True) + if stock is not None: + # Latch so the warning fires once, not per download. + stock._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + + +def _make_create_connection() -> Callable[..., socket.socket]: + """Build a drop-in replacement for urllib3's ``create_connection``.""" + # Deferred so runs that never download skip the ~30 ms asyncio import. + import asyncio + + from aiohappyeyeballs import start_connection + from urllib3.exceptions import LocationParseError + from urllib3.util.connection import ( # noqa: PLC2701 + _set_socket_options, + allowed_gai_family, + ) + from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 + + from esphome import async_thread + + def create_connection( + address: tuple[str, int], + timeout: Any = _DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: Any = None, + ) -> socket.socket: + host, port = address + if host.startswith("["): + host = host.strip("[]") + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + addr_infos = socket.getaddrinfo( + host, port, allowed_gai_family(), socket.SOCK_STREAM + ) + if not addr_infos: + # Same error as stock urllib3. + raise OSError("getaddrinfo returns an empty list") + connect_timeout = ( + socket.getdefaulttimeout() if timeout is _DEFAULT_TIMEOUT else timeout + ) + + def socket_factory(addr_info: Any) -> socket.socket: + family, type_, proto, _, _ = addr_info + sock = socket.socket(family, type_, proto) + try: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + except BaseException: + sock.close() + raise + return sock + + async def connect() -> socket.socket: + return await asyncio.wait_for( + start_connection( + addr_infos, + happy_eyeballs_delay=HAPPY_EYEBALLS_DELAY, + interleave=1, + socket_factory=socket_factory, + ), + connect_timeout, + ) + + wait = ( + None if connect_timeout is None else connect_timeout + _THREAD_WAIT_BUFFER + ) + # on_orphan closes a socket won after the timeout so it cannot leak. + sock = async_thread.run_async( + connect, timeout=wait, on_orphan=socket.socket.close + ) + # aiohappyeyeballs leaves the winning socket non-blocking; restore the + # blocking-with-timeout behavior urllib3 callers expect. + try: + sock.settimeout(connect_timeout) + except BaseException: + sock.close() + raise + return sock + + create_connection._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + return create_connection diff --git a/esphome/helpers.py b/esphome/helpers.py index 631bcb6f39..2731109164 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping from contextlib import suppress import ipaddress import logging @@ -8,12 +8,9 @@ import os from pathlib import Path import platform import re -import shutil import stat import sys -import tempfile from typing import TYPE_CHECKING, TextIO -from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION @@ -56,15 +53,20 @@ def ensure_unique_string(preferred_string, current_strings): return test_string -def fnv1_hash(string: str) -> int: - """FNV-1 32-bit hash function (multiply then XOR).""" +def _fnv1_hash(values: Iterable[int]) -> int: + """FNV-1 32-bit hash (multiply then XOR) over a sequence of integer values.""" hash_value = FNV1_OFFSET_BASIS - for char in string: + for value in values: hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF - hash_value ^= ord(char) + hash_value ^= value return hash_value +def fnv1_hash(string: str) -> int: + """FNV-1 32-bit hash function (multiply then XOR) over code points.""" + return _fnv1_hash(map(ord, string)) + + def fnv1a_32bit_hash(string: str) -> int: """FNV-1a 32-bit hash function (XOR then multiply). @@ -89,12 +91,27 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. - Used for pre-computing entity object_id hashes at code generation time. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h + with per_code_point set. This is the OLD entity hash; it computes preference + keys that existing devices already have stored (see + https://github.com/esphome/backlog/issues/85) and is also still used for live + keys derived from config IDs (see the motion component's calibration key). + Note: lower() here is Unicode aware while the C++ reconstruction is not; see + the known limitation note on the C++ function. """ return fnv1_hash(sanitize(snake_case(name))) +def fnv1_hash_name(name: str) -> int: + """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). + + IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, + which hashes the name bytes as stored on the device. + Used for pre-computing entity keys at code generation time. + """ + return _fnv1_hash(name.encode("utf-8")) + + def strip_accents(value: str) -> str: """Remove accents from a string.""" import unicodedata @@ -148,6 +165,21 @@ def indent(text, padding=" "): return "\n".join(indent_list(text, padding)) +def format_duration(seconds: float) -> str: + """Format a duration in seconds as a short string like "1d 2h" or "42s". + + Uses the two largest non-zero units, with unit suffixes matching the YAML + time period shorthand (d, h, min, s). + """ + remainder = max(0, int(seconds)) + parts = [] + for suffix, length in (("d", 86400), ("h", 3600), ("min", 60), ("s", 1)): + value, remainder = divmod(remainder, length) + if value: + parts.append(f"{value}{suffix}") + return " ".join(parts[:2]) if parts else "0s" + + # From https://stackoverflow.com/a/14945195/8924614 def cpp_string_escape(string, encoding="utf-8"): def _should_escape(byte: int) -> bool: @@ -246,6 +278,9 @@ def resolve_ip_address( hosts = host else: if not is_ip_address(host): + # Deferred: upload/logs with an IP target never parse a URL. + from urllib.parse import urlparse + url = urlparse(host) if url.scheme != "": host = url.hostname @@ -322,6 +357,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. @@ -397,6 +450,8 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ + import shutil + def _onexc(func, path, exc): if os.access(path, os.W_OK): raise exc @@ -434,6 +489,11 @@ def _write_file( Automatically creates all parent directories. """ + # Deferred: a cache-hit upload/logs run never writes a file; keep the + # tempfile/shutil chain (bz2, lzma, random) off that path. + import shutil + import tempfile + data = text if isinstance(text, str): data = text.encode() @@ -509,6 +569,8 @@ def copy_file_if_changed(src: Path, dst: Path) -> bool: Returns True if file was copied, False if files already matched. """ + import shutil + if file_compare(src, dst): return False dst.parent.mkdir(parents=True, exist_ok=True) @@ -612,7 +674,7 @@ def add_class_to_obj(value, cls): raise -def snake_case(value): +def snake_case(value: str) -> str: """Same behaviour as `helpers.cpp` method `str_snake_case`.""" return value.replace(" ", "_").lower() @@ -620,7 +682,7 @@ def snake_case(value): _DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9-_]") -def sanitize(value): +def sanitize(value: str) -> str: """Same behaviour as `helpers.cpp` method `str_sanitize`.""" return _DISALLOWED_CHARS.sub("_", value) @@ -670,7 +732,7 @@ class ProgressBar: def docs_url(path: str) -> str: """Return the URL to the documentation for a given path.""" # Local import to avoid circular import - from esphome.config_validation import Version + from esphome.core import Version version = Version.parse(ESPHOME_VERSION) if version.is_beta: diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 45efd20bf6..6a9d7171ec 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.2.0 + version: 0.4.0 esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: @@ -18,19 +18,19 @@ dependencies: esphome/micro-wav: version: 0.2.0 espressif/esp-dsp: - version: "1.7.1" + version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: version: 2.1.7 espressif/mdns: - version: 1.11.0 + version: 1.11.3 espressif/esp_wifi_remote: - version: 1.5.1 + version: 1.6.3 rules: - if: "target in [esp32h2, esp32p4]" espressif/wifi_remote_over_eppp: - version: 0.3.2 + version: 0.3.3 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.9 + version: 2.12.12 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.0 + version: 0.7.1 lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..7a659aa0a8 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. diff --git a/esphome/log.py b/esphome/log.py index b120c930d0..1f208bb909 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -1,5 +1,7 @@ from enum import Enum import logging +import sys +from typing import TextIO from esphome.core import CORE @@ -72,13 +74,30 @@ class ESPHomeLogFormatter(logging.Formatter): return message +def _is_tty(stream: TextIO | None) -> bool: + # A stream can be missing, closed, or not a real file object; colorama + # tolerates all three, so treat them like a redirect and let its own + # handling apply. + if stream is None or getattr(stream, "closed", True): + return False + return hasattr(stream, "isatty") and stream.isatty() + + def setup_log( log_level: int = logging.INFO, include_timestamp: bool = False, ) -> None: - import colorama + # colorama translates ANSI escapes for old Windows consoles and strips + # them from redirected output. POSIX terminals render ANSI natively, and + # dashboard runs escape their color codes before printing, so both would + # use colorama as a plain passthrough; skip the import there (it pulls + # in ctypes, ~3ms on every CLI invocation). + if sys.platform == "win32" or not ( + CORE.dashboard or (_is_tty(sys.stdout) and _is_tty(sys.stderr)) + ): + import colorama - colorama.init() + colorama.init() # Setup logging - will map log level from string to constant logging.basicConfig(level=log_level) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index c6a7a7558b..3198de9d21 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -110,8 +110,12 @@ def prepare( CONF_CLIENT_CERTIFICATE_KEY ): with ( - tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file, - tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file, + tempfile.NamedTemporaryFile( + encoding="utf-8", mode="w+", delete=False + ) as cert_file, + tempfile.NamedTemporaryFile( + encoding="utf-8", mode="w+", delete=False + ) as key_file, ): try: cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) diff --git a/esphome/platform_hooks.py b/esphome/platform_hooks.py new file mode 100644 index 0000000000..10515723de --- /dev/null +++ b/esphome/platform_hooks.py @@ -0,0 +1,175 @@ +"""Registry of platform packages that provide optional CLI hooks. + +The logs/upload fast path must know whether a target platform overrides +``show_logs``/``upload_program`` or provides ``process_stacktrace`` +without importing the platform package to find out; importing one pulls +in the whole validation stack (config_validation, voluptuous, boards), +which costs seconds on slow hardware. Keep the mapping in sync with the +hook definitions in ``esphome/components/*/__init__.py``; a unit test +imports each platform package and fails when they drift. + +The compile-path ``run_compile`` hook is deliberately not registered: +compiling imports the platform package regardless, so its probe in +``__main__.py`` stays eager. Both log paths resolve +``process_stacktrace`` through ``esphome.stacktrace.LogLineProcessor``, +which uses get_stacktrace_handler below. +""" + +from __future__ import annotations + +from collections.abc import Callable +from importlib import import_module +import logging +from typing import Any, Final + +from esphome.const import ( + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_NRF52, + PLATFORM_RP2, + Platform, +) + +_LOGGER = logging.getLogger(__name__) + +# Hooks whose loss only degrades diagnostics; skipping one of these is +# logged at debug, while skipping a hook that changes what the CLI does +# (upload method, log transport) warns. A new hook is loud by default. +COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"}) + +# Per-platform trigger languages for lazy stacktrace decoding: a +# matching line is what imports the platform package, so false triggers +# (8-digit uptime counters, ESP-IDF decimal timestamps) must stay out. +# Declaring a gate registers the process_stacktrace hook, and each gate +# must stay a superset of its decoder patterns' trigger language; both +# are enforced by tests/unit_tests/test_stacktrace.py. Stored as +# strings so a log session compiles only its own platform's gate. +STACKTRACE_GATES: Final[dict[str, str]] = { + PLATFORM_ESP32: ( + r"0x[0-9a-fA-F]{3,}\b" + r"|(?:PC|RA|MEPC|MTVAL|EXCVADDR|call)\s*[:=]\s*(?:0x)?4[0-9a-fA-F]{7}" + r"|CRASH DETECTED ON PREVIOUS BOOT" + ), + PLATFORM_ESP8266: ( + r"0x[0-9a-fA-F]{3,}\b" + r"|\b(?![0-9]{8}\b)[0-9a-fA-F]{8}\b" + r"|(?:PC|EXCVADDR|call)\s*[:=]\s*(?:0x)?4[0-9a-fA-F]{7}" + r"|[eE]xception \(\d+\):" + r"|>>>stack>>>" + r"|CRASH DETECTED ON PREVIOUS BOOT" + ), + PLATFORM_RP2: r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT", + PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:", +} + +PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = { + "show_logs": frozenset({PLATFORM_NRF52}), + "upload_program": frozenset({PLATFORM_NRF52}), + "process_stacktrace": frozenset(STACKTRACE_GATES), +} + + +# The registry only speaks for in-tree platforms; a target platform +# supplied via external_components is normally not in Platform and falls +# back to probing the imported package, as the CLI did before the +# registry. Deliberate trade: an external component that shadows an +# in-tree platform name (the meta finder allows it) is treated as the +# in-tree platform here, so its own hooks are not probed. +_IN_TREE_PLATFORMS: Final = frozenset(Platform) + + +def has_registered_hook(platform: str, hook: str) -> bool: + """True when *platform* declares *hook* in ``PLATFORM_HOOKS``. + + Callers that defer imports key off this: a registered hook is known + to exist, so ``get_platform_hook`` can wait until it is needed; + anything else must be probed up front so availability is reported + at session start. Keeping the predicate here keeps the resolution + rule in one module. + """ + return platform in PLATFORM_HOOKS[hook] + + +def get_platform_hook(platform: str, hook: str) -> Callable[..., Any] | None: + """Return ``esphome.components..`` or None. + + In-tree platforms not registered for the hook return None without + being imported. A registered platform that no longer defines the + hook also returns None, so a stale registry degrades to the generic + path instead of raising. + """ + registered = has_registered_hook(platform, hook) + if not registered and platform in _IN_TREE_PLATFORMS: + return None + # For external platforms this probes the imported package like the + # CLI used to; the package can be missing entirely on the warm-cache + # path, where the external_components meta finder never registered. + # Degrade to the generic path then, but let a failure deeper in the + # package (missing dependency) surface. + module_name = f"esphome.components.{platform}" + try: + module = import_module(module_name) + except ModuleNotFoundError as err: + if registered or err.name != module_name: + raise + if hook in COSMETIC_HOOKS: + _LOGGER.debug( + "External platform %s is not importable; using the generic %s path", + platform, + hook, + ) + else: + # Deliberately loud even though the warm-cache path makes + # this expected: the user's platform hooks are not in effect + # for this run, and a silently substituted upload method is + # worse than a routine warning. + _LOGGER.warning( + "External platform %s is not importable; using the generic %s path", + platform, + hook, + ) + return None + handler = getattr(module, hook, None) + if handler is None: + if registered: + _LOGGER.warning( + "%s is registered for %s but no longer exposes it; using the generic path", + platform, + hook, + ) + else: + # The common case for external platforms; debug so a typoed + # hook name is still diagnosable without being noisy. + _LOGGER.debug( + "External platform %s does not expose %s; using the generic path", + platform, + hook, + ) + return handler + + +def get_stacktrace_handler(platform: str) -> Callable[..., Any] | None: + """Resolve ``process_stacktrace`` for *platform*, degrading with a log. + + Stacktrace decoding is a diagnostic nicety. This only distinguishes + an import failure from an ordinary capability gap so the message is + accurate; it returns None for both, and callers own any further + containment. Shared so the user-facing message lives in one place. + """ + try: + handler = get_platform_hook(platform, "process_stacktrace") + except ImportError as err: + # A real breakage, not an ordinary capability gap; say so louder. + _LOGGER.debug("Stacktrace analyzer import failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" failed to import: %s', + platform, + err, + ) + return None + if handler is None: + _LOGGER.info( + 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', + platform, + ) + return handler diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script new file mode 100644 index 0000000000..cc08a8c044 --- /dev/null +++ b/esphome/platformio/ccache.py.script @@ -0,0 +1,34 @@ +import os +import shutil + +# pylint: disable=E0602 +Import("env") # noqa + +# ESPHome decides whether ccache is used and exports the CCACHE_* settings +# into the environment before PlatformIO starts (_ccache_env() in +# esphome/platformio/toolchain.py); this script only supplies the SCons-level +# mechanism. +# +# This is a "pre" script, so the platform's builder (which sets CC/CXX and +# clones the construction environment for framework and library builds) runs +# after it. Replacing CC/CXX here would be overwritten, and replacing them in +# a "post" script would miss the already-cloned library environments. Wrapping +# SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler +# invocation from every environment funnels through it at execution time. +if ( + os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" + and (ccache_path := shutil.which("ccache")) is not None +): + original_spawn = env["SPAWN"] + + def ccache_spawn(sh, escape, cmd, args, child_env): + # Only wrap compile steps (gcc/g++ with -c); linking, archiving and + # the other tools gain nothing from ccache. + prog = os.path.basename(cmd).removesuffix(".exe") + if prog.endswith(("gcc", "g++")) and "-c" in args: + cmd = ccache_path + args = [escape(ccache_path), *args] + return original_spawn(sh, escape, cmd, args, child_env) + + env.Replace(SPAWN=ccache_spawn) + print("ESPHome: Compiling with ccache") diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 1a523ce0ab..ee0a758a31 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -21,14 +21,15 @@ import itertools import json import logging import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re import tempfile from typing import Any from urllib.parse import urlsplit, urlunsplit +from urllib.request import url2pathname from esphome import git -from esphome.core import CORE, Library +from esphome.core import CORE, EsphomeError, Library from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,14 @@ class Source: ) -> Path: raise NotImplementedError + def source_root(self, build_path: Path) -> Path: + """Directory holding the library's own files (manifest + sources). + + Defaults to the downloaded build directory; a source that references its + files in place (:class:`LocalSource`) overrides this to point elsewhere. + """ + return build_path + class URLSource(Source): def __init__(self, url: str): @@ -143,6 +152,53 @@ class GitSource(Source): return f"{self.url}#{self.ref}" if self.ref else self.url +class LocalSource(Source): + """A library that already exists as a directory on the local filesystem. + + Referenced with a ``file://`` URL (PlatformIO's spelling for a local library + folder). Nothing is copied: the backend generates its build files into an + otherwise empty cache directory and references the library's own sources in + place by absolute path (via :meth:`source_root`). So the user's source tree + stays untouched and edits are picked up on the next build without syncing. + """ + + def __init__(self, path: str): + self.local_path = path + + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + src = Path(self.local_path) + if not src.is_dir(): + # EsphomeError (not InvalidLibrary) so the CLI prints a clean message + # instead of a traceback -- pointing a file:// at a missing folder is + # the most common first mistake with a local library. + raise EsphomeError( + f"Local library directory does not exist: {self.local_path}" + ) + base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace + h = hashlib.new("sha256") + h.update(str(src.resolve()).encode()) + if salt: + h.update(salt.encode()) + # Only the generated build files live here; the library's own sources + # are referenced in place from source_root(). + path = base_dir / h.hexdigest()[:8] / dir_suffix + path.mkdir(parents=True, exist_ok=True) + return path + + def source_root(self, build_path: Path) -> Path: + return Path(self.local_path) + + def __str__(self): + path = Path(self.local_path) + # as_uri() needs an absolute path; _node_key rejects relative file:// + # URLs, but guard anyway so a diagnostic can't itself raise. + return path.as_uri() if path.is_absolute() else f"file://{self.local_path}" + + class InvalidLibrary(Exception): pass @@ -162,6 +218,9 @@ class ConvertedLibrary: self.data = {} self.dependencies: list[ConvertedLibrary] = [] self._path: Path | None = None + # Where the library's own files live (manifest + sources). Set by + # download(); equals path for registry/git, the user's dir for local. + self.source_path: Path | None = None def __str__(self): return f"{self.name}@{self.version}={self.source}" @@ -176,6 +235,16 @@ class ConvertedLibrary: def path(self, value: Path) -> None: self._path = value + @property + def source_dir(self) -> Path: + """Directory the library's own files (manifest + sources) are read from. + + The build dir for a registry/git source; the user's directory for a + local library. Backends read sources from here and emit their build + files into ``path``. + """ + return self.source_path or self.path + def get_sanitized_name(self): return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) @@ -193,6 +262,7 @@ class ConvertedLibrary: self.path = self.source.download( self.get_sanitized_name(), force=force, salt=salt, namespace=namespace ) + self.source_path = self.source.source_root(self.path) @dataclass @@ -515,11 +585,14 @@ class _LibNode: key: str is_git: bool + is_local: bool = False + is_registry: bool = False owner: str | None = None pkgname: str | None = None requirements: set[str] = field(default_factory=set) url: str | None = None ref: str | None = None + local_path: str | None = None edges: set[str] = field(default_factory=set) @@ -536,40 +609,83 @@ def _url_or_none(value: Any) -> str | None: def _node_key( name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. +) -> tuple[str, str, tuple[str | None, str | None]]: + """Return ``(key, kind, locator)`` for a library or dependency spec. - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``convert_libraries`` warns about - that after resolution rather than merging the nodes. + ``kind`` is one of: - PlatformIO's Library Manager also accepted a git URL in the *name* - position (``add_library("https://github.com/x/y", None)``), including the - ``git+`` VCS prefix and the ``CustomName=URL`` form; recognize those here - so such specs resolve as git sources instead of failing a registry lookup. + - ``"registry"`` -- ``locator`` is ``(owner, pkgname)``. + - ``"git"`` -- ``locator`` is ``(url, ref)``. + - ``"local"`` -- a ``file://`` directory; ``locator`` is ``(path, None)``. + + The key is derived from the *input* spec (the registry name as written, the + git URL path, or the custom name / directory name for a local folder), not + the resolved canonical name. So a package referenced inconsistently -- bare + ``name`` vs ``owner/name``, or git vs registry -- maps to distinct keys and + isn't deduplicated; ``convert_libraries`` warns about that after resolution + rather than merging the nodes. + + PlatformIO's Library Manager also accepted a URL in the *name* position + (``add_library("https://github.com/x/y", None)``), including the ``git+`` + VCS prefix and the ``CustomName=URL`` form; recognize those here so such + specs resolve as git (or local) sources instead of failing a registry + lookup. A plain ``file://`` URL is PlatformIO's spelling for a local library + folder, so it resolves as a local directory; ``git+file://`` stays a git + source. """ if not repository and name and "://" in name: - # Try the whole name first so a bare URL whose query contains ``=`` - # stays intact; fall back to the ``CustomName=URL`` form, where the - # key derives from the URL path and the custom name is irrelevant. - repository = _url_or_none(name) or _url_or_none(name.split("=", 1)[-1]) - if repository is None: + # Split a ``CustomName=URL`` name, but only when the whole string isn't + # itself a valid URL (a bare URL whose query contains ``=`` must stay + # intact). + custom_name, candidate = None, name + if "=" in name and _url_or_none(name) is None: + custom_name, candidate = name.split("=", 1) + try: + scheme = urlsplit(candidate).scheme + except ValueError: + scheme = "" + if scheme == "file" or _url_or_none(candidate): + name, repository = custom_name, candidate + else: # Anything with ``://`` was meant to be a URL; failing it fast # beats a confusing registry "package not found" error. raise RuntimeError(f"Invalid PIO library URL: {name}") if repository: + is_git_prefixed = repository.startswith("git+") split_result = urlsplit(repository.removeprefix("git+")) + if split_result.scheme == "file" and not is_git_prefixed: + # A plain file:// URL points at a local library directory. A local + # file URL is written file:///absolute/path (empty host) or, less + # commonly, file://localhost/path. Anything else -- a real host, or + # a relative path whose first segment parses as the host -- is + # rejected rather than silently resolved to the wrong directory. + if split_result.netloc not in ("", "localhost"): + raise RuntimeError( + f"Unsupported host in file:// library URL '{repository}'; " + "use an absolute path, e.g. file:///path/to/lib" + ) + # Validate the URL path itself (always POSIX-style, leading slash), + # not the OS path: on Windows a "/foo" path is not is_absolute() + # without a drive, which would wrongly reject a valid file:/// URL. + # Reject a relative path (``file:lib_dev``) or a bare root + # (``file:///``, which has no final segment). + url_path = split_result.path + if not url_path.startswith("/") or not PurePosixPath(url_path).name: + raise RuntimeError( + f"file:// library URL '{repository}' must be an absolute " + "directory path, e.g. file:///path/to/lib" + ) + path = url2pathname(url_path) + return (name or PurePosixPath(url_path).name), "local", (path, None) key = str(split_result.path).strip("/").removesuffix(".git") ref = split_result.fragment.strip() or None url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) + return key, "git", (url, ref) if name and "/" in name: owner, pkgname = name.split("/", 1) else: owner, pkgname = None, name - return name, False, (owner, pkgname) + return name, "registry", (owner, pkgname) def convert_libraries( @@ -618,13 +734,45 @@ def convert_libraries( return name.split("/")[-1].lower() in lib_ignore def add_spec(name: str | None, version: str | None, repository: str | None) -> str: - key, is_git, locator = _node_key(name, version, repository) - node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + key, kind, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git") nodes[key] = node - if is_git: + # The same key requested from two different kinds of source (or two + # different local paths) is a config mistake: one silently wins. Warn so + # it isn't a surprise. (git-vs-registry is reported separately below.) + if kind == "git": + if node.is_local: + _LOGGER.warning( + "Library %s is requested as both a local directory and a git " + "source; using the git source.", + key, + ) node.is_git = True node.url, node.ref = locator + elif kind == "local": + new_path = locator[0] + if node.is_git: + # git wins (checked first when building the source); leave the + # node as a git source. + _LOGGER.warning( + "Library %s is requested as both a local directory and a git " + "source; using the git source.", + key, + ) + else: + if node.is_local and node.local_path != new_path: + _LOGGER.warning( + "Library %s is requested from two local directories (%s " + "and %s); using %s.", + key, + node.local_path, + new_path, + new_path, + ) + node.is_local = True + node.local_path = new_path else: + node.is_registry = True node.owner, node.pkgname = locator if version: node.requirements.add(version) @@ -658,6 +806,8 @@ def convert_libraries( if node.is_git: component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + elif node.is_local: + component = ConvertedLibrary(key, "*", LocalSource(node.local_path)) else: owner, name, version, url = _resolve_registry_version( node.owner, node.pkgname, node.requirements @@ -667,20 +817,22 @@ def convert_libraries( ) component.download(salt=salt, namespace=backend.cache_key) - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" + source_dir = component.source_dir + library_json_path = source_dir / "library.json" + library_properties_path = source_dir / "library.properties" has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() - if not has_json and not has_properties: + if not has_json and not has_properties and not node.is_local: # The shared cache can hold a broken copy (e.g. a clone or an # extraction interrupted by a killed process). Force one # re-download so a bad cache entry self-heals instead of failing - # every build until the user runs a full clean. + # every build until the user runs a full clean. A local source is + # read in place, so there is nothing to re-download. _LOGGER.warning( "Library %s at %s is missing library.json and library.properties; " "re-downloading", key, - component.path, + source_dir, ) component.download(force=True, salt=salt, namespace=backend.cache_key) has_json = library_json_path.is_file() @@ -690,9 +842,14 @@ def convert_libraries( elif has_properties: component.data = _parse_library_properties(library_properties_path) else: - raise RuntimeError( + # For a local library a missing manifest is user input, so raise + # EsphomeError (clean CLI message) like the missing-directory case; + # for registry/git a missing manifest means a corrupt cache, which + # is not user error, so keep RuntimeError. + error_cls = EsphomeError if node.is_local else RuntimeError + raise error_cls( f"Invalid PIO library {key}: missing library.json and " - f"library.properties in {component.path}" + f"library.properties in {source_dir}" ) try: @@ -735,17 +892,26 @@ def convert_libraries( node.edges.add(dep_key) worklist.append(dep_key) - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. + # A git or local source wins over the same component requested from the + # registry. That's intentional, but warn so the dropped registry spec isn't + # a silent surprise -- including when it carried no version pin (a bare + # cg.add_library("Foo"), which is how most components add libraries). for node in nodes.values(): - if node.is_git and node.requirements: + if (node.is_git or node.is_local) and (node.is_registry or node.requirements): + source = "git" if node.is_git else "local" + registry = ( + f"registry version(s) {sorted(node.requirements)}" + if node.requirements + else "a registry package" + ) _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", + "Library %s is requested both from a %s source (%s) and as %s; " + "using the %s source.", node.key, - node.url, - sorted(node.requirements), + source, + node.url if node.is_git else node.local_path, + registry, + source, ) # Two graph nodes that resolve to the same component name (e.g. a package diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index c49220a044..9bb2205a90 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -179,12 +179,24 @@ def main() -> int: 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) + stdout_redirect = sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + stderr_redirect = sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) import platformio.__main__ - return platformio.__main__.main() or 0 + # PlatformIO exits through ``sys.exit``, so drain from a finally to give + # a last line without a terminator a chance to reach the user. Drain the + # wrappers we made rather than sys.stdout, which PlatformIO is free to + # replace while it runs. + try: + return platformio.__main__.main() or 0 + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() if __name__ == "__main__": diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 105d4a8283..0e7ffce939 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -4,13 +4,22 @@ import logging import os from pathlib import Path import re +import shutil import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +import platformdirs from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.helpers import add_git_ceiling_directory, rmtree, write_file -from esphome.util import FlashImage, run_external_process +from esphome.helpers import ( + add_git_ceiling_directory, + copy_file_if_changed, + get_bool_env, + rmtree, + write_file, +) +from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process if TYPE_CHECKING: from platformio.project.config import ProjectConfig @@ -225,6 +234,77 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_env() -> dict[str, str]: + """Return ccache settings for PlatformIO builds. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to + force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` + so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, + which wraps compiler invocations inside SCons) only have to check for + ``"1"`` instead of re-implementing the policy. + + The returned values are merged into the environment of the PlatformIO + subprocess only, never into ``os.environ``: a long-running process + (e.g. the dashboard) also runs ESP-IDF builds, whose own ccache setup + skips defaults for ``CCACHE_*`` keys it finds already set, so leaking + these values would hand it the wrong cache dir and a stale basedir. + + This mirrors ``_ccache_env()`` in ``esphome/espidf/framework.py``. The + cache lives under the machine-global ESPHome cache dir, so it is shared + across all projects and removed by ``esphome clean-all``. Unlike the + ESP-IDF path, ``CCACHE_DEPEND`` is not set: SCons compiles don't emit + the depfiles depend mode needs, so ccache's default preprocessor mode + is used. + + ``CCACHE_BASEDIR`` rewrites the per-device absolute paths (the generated + sources under src/, the .pioenvs build dir) so different devices with + identical source share cache entries; it is always set to the current + build dir. The other ``CCACHE_*`` values the user already set in the + environment are respected. + """ + if "ESPHOME_CCACHE_ENABLE" in os.environ: + enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") + else: + enabled = shutil.which("ccache") is not None + env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} + if not enabled: + return env + # build_path is set during preload for every config-loading command, so it + # being unset means a caller built the environment too early; fail loudly + # rather than with an opaque TypeError from Path(None). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the PlatformIO " + "build environment" + ) + env["CCACHE_BASEDIR"] = str(Path(CORE.build_path).resolve()) + defaults = { + "CCACHE_DIR": str( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) + / "platformio-ccache" + ), + "CCACHE_NOHASHDIR": "true", + } + env.update({k: v for k, v in defaults.items() if k not in os.environ}) + return env + + +def copy_ccache_script() -> None: + """Copy the shared ccache SCons pre-script into the build dir. + + Platform components call this from their ``copy_files()`` and add + ``pre:ccache.py`` to their ``extra_scripts``. The script wraps compiler + invocations inside SCons with ccache; it is platform-agnostic, so it + lives here next to ``_ccache_env()`` rather than being duplicated per + component. + """ + copy_file_if_changed( + Path(__file__).parent / "ccache.py.script", + CORE.relative_build_path("ccache.py"), + ) + + def run_platformio_cli(*args, **kwargs) -> str | int: # Re-provision the PlatformIO cache if the interpreter's major.minor changed # since it was last built; a stale platform otherwise rejects the new Python @@ -256,7 +336,21 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PYTHONEXEPATH"] = python_exe cmd = [python_exe, "-m", "esphome.platformio.runner"] + list(args) - return run_external_process(*cmd, **kwargs) + # ccache settings go into the subprocess environment only (see + # _ccache_env() for why they must not leak into os.environ). A caller + # supplied env is used as the base when present. + base_env = kwargs.pop("env", None) + env = dict(os.environ if base_env is None else base_env) + env.update(_ccache_env()) + # The runner offers the out-of-flash tip but has no configured CORE, so + # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same + # variable; clear an inherited one so it cannot reach the wrong build. + if CORE.is_configured and CORE.is_esp32 and CORE.using_arduino: + env[ESP32_ARDUINO_ENV] = "1" + else: + env.pop(ESP32_ARDUINO_ENV, None) + + return run_external_process(*cmd, env=env, **kwargs) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: @@ -277,18 +371,24 @@ def run_compile(config, verbose): def _run_idedata(config): args = ["-t", "idedata"] stdout = run_platformio_cli_run(config, False, *args, capture_stdout=True) + if not isinstance(stdout, str): + # run_external_process returns 1 instead of captured output when + # launching platformio raised; see the error it logged above. + raise EsphomeError("Could not launch platformio to get idedata") match = re.search(r'{\s*".*}', stdout) if match is None: - _LOGGER.error("Could not match idedata, please report this error") + # A run that launches but fails emits its build error instead of + # idedata; the logged stdout is the useful part, not a bug report. + _LOGGER.error("Could not find idedata in the platformio output") _LOGGER.error("Stdout: %s", stdout) - raise EsphomeError + raise EsphomeError("PlatformIO did not report idedata") try: return json.loads(match.group()) - except ValueError: + except ValueError as err: _LOGGER.exception("Could not parse idedata") _LOGGER.error("Stdout: %s", stdout) - raise + raise EsphomeError("Could not parse idedata from platformio") from err def _load_idedata(config): @@ -332,9 +432,27 @@ class IDEData: def __init__(self, raw): self.raw = raw + def _require(self, *keys: str) -> Any: + """Read a nested key, classifying a miss as an environment error. + + A stale or truncated cached idedata JSON is the user's build + tree, not a bug; recompiling regenerates it. The message names + the key so a platformio schema change stays diagnosable. + """ + value = self.raw + # TypeError covers a key that is null instead of absent. + try: + for key in keys: + value = value[key] + except (KeyError, TypeError) as err: + raise EsphomeError( + f"Cached idedata is incomplete (missing {'.'.join(keys)})" + ) from err + return value + @property def firmware_elf_path(self) -> Path: - return Path(self.raw["prog_path"]) + return Path(self._require("prog_path")) @property def firmware_bin_path(self) -> Path: @@ -342,15 +460,22 @@ class IDEData: @property def extra_flash_images(self) -> list[FlashImage]: - return [ - FlashImage(path=Path(entry["path"]), offset=entry["offset"]) - for entry in self.raw["extra"]["flash_images"] - ] + try: + return [ + FlashImage(path=Path(entry["path"]), offset=entry["offset"]) + for entry in self._require("extra", "flash_images") + ] + except (KeyError, TypeError) as err: + # Covers entries missing path/offset and a null or non-list + # flash_images value alike. + raise EsphomeError( + "Cached idedata is incomplete (malformed extra.flash_images)" + ) from err @property def cc_path(self) -> str: # For example /Users//.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc - return self.raw["cc_path"] + return self._require("cc_path") @property def addr2line_path(self) -> str: diff --git a/esphome/resolver.py b/esphome/resolver.py index f80a910afe..68bf37eecd 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -8,7 +8,7 @@ import os from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError import aioesphomeapi.host_resolver as hr -from esphome.async_thread import AsyncThreadRunner +from esphome.async_thread import AsyncDispatchTimeout, run_async from esphome.core import EsphomeError _LOGGER = logging.getLogger(__name__) @@ -31,9 +31,9 @@ class AsyncResolver: 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. + (via :func:`run_async`) 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: @@ -48,21 +48,13 @@ class AsyncResolver: ) def resolve(self) -> list[hr.AddrInfo]: - """Start the thread and wait for the result.""" - runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve) - runner.start() - - 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 := 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 - - assert runner.result is not None # guaranteed when event set and no exception - return runner.result + """Resolve and wait for the result.""" + try: + # Give it 1 second more than the resolver timeout + return run_async(self._resolve, timeout=RESOLVE_TIMEOUT + 1.0) + except ResolveTimeoutAPIError as exc: + raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + except ResolveAPIError as exc: + raise EsphomeError(f"Error resolving IP address: {exc}") from exc + except AsyncDispatchTimeout as exc: + raise EsphomeError("Timeout resolving IP address") from exc diff --git a/esphome/stacktrace.py b/esphome/stacktrace.py new file mode 100644 index 0000000000..0adbbf6f2b --- /dev/null +++ b/esphome/stacktrace.py @@ -0,0 +1,124 @@ +"""Lazy stack-trace decoding for streamed device log lines. + +Shared by the serial (run_miniterm) and network (api_client) log paths. +Deliberately light: importing this module must not pull in aioesphomeapi +or any platform package. +""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING + +from esphome import platform_hooks +from esphome.core import EsphomeError +from esphome.types import ConfigType + +if TYPE_CHECKING: + from collections.abc import Callable + + # The contract every platform's process_stacktrace implements. + StacktraceHandler = Callable[[ConfigType, str, bool], bool] + +_LOGGER = logging.getLogger(__name__) + + +class LogLineProcessor: + """Feeds incoming log lines to the stack-trace decoder. + + Three responsibilities beyond just calling the decoder: + 1. Resolve the platform decoder lazily: registered platforms import + nothing until a line matches their gate, registry misses report + at session start without importing, and external platforms + resolve eagerly since their import is unavoidable and belongs + off the streaming callback. + 2. Catch everything the decoder can raise; decoding is a diagnostic + nicety and an escaping exception would log a traceback per dump + line, burying the dump the user is trying to read. + 3. Disable decoding for the rest of the session after a failure. + Retrying means re-running a failing toolchain subprocess on the + stream, and nothing a decode failure depends on heals by itself; + the warning names the fix and a fresh run picks it up. Working + at all requires catching every failure, which is why 2 is not + narrowed to EsphomeError. + """ + + def __init__(self, config: ConfigType, platform: str) -> None: + self._config = config + self._platform = platform + self._platform_handler: StacktraceHandler | None = None + self._decode_enabled = True + # None only for platforms resolved eagerly below; a registered + # platform always declares a gate. + gate = platform_hooks.STACKTRACE_GATES.get(platform) + self._gate: re.Pattern[str] | None = None if gate is None else re.compile(gate) + self.backtrace_state = False + if not platform_hooks.has_registered_hook(platform, "process_stacktrace"): + self._resolve_handler() + + def process_line(self, raw_line: str) -> None: + if not self._decode_enabled: + return + if self._platform_handler is None: + if not self._gate.search(raw_line): + return + # Deliberate trade: the platform import blocks the stream + # here, once per session, instead of at every startup. + if not self._resolve_handler(): + return + _LOGGER.debug( + "Stacktrace gate fired for %s; decoder resolved", self._platform + ) + self._feed(raw_line) + + def _resolve_handler(self) -> bool: + try: + handler = platform_hooks.get_stacktrace_handler(self._platform) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + # Containment includes resolution: a broken platform package + # must not kill the session or retry per line. + _LOGGER.debug("Stacktrace analyzer resolution failed", exc_info=True) + _LOGGER.warning( + 'Stacktrace analysis is unavailable: analyzer for target platform "%s" could not be loaded: %s', + self._platform, + f"{type(exc).__name__}: {exc}", + ) + handler = None + if handler is None: + self._decode_enabled = False + return False + self._platform_handler = handler + return True + + def _feed(self, raw_line: str) -> None: + try: + self.backtrace_state = self._platform_handler( + self._config, raw_line, self.backtrace_state + ) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except + self._decode_enabled = False + self.backtrace_state = False + _LOGGER.debug("Stack-trace decoding failed", exc_info=True) + if isinstance(exc, (EsphomeError, OSError)): + # Environment failures (idedata, build tree) get the + # remediation hint; the fallback string keeps a bare + # EsphomeError from rendering as empty parens. + _LOGGER.warning( + "Crash trace decoding unavailable: %s. " + "Run 'esphome compile' for this device to enable PC decoding.", + str(exc) or "build artifacts not found locally", + ) + else: + # A decoder bug is ESPHome's problem, not the user's; + # don't send them to recompile a healthy build. Name the + # type so a bare KeyError message reads as an exception. + detail = type(exc).__name__ + if msg := str(exc): + detail = f"{detail}: {msg}" + _LOGGER.warning( + 'Crash trace decoding disabled: decoder for "%s" raised %s ' + "(this is a bug; run with -v for the traceback)", + self._platform, + detail, + ) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 6376e573c4..a90a36b848 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -1,26 +1,32 @@ from __future__ import annotations import binascii -from datetime import datetime import json import logging import os from pathlib import Path +from typing import TYPE_CHECKING from esphome import const from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_ESP32, KEY_FRAMEWORK_VERSION, + KEY_IDF_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, EsphomeError, Version from esphome.helpers import write_file_if_changed from esphome.types import CoreType +if TYPE_CHECKING: + from datetime import datetime + _LOGGER = logging.getLogger(__name__) @@ -69,6 +75,17 @@ def _to_path_if_not_none(value: str | None) -> Path | None: return Path(value) if value is not None else None +def _parse_framework_version(framework_version: str) -> Version: + try: + return Version.parse(framework_version) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err + + class StorageJSON: """Persisted device metadata sidecar. @@ -315,41 +332,20 @@ class StorageJSON: } # The compile pipeline populates CORE.data[KEY_ESP32] when esp32's # validator runs; on the cache fast path that validator is skipped, - # so populate the variant upload_using_esptool reads via - # esp32.get_esp32_variant(). target_platform on disk is the variant - # (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). + # so populate the variant upload_using_esptool reads from + # CORE.data[KEY_ESP32][KEY_VARIANT]. target_platform on disk is the + # variant (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32"). if target_platform == const.PLATFORM_ESP32: - from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION - from esphome.const import KEY_VARIANT - esp32_data = {KEY_VARIANT: self.target_platform} if self.framework_version: - import esphome.config_validation as cv - - try: - esp32_data[KEY_IDF_VERSION] = cv.Version.parse( - self.framework_version - ) - except ValueError as err: - raise EsphomeError( - f"Could not parse the framework version " - f"{self.framework_version!r} from {storage_path()}. " - f"Please clean the build files and recompile." - ) from err - CORE.data[KEY_ESP32] = esp32_data - elif target_platform == const.PLATFORM_NRF52 and self.framework_version: - import esphome.config_validation as cv - - try: - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + esp32_data[KEY_IDF_VERSION] = _parse_framework_version( self.framework_version ) - except ValueError as err: - raise EsphomeError( - f"Could not parse the framework version " - f"{self.framework_version!r} from {storage_path()}. " - f"Please clean the build files and recompile." - ) from err + CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = _parse_framework_version( + self.framework_version + ) def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() @@ -379,6 +375,10 @@ class EsphomeStorageJSON: @property def last_update_check(self) -> datetime | None: + # Deferred: this module is on the upload/logs fast path; only the + # dashboard's update check touches these accessors. + from datetime import datetime + try: # Stored format is naive ISO without %z; preserved for backward compat. return datetime.strptime( # noqa: DTZ007 diff --git a/esphome/util.py b/esphome/util.py index b597b4b42e..2fc34f3a69 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,11 +1,11 @@ import collections -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass import io import logging +import os from pathlib import Path import re -import subprocess import sys from typing import TYPE_CHECKING, Any @@ -88,8 +88,11 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Always flush: stdout is block buffered when it is a pipe (the dashboard + # runs us that way), so live log lines would otherwise sit in the buffer + # for a long time instead of streaming out. try: - print(message, end=end) + print(message, end=end, flush=True) return except UnicodeEncodeError: pass @@ -105,6 +108,7 @@ def safe_print(message="", end="\n"): print( message.encode(encoding, "backslashreplace").decode(encoding), end=end, + flush=True, ) return except UnicodeEncodeError: @@ -114,9 +118,10 @@ def safe_print(message="", end="\n"): print( message.encode("ascii", "backslashreplace").decode("ascii"), end=end, + flush=True, ) except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + print("Cannot print line because of invalid locale!", flush=True) def safe_input(prompt=""): @@ -137,6 +142,10 @@ def shlex_quote(s: str | Path) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +# Tells the PlatformIO runner subprocess, which has no configured CORE, that +# this is an ESP32 Arduino build. +ESP32_ARDUINO_ENV = "ESPHOME_ESP32_ARDUINO_BUILD" + ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") @@ -170,6 +179,51 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) + def _emit_line(self, line: str) -> None: + line_without_ansi = ANSI_ESCAPE.sub("", line) + line_without_end = line_without_ansi.rstrip() + if ( + self._filter_pattern is not None + and self._filter_pattern.match(line_without_end) is not None + ): + # Filter pattern matched, ignore the line + return + + self._write_color_replace(line) + # Check for flash size error and provide helpful guidance + if ( + "Error: The program size" in line + and "is greater than maximum allowed" in line + and (help_msg := get_esp32_arduino_flash_error_help()) + ): + self._write_color_replace(help_msg) + for callback in self._line_callbacks: + if msg := callback(line_without_end): + self._write_color_replace(msg) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + A tool that dies part way through a line, or ends its output without + a final newline, would otherwise have that text sit in the buffer + and never reach the user. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit_line(line + "\n") + self._out.flush() + except (OSError, ValueError) as err: + # Every caller drains from a cleanup path, where the command's + # real result is already on its way out; raising here would + # replace it with an unrelated traceback. Carry the line into + # the warning, since the stream we were told to write it to is + # the one that just failed. + _LOGGER.warning("Could not write out remaining output (%s): %s", err, line) + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a @@ -180,38 +234,30 @@ class RedirectText: s = s.decode() if self._filter_pattern is not None or self._line_callbacks: - self._line_buffer += s - lines = self._line_buffer.splitlines(True) - for line in lines: - if "\n" not in line and "\r" not in line: - # Not a complete line, set line buffer - self._line_buffer = line - break + lines = (self._line_buffer + s).splitlines(True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one can + # still be waiting for more text. Hold that one, write out the + # rest. + # + # Some of those breaks are not line endings to us, a form feed + # for one, so a piece can go out without ending in a newline. + # That beats what we did before, which was to stop at the first + # such piece and drop every complete line behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" - - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( - self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line - continue - - self._write_color_replace(line) - # Check for flash size error and provide helpful guidance - if ( - "Error: The program size" in line - and "is greater than maximum allowed" in line - and (help_msg := get_esp32_arduino_flash_error_help()) - ): - self._write_color_replace(help_msg) - for callback in self._line_callbacks: - if msg := callback(line_without_end): - self._write_color_replace(msg) + for line in lines: + self._emit_line(line) else: self._write_color_replace(s) + # Same reason as safe_print: the dashboard gives us a pipe, which is + # block buffered, so in-process esptool progress would not show up + # until the buffer filled. + self._out.flush() + # write() returns the number of characters written # Let's print the number of characters of the original string in order to not confuse # any caller. @@ -252,11 +298,11 @@ def run_external_command( _LOGGER.debug("Running: %s", full_cmd) orig_stdout = sys.stdout - sys.stdout = RedirectText( + stdout_redirect = sys.stdout = RedirectText( sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks ) orig_stderr = sys.stderr - sys.stderr = RedirectText( + stderr_redirect = sys.stderr = RedirectText( sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks ) @@ -282,6 +328,18 @@ def run_external_command( sys.stdout = orig_stdout sys.stderr = orig_stderr + # Release a last line that never got its terminator. This runs after + # the real streams are back, and uses the wrappers we made rather + # than whatever the command left in sys.stdout, so it cannot strand + # them. With capture_stdout the stdout wrapper was never written to, + # so draining it does nothing. Drain stderr from a finally so a + # surprise from the first one cannot strand the second; a real bug + # still propagates, it just does not take the other line with it. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() + if capture_stdout: return cap_stdout.getvalue() @@ -289,6 +347,9 @@ def run_external_command( def run_external_process(*cmd: str, **kwargs: Any) -> int | str: + # Deferred: an OTA upload/logs run never spawns an external process. + import subprocess + full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") @@ -314,6 +375,7 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: encoding="utf-8", check=False, close_fds=False, + env=kwargs.get("env"), ) return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise @@ -328,13 +390,6 @@ def is_dev_esphome_version(): return "dev" in const.__version__ -def parse_esphome_version() -> tuple[int, int, int]: - match = re.match(r"^(\d+).(\d+).(\d+)(-dev\d*|b\d*)?$", const.__version__) - if match is None: - raise ValueError(f"Failed to parse ESPHome version '{const.__version__}'") - return int(match.group(1)), int(match.group(2)), int(match.group(3)) - - # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): @@ -355,7 +410,7 @@ def list_yaml_files(configs: list[str | Path]) -> list[Path]: return sorted(files) -def filter_yaml_files(files: list[Path]) -> list[Path]: +def filter_yaml_files(files: Iterable[Path]) -> list[Path]: return [ f for f in files @@ -449,6 +504,8 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: Returns a BootselResult with the number of devices found (by counting 'type:' lines in output), and whether a permission error was detected. """ + import subprocess + try: result = subprocess.run( [str(picotool_path), "info", "-d"], @@ -468,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: return BootselResult(0) -def get_esp32_arduino_flash_error_help() -> str | None: - """Returns helpful message when ESP32 with Arduino runs out of flash space.""" +def is_esp32_arduino_build() -> bool: + """Whether the build targets ESP32 with the Arduino framework. + + The PlatformIO runner subprocess has no configured CORE, so the parent + passes the answer in the environment. + """ from esphome.core import CORE - if not (CORE.is_esp32 and CORE.using_arduino): + if not CORE.is_configured: + # The runner subprocess. A half filled in CORE still counts as + # configured, so reading from it raises instead of landing here. + return os.environ.get(ESP32_ARDUINO_ENV) == "1" + return CORE.is_esp32 and CORE.using_arduino + + +def get_esp32_arduino_flash_error_help() -> str | None: + """Returns helpful message when ESP32 with Arduino runs out of flash space.""" + if not is_esp32_arduino_build(): return None from esphome.log import AnsiFore, color diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index c2db9b97ed..d3c6caf60b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass, field import functools @@ -253,7 +253,7 @@ class IncludeFile: if self._content is not _UNSET: return self._content if self.has_unresolved_expressions(): - from esphome.config_validation import Invalid + from voluptuous import Invalid raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" @@ -266,12 +266,135 @@ class IncludeFile: """Check if the filename contains substitution variables or Jinja expressions.""" return has_substitution_or_expression(str(self.file)) + def with_file(self, file: Path | str) -> IncludeFile: + """Clone this include with *file* as the filename.""" + return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader) + + +def _is_visible_path(rel: Path) -> bool: + """Report whether no component of *rel* is hidden (``..`` stays valid).""" + return all(part == ".." or _is_file_valid(part) for part in rel.parts) + + +def _glob_include_candidates(parent_dir: Path, pattern: str) -> list[Path]: + """ + Expand a candidate glob under *parent_dir*, keeping hidden files out. + + An un-globbable pattern (absolute, or one the filesystem rejects) is + skipped instead of crashing discovery. + """ + try: + found_paths = parent_dir.glob(pattern) + return [ + rel + for found in found_paths + if _is_visible_path(rel := found.relative_to(parent_dir)) + ] + except (NotImplementedError, ValueError) as err: + _LOGGER.debug("Cannot glob include pattern %r: %s", pattern, err) + return [] + except OSError as err: + _LOGGER.warning("I/O error globbing include pattern %r: %s", pattern, err) + return [] + + +def _candidate_include_paths(include: IncludeFile) -> list[Path]: + """Enumerate resolved files an expression-templated ``!include`` could select. + + Wildcard patterns from ``substitutions.include_candidate_patterns`` glob + under the including file's directory with hidden files excluded (like + ``!include_dir_*``); literal branch patterns are tried verbatim. Matches + still carrying expression markers or pointing back at the including file + are skipped. + """ + # Deferred import — the substitutions component imports this module. + from esphome.components.substitutions import include_candidate_patterns + + parent_dir = include.parent_file.parent + parent_resolved = include.parent_file.resolve() + candidates: list[Path] = [] + for pattern in include_candidate_patterns(str(include.file)): + if "*" in pattern: + matches = sorted(_glob_include_candidates(parent_dir, pattern)) + else: + matches = [Path(pattern)] + for match in matches: + if has_substitution_or_expression(str(match)): + continue + candidate = parent_dir / match + if not candidate.is_file(): + continue + resolved = candidate.resolve() + if resolved == parent_resolved: + continue + candidates.append(resolved) + return candidates + + +def _load_include_candidates( + include: IncludeFile, + *, + warn_on_unresolved: bool, + seen: set[int], + expanded_paths: set[Path], + keepalive: list[Any], +) -> None: + """Load every filesystem candidate for an unresolved ``IncludeFile``.""" + from voluptuous import Invalid + + log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug + candidates = _candidate_include_paths(include) + if not candidates: + log( + "Cannot resolve !include %s (referenced from %s) with substitutions in path", + include.file, + include.parent_file, + ) + return + _LOGGER.debug( + "Expanding !include %s (referenced from %s) to %d candidate file(s)", + include.file, + include.parent_file, + len(candidates), + ) + for candidate in candidates: + if candidate in expanded_paths: + continue + expanded_paths.add(candidate) + try: + loaded = include.with_file(candidate).load() + except (EsphomeError, Invalid) as err: + # Unlike an unresolved pattern (expected during the discovery + # re-parse), a matched on-disk candidate that fails to load is a + # genuine user error; warn in every mode. The file itself is + # still tracked (the load listener fires before parsing), only + # its nested includes go undiscovered. + _LOGGER.warning( + "Failed to load candidate %s for !include %s: %s", + candidate, + include.file, + err, + ) + continue + # The throwaway IncludeFile is this tree's only owner; keep the tree + # alive so ids recorded in ``seen`` stay unique for the traversal. + keepalive.append(loaded) + force_load_include_files( + loaded, + warn_on_unresolved=warn_on_unresolved, + _seen=seen, + _expanded_paths=expanded_paths, + _keepalive=keepalive, + ) + def force_load_include_files( obj: Any, *, warn_on_unresolved: bool = True, _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, ) -> None: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. @@ -282,29 +405,43 @@ def force_load_include_files( loader fires and records every reachable file. ``IncludeFile`` instances whose path contains unresolved substitution - variables cannot be loaded. By default a warning is logged for each one; - pass ``warn_on_unresolved=False`` (used by discovery paths that run on a - fresh re-parse where substitutions haven't been applied yet) to demote it - to a debug log. + variables or Jinja expressions are expanded against the filesystem and + every existing candidate file is loaded, so bundles ship all branches the + expression could select. By default a warning is logged when no candidate + exists; pass ``warn_on_unresolved=False`` (used by discovery paths that + run on a fresh re-parse where substitutions haven't been applied yet) to + demote it to a debug log. """ + from voluptuous import Invalid + if _seen is None: _seen = set() + if _expanded_paths is None: + _expanded_paths = set() + if _keepalive is None: + # ``_seen`` tracks ids, which is only safe while every traversed + # object stays alive; candidate trees are otherwise freed between + # loop iterations and CPython recycles their addresses, making a + # fresh tree look already seen. Discovery is a one-shot operation, + # so holding the parsed trees costs nothing. + _keepalive = [] if isinstance(obj, IncludeFile): if id(obj) in _seen: return _seen.add(id(obj)) if obj.has_unresolved_expressions(): - log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug - log( - "Cannot resolve !include %s (referenced from %s) with substitutions in path", - obj.file, - obj.parent_file, + _load_include_candidates( + obj, + warn_on_unresolved=warn_on_unresolved, + seen=_seen, + expanded_paths=_expanded_paths, + keepalive=_keepalive, ) return try: loaded = obj.load() - except EsphomeError as err: + except (EsphomeError, Invalid) as err: _LOGGER.warning( "Failed to load !include %s (referenced from %s): %s", obj.file, @@ -313,7 +450,11 @@ def force_load_include_files( ) return force_load_include_files( - loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen + loaded, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) elif isinstance(obj, dict): if id(obj) in _seen: @@ -321,7 +462,11 @@ def force_load_include_files( _seen.add(id(obj)) for value in obj.values(): force_load_include_files( - value, warn_on_unresolved=warn_on_unresolved, _seen=_seen + value, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: @@ -329,7 +474,11 @@ def force_load_include_files( _seen.add(id(obj)) for item in obj: force_load_include_files( - item, warn_on_unresolved=warn_on_unresolved, _seen=_seen + item, + warn_on_unresolved=warn_on_unresolved, + _seen=_seen, + _expanded_paths=_expanded_paths, + _keepalive=_keepalive, ) @@ -402,6 +551,13 @@ def _add_data_ref(fn): # Let generator finish for _ in generator: pass + # Fast mode keeps this per-node attribute check instead of a second + # constructor table: measured, fast mode already parses within ~8% + # of a raw CSafeLoader, so a parallel table isn't worth the + # duplication (and undecorated constructors return generators with + # different resolution ordering). + if not loader.track_document_range: + return res res = make_data_base(res) if isinstance(res, ESPHomeDataBase): res.from_node(node) @@ -441,14 +597,19 @@ def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) - class ESPHomeLoaderMixin: - """Loader class that keeps track of line numbers.""" + """Loader that tracks line numbers unless track_document_range is off.""" def __init__( - self, name: Path, yaml_loader: Callable[[Path], dict[str, Any]] + self, + name: Path, + yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: - """Initialize the loader.""" + """Initialize the loader. See load_yaml for track_document_range.""" self.name = name self.yaml_loader = yaml_loader + self.track_document_range = track_document_range @_add_data_ref def construct_yaml_int(self, node): @@ -511,8 +672,10 @@ class ESPHomeLoaderMixin: f'Invalid key "{key}" (not hashable)', key_node.start_mark ) from None - key = make_data_base(str(key)) - key.from_node(key_node) + key = str(key) + if self.track_document_range: + key = make_data_base(key) + key.from_node(key_node) # Check if it is a duplicate key if key in seen_keys: @@ -647,12 +810,12 @@ class ESPHomeLoaderMixin: @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) return [self.yaml_loader(f) for f in files] @_add_data_ref def construct_include_dir_merge_list(self, node: yaml.Node) -> list[dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) merged_list = [] for fname in files: loaded_yaml = self.yaml_loader(fname) @@ -664,7 +827,7 @@ class ESPHomeLoaderMixin: def construct_include_dir_named( self, node: yaml.Node ) -> OrderedDict[str, dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) mapping = OrderedDict() for fname in files: filename = fname.stem @@ -675,7 +838,7 @@ class ESPHomeLoaderMixin: def construct_include_dir_merge_named( self, node: yaml.Node ) -> OrderedDict[str, dict[str, Any]]: - files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) + files = filter_yaml_files(find_files(self._rel_path(node.value), "*.yaml")) mapping = OrderedDict() for fname in files: loaded_yaml = self.yaml_loader(fname) @@ -708,29 +871,37 @@ class ESPHomeLoaderMixin: class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader): - """Loader class that keeps track of line numbers.""" + """C-accelerated loader; see ESPHomeLoaderMixin.""" def __init__( self, stream: TextIOBase | BytesIO, name: Path, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: FastestAvailableSafeLoader.__init__(self, stream) - ESPHomeLoaderMixin.__init__(self, name, yaml_loader) + ESPHomeLoaderMixin.__init__( + self, name, yaml_loader, track_document_range=track_document_range + ) class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader): - """Loader class that keeps track of line numbers.""" + """Pure-Python loader with readable errors; see ESPHomeLoaderMixin.""" def __init__( self, stream: TextIOBase | BytesIO, name: Path, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> None: PurePythonLoader.__init__(self, stream) - ESPHomeLoaderMixin.__init__(self, name, yaml_loader) + ESPHomeLoaderMixin.__init__( + self, name, yaml_loader, track_document_range=track_document_range + ) for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): @@ -758,20 +929,31 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): _loader.add_constructor("!remove", _loader.construct_remove) -def load_yaml(fname: Path, clear_secrets: bool = True) -> Any: +def load_yaml( + fname: Path, clear_secrets: bool = True, *, track_document_range: bool = True +) -> Any: + """Load a YAML file. + + track_document_range=False skips wrapping every node in an + ESPHomeDataBase subclass carrying its source range. That metadata + serves validation error messages and lambda source locations in + generated code; callers that neither validate nor generate code (the + upload/logs fast path re-reading the validated config cache) can skip + it, roughly halving parse time. + """ if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - return _load_yaml_internal(fname) + return _load_yaml_internal(fname, track_document_range=track_document_range) -def _load_yaml_internal(fname: Path) -> Any: +def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any: """Load a YAML file.""" for listener in _load_listeners: listener(fname) try: with fname.open(encoding="utf-8") as f_handle: - res = parse_yaml(fname, f_handle) + res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err # Top-level !include returns a deferred IncludeFile; resolve it so @@ -781,13 +963,32 @@ def _load_yaml_internal(fname: Path) -> Any: return res -def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any: +_FAST_YAML_LOADER = functools.partial(_load_yaml_internal, track_document_range=False) + + +def parse_yaml( + file_name: Path, + file_handle: TextIOWrapper, + yaml_loader=None, + *, + track_document_range: bool = True, +) -> Any: """Parse a YAML file.""" if yaml_loader is None: - yaml_loader = _load_yaml_internal + # Nested loads (!include, !secret, !include_dir_*) inherit the + # same tracking mode. + yaml_loader = _load_yaml_internal if track_document_range else _FAST_YAML_LOADER + elif not track_document_range: + # A caller-supplied loader would silently revert nested loads to + # tracked mode; reject the combination instead of half-applying it. + raise ValueError("track_document_range=False requires the default yaml_loader") try: return _load_yaml_internal_with_type( - ESPHomeLoader, file_name, file_handle, yaml_loader + ESPHomeLoader, + file_name, + file_handle, + yaml_loader, + track_document_range=track_document_range, ) except EsphomeError: # Loading failed, so we now load with the Python loader which has more @@ -795,7 +996,11 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> # Rewind the stream so we can try again file_handle.seek(0, 0) return _load_yaml_internal_with_type( - ESPHomePurePythonLoader, file_name, file_handle, yaml_loader + ESPHomePurePythonLoader, + file_name, + file_handle, + yaml_loader, + track_document_range=track_document_range, ) @@ -804,6 +1009,8 @@ def _load_yaml_internal_with_type( fname: Path, content: TextIOWrapper, yaml_loader: Callable[[Path], dict[str, Any]], + *, + track_document_range: bool, ) -> Any: """Load a YAML file. @@ -814,7 +1021,9 @@ def _load_yaml_internal_with_type( configuration. Frontmatter is ignored by config validation and code generation. """ - loader = loader_type(content, fname, yaml_loader) + loader = loader_type( + content, fname, yaml_loader, track_document_range=track_document_range + ) try: documents: list[Any] = [] while loader.check_data(): @@ -871,8 +1080,8 @@ def _is_file_valid(name: str) -> bool: return not name.startswith(".") -def _find_files(directory: Path, pattern): - """Recursively load files in a directory.""" +def find_files(directory: Path, pattern: str) -> Iterator[Path]: + """Recursively find files in a directory matching *pattern*, skipping hidden entries.""" for root, dirs, files in os.walk(directory): dirs[:] = [d for d in dirs if _is_file_valid(d)] for f in files: @@ -1140,6 +1349,8 @@ class ESPHomeDumper(yaml.SafeDumper): return super().increase_indent(flow, False) +# Mirrored by compiled_config._json_default: a new representer that keeps a +# type round-trippable (like Lambda's) needs a sentinel there too. ESPHomeDumper.add_multi_representer( dict, lambda dumper, value: dumper.represent_mapping("tag:yaml.org,2002:map", value) ) diff --git a/platformio.ini b/platformio.ini index 2ab90e63ad..bf3b0685f8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -44,8 +44,9 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} + https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea esphome/noise-c@0.1.11 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv + improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image @@ -80,7 +81,6 @@ lib_deps = Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 - dudanov/MideaUART@1.1.9 ; midea tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common.build_flags} @@ -141,9 +141,9 @@ 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.39/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.10/esp32-core-3.3.10.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -178,7 +178,7 @@ extra_scripts = ; 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.39/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz @@ -203,10 +203,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 platform_packages = - ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip + ; The framework-arduinopico package is no longer published to the PlatformIO + ; registry, so install the framework straight from the GitHub release + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip framework = arduino lib_deps = @@ -214,8 +215,19 @@ lib_deps = ${common:idf-component-libs.lib_deps} ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base + WiFi ; wifi (arduino-pico built-in) + lwIP_CYW43 ; wifi (arduino-pico built-in, WiFi dependency) + HTTPClient ; http_request (arduino-pico built-in) + Updater ; ota (arduino-pico built-in) + MD5Builder ; md5 (arduino-pico built-in) + LEAmDNS ; mdns (arduino-pico built-in) + lwIP_w5500 ; ethernet (arduino-pico built-in) + lwIP-Ethernet ; ethernet (arduino-pico built-in, lwIP_w5500/lwIP_CYW43 dependency) + WebServer ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) + http-parser ; web_server_base (arduino-pico built-in, ESPAsyncWebServer dependency) build_flags = ${common:arduino.build_flags} + -DUSE_RP2 -DUSE_RP2040 -DUSE_RP2040_FRAMEWORK_ARDUINO build_unflags = @@ -228,9 +240,17 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = + ${common.lib_deps_base} ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard + esphome/noise-c@0.1.11 ; api + ESP32Async/AsyncTCP@3.4.5 ; async_tcp + DNSServer ; captive_portal + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt + improv/Improv@1.2.6 ; improv_serial + kikuchan98/pngle@1.1.0 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -510,6 +530,17 @@ build_flags = build_unflags = ${common.build_unflags} +[env:rp2-tidy] +extends = common:rp2040-arduino +; The W variant so the cyw43 / WiFi library paths are part of the idedata. +board = rpipicow +build_flags = + ${common:rp2040-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH +build_unflags = + ${common.build_unflags} + ;;;;;;;; LibreTiny ;;;;;;;; [env:bk72xx-arduino] @@ -556,6 +587,54 @@ build_flags = build_unflags = ${common.build_unflags} +[env:bk72xx-tidy] +extends = common:libretiny-arduino +board = generic-bk7231n-qfn32-tuya +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_BK72XX + -DUSE_LIBRETINY_VARIANT_BK7231N +build_unflags = + ${common.build_unflags} + +[env:ln882h-tidy] +extends = common:libretiny-arduino +board = generic-ln882h +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_LN882X + -DUSE_LIBRETINY_VARIANT_LN882H + ; the SDK lwip port dir is missing from pio idedata; lwipopts.h include_next needs it + -I${platformio.packages_dir}/framework-lightning-ln882h/components/net/lwip-2.1.3/src/port/ln_osal/include +build_unflags = + ${common.build_unflags} + +[env:rtl87xxb-tidy] +extends = common:libretiny-arduino +board = generic-rtl8710bn-2mb-788k +; mirror the libretiny codegen pin: RTL8710B needs 8.2.3+ for task notifications +custom_versions.freertos = 8.2.3 +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8710B +build_unflags = + ${common.build_unflags} + +[env:rtl87xxc-tidy] +extends = common:libretiny-arduino +board = generic-rtl8720cf-2mb-992k +build_flags = + ${common:libretiny-arduino.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_RTL87XX + -DUSE_LIBRETINY_VARIANT_RTL8720C +build_unflags = + ${common.build_unflags} + ;;;;;;;; Host ;;;;;;;; [env:host] diff --git a/pyproject.toml b/pyproject.toml index f38633b4ae..afa6208cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] @@ -56,6 +56,9 @@ include = ["esphome*"] testpaths = [ "tests", ] +# Prepend the repo root so in-process esphome imports resolve to THIS tree, +# not wherever the venv's editable install points (e.g. another git worktree). +pythonpath = ["."] addopts = [ "--cov=esphome", "--cov-branch", @@ -110,6 +113,10 @@ target-version = "py312" exclude = ['generated'] [tool.ruff.lint] +# Preview mode is scoped: with explicit-preview-rules only rules named in +# select run in preview, prefixes like "PL" keep their stable set. +preview = true +explicit-preview-rules = true select = [ "B", # flake8-bugbear "BLE", # flake8-blind-except @@ -131,6 +138,7 @@ select = [ "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint + "PLW1514", # require explicit encoding on text file I/O (Windows defaults to cp1252) "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes @@ -151,6 +159,7 @@ ignore = [ "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR0917", # Too many positional arguments ({c_pos} > {max_pos}) "PLW1641", # Object does not implement `__hash__` method "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target diff --git a/requirements.txt b/requirements.txt index 8ba908d8b4..9c231bd0fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,36 +1,37 @@ # cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. # Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. -cryptography==49.0.0; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==50.0.0; platform_system != "Darwin" or platform_machine != "x86_64" cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.2 # from time +tzdata>=2026.3 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.7.0 +aioesphomeapi==45.10.0 +aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.3.3 +resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.0 # native esp-idf toolchain global cache dir -filelock==3.32.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +platformdirs==4.11.1 # native esp-idf toolchain global cache dir +filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.7.0 +argcomplete>=3.7.2 diff --git a/requirements_dev.txt b/requirements_dev.txt index 7e66c7244d..f2cf855d6b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.7 +clang-tidy==22.1.8 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating diff --git a/requirements_test.txt b/requirements_test.txt index ebd93ea390..0905fe6be1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,8 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.20 # also change in .pre-commit-config.yaml when updating +ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -pre-commit +prek==0.4.12 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -52,6 +52,7 @@ COMMON_BUS_PATH = ( # the packages on the right as well PACKAGE_DEPENDENCIES = { "modbus": ["uart"], # modbus packages include uart packages + "modbus_server": ["uart"], # modbus_server packages include uart packages # Add more package dependencies here as needed } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 451cd9ac1f..f4eff4a254 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2401,7 +2401,10 @@ def get_varint64_ifdef( # At least one 64-bit varint field is unconditional, so the guard must be unconditional. return True, None ifdefs.discard(None) - return True, ifdefs.pop() if len(ifdefs) == 1 else None + # Several guards: the define is needed under any of them, so emit the union. + # Falling back to unconditional would pull 64-bit varint support into builds + # that have none of them. + return True, " || ".join(sorted(ifdefs)) def build_enum_type(desc, enum_ifdef_map) -> tuple[str, str, str]: diff --git a/script/build_codeowners.py b/script/build_codeowners.py index 10ca1295b7..be8b445542 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -61,6 +61,13 @@ for path in components_dir.iterdir(): codeowners[f"esphome/components/{name}/*"].extend(comp.codeowners) for platform_path in path.iterdir(): + if platform_path.name == "__init__.py": + # `import pkg.__init__` is valid but distinct from `import pkg`: it re-executes + # the component's __init__.py as a second, separate module. That's harmless for + # components whose top-level code is idempotent, but not guaranteed in general + # (e.g. code that registers into a global registry with a duplicate check), so + # never treat __init__.py itself as a platform candidate. + continue platform_name = platform_path.stem platform = get_platform(platform_name, name) if platform is None: diff --git a/script/build_language_schema.py b/script/build_language_schema.py index f6dcf00851..2b64cb0256 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path): else: converted["key"] = "String" key_string_match = re.search( - r"", str(k), re.IGNORECASE + r"", str(k), re.IGNORECASE ) if key_string_match: converted["key_type"] = key_string_match.group(1) else: converted["key_type"] = str(k) + # A marker-wrapped callable key (e.g. script.execute's + # ``cv.Optional(validate_parameter_name)``) is a wildcard matcher; + # ``str(marker)`` is the function repr, whose heap address would + # churn the dump every build. Normalize like the bare-callable + # branch above: record the validator name in ``key_type`` and file + # the config var under ``string``. + key_name = str(k) + if isinstance(k, vol.Marker) and callable(k.schema): + key_string_match = re.search( + r"", key_name, re.IGNORECASE + ) + result["key_type"] = ( + key_string_match.group(1) if key_string_match else key_name + ) + key_name = "string" + # ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as # a property that returns ``vol.UNDEFINED`` when the gating # component isn't loaded — and at schema-generation time @@ -1220,7 +1236,7 @@ def convert_keys(converted, schema, path): for base_k, base_v in get_overridden_config(k, converted).items(): if base_k in result and base_v == result[base_k]: result.pop(base_k) - converted["schema"][S_CONFIG_VARS][str(k)] = result + converted["schema"][S_CONFIG_VARS][key_name] = result if "key" in converted and converted["key"] == "String": config_vars = converted["schema"]["config_vars"] assert len(config_vars) == 1 diff --git a/script/check_import_time.py b/script/check_import_time.py index 0d5362c968..0f2b395902 100755 --- a/script/check_import_time.py +++ b/script/check_import_time.py @@ -194,7 +194,7 @@ def cmd_update(args: argparse.Namespace) -> int: def cmd_har_only(args: argparse.Namespace) -> int: - Path(args.har).write_text(run_waterfall(TARGET_MODULE)) + Path(args.har).write_text(run_waterfall(TARGET_MODULE), encoding="utf-8") print(f"Wrote waterfall HAR to {args.har}") return 0 diff --git a/script/ci-custom.py b/script/ci-custom.py index 4b16734ebe..2d2da20995 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -345,9 +345,11 @@ def lint_const_ordered(fname, content): ( mi, 1, - f"Constant {highlight(mline)} is not ordered, please make sure all " - f"constants are ordered. See line {mi} (should go to line {target}, " - f"{target_text})", + ( + f"Constant {highlight(mline)} is not ordered, please make sure all " + f"constants are ordered. See line {mi} (should go to line {target}, " + f"{target_text})" + ), ) ) return errs @@ -555,7 +557,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1015 +CONST_PY_MAX_CONF = 1017 @lint_content_check(include=["esphome/const.py"]) @@ -990,12 +992,14 @@ def lint_log_multiline_continuation(fname, content): ( lineno, col, - "Multi-line log message has a continuation line that does " - "not start with a space. The log viewer uses leading " - "whitespace to detect continuation lines and re-add the " - f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n" - "Either start the continuation with a space/indent, or " - "split into separate ESP_LOG* calls.", + ( + "Multi-line log message has a continuation line that does " + "not start with a space. The log viewer uses leading " + "whitespace to detect continuation lines and re-add the " + f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n" + "Either start the continuation with a space/indent, or " + "split into separate ESP_LOG* calls." + ), ) ) return errs @@ -1073,10 +1077,12 @@ def lint_test_package_key_matches_bus(fname, content): ( lineno, 1, - f"Package key {highlight(pkg_key)} does not match bus directory " - f"{highlight(bus_dir)}. The package key must match the directory " - f"name under tests/test_build_components/common/. " - f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.", + ( + f"Package key {highlight(pkg_key)} does not match bus directory " + f"{highlight(bus_dir)}. The package key must match the directory " + f"name under tests/test_build_components/common/. " + f"Change {highlight(pkg_key)} to {highlight(bus_dir)}." + ), ) ) return errs diff --git a/script/ci_check_test_fixture_list_form.py b/script/ci_check_test_fixture_list_form.py new file mode 100755 index 0000000000..6da1f8337d --- /dev/null +++ b/script/ci_check_test_fixture_list_form.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Fail when a test fixture writes a platform-list domain as a single dict. + +Component tests are merged and built in groups in CI (see +``script/merge_component_configs.py``). ESPHome's ``merge_config`` concatenates +two lists, but when one side is a dict it replaces the other side wholesale +(``esphome/config_helpers.py``). A domain such as ``one_wire:`` or ``ota:`` +written in single-dict form therefore deletes every entry other components +contributed to that domain before it in the merge, and is itself deleted by any +list that merges after it. The resulting failure only appears when the affected +components land in the same group -- usually a full component matrix run on an +unrelated PR long after the fixture was written (this is what broke the +dallas_temp tests when ds2484 was added, see #17868). + +This guard scans every fixture under ``tests/components/`` and rejects any +top-level domain written as a dict with a ``platform`` key. Such a domain is by +definition a platform list (single-dict form is only user-config sugar), so the +fix is always to write it as a one-element list: + + one_wire: + - platform: gpio + pin: 4 +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from esphome.core import EsphomeError # noqa: E402 +from script.analyze_component_buses import ISOLATED_COMPONENTS # noqa: E402 +from script.merge_component_configs import load_yaml_file # noqa: E402 + +# Resolved relative to this file (not the CWD) so the scan cannot silently cover +# nothing when run from a different directory. +ROOT_DIR = Path(__file__).resolve().parent.parent +TESTS_DIR = ROOT_DIR / "tests" / "components" + + +def main() -> int: + offenders: list[str] = [] + parse_errors: list[str] = [] + fixtures_scanned = 0 + + for fixture in sorted(TESTS_DIR.glob("*/*.yaml")): + # Isolated components are never merged with others, so dict form + # cannot clobber anyone there. + if fixture.parent.name in ISOLATED_COMPONENTS: + continue + try: + data = load_yaml_file(fixture) + except EsphomeError as err: + parse_errors.append(f"{fixture.relative_to(ROOT_DIR)}: {err}") + continue + fixtures_scanned += 1 + if not isinstance(data, dict): + continue + for key, value in data.items(): + if isinstance(value, dict) and "platform" in value: + offenders.append(f"{fixture.relative_to(ROOT_DIR)}: '{key}:'") + + if offenders: + print("Test fixtures with platform domains in single-dict form:\n") + for line in offenders: + print(f" - {line}") + print( + "\nWrite the domain as a one-element list ('- platform: ...') so " + "grouped CI builds can merge it with other components' entries; " + "in dict form it replaces or is replaced by their lists wholesale." + ) + + if parse_errors: + # A fixture we could not parse was never scanned, so the run is not a + # clean pass even if no offenders were found among the rest. + print( + f"\n{len(parse_errors)} test fixture(s) could not be parsed and " + "were not checked:" + ) + for line in parse_errors: + print(f" - {line}") + + if fixtures_scanned == 0: + # A scan that covered nothing is a false green -- the whole point of the + # guard is defeated. Fail loudly (wrong working directory or layout change). + print( + f"\nERROR: scanned 0 test fixtures under {TESTS_DIR}; " + "the guard covered nothing.", + file=sys.stderr, + ) + + if offenders or parse_errors or fixtures_scanned == 0: + return 1 + + print( + f"No single-dict platform domains found ({fixtures_scanned} fixtures scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index 20a737cdbf..2d74362169 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -33,6 +33,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position from esphome.analyze_memory import MemoryAnalyzer +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) from esphome.platformio.toolchain import IDEData from script.ci_helpers import write_github_output @@ -130,53 +135,31 @@ def run_detailed_analysis(build_dir: str) -> dict | None: print(f"Build directory not found: {build_dir}", file=sys.stderr) return None - # Find firmware.elf (or raw_firmware.elf for LibreTiny) - elf_path = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - # LibreTiny uses raw_firmware.elf - build_path / "raw_firmware.elf", - build_path / ".pioenvs" / build_path.name / "raw_firmware.elf", - ]: - if elf_candidate.exists(): - elf_path = str(elf_candidate) - break - + elf_path = find_elf_path(build_path) if not elf_path: - print( - f"firmware.elf/raw_firmware.elf not found in {build_dir}", file=sys.stderr - ) + print(f"No firmware ELF found in {build_dir}", file=sys.stderr) return None - # Find idedata.json - check multiple locations - device_name = build_path.name - idedata_candidates = [ - # In .pioenvs for test builds - build_path / ".pioenvs" / device_name / "idedata.json", - # In .esphome/idedata for regular builds - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - # Check parent directories for .esphome/idedata (for test_build_components) - build_path.parent.parent.parent / "idedata" / f"{device_name}.json", - ] - idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print( f"Warning: Failed to load idedata from {idedata_path}: {e}", file=sys.stderr, ) + else: + # Without idedata the analyzer falls back to whatever binutils are on + # PATH, which are the wrong architecture for a cross build, so say where + # we looked rather than let the results quietly get worse. + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) - analyzer = MemoryAnalyzer(elf_path, idedata=idedata) + analyzer = MemoryAnalyzer(str(elf_path), idedata=idedata) components = analyzer.analyze() # Convert to JSON-serializable format @@ -286,7 +269,7 @@ def main() -> int: if args.output_build_dir and build_dir: build_dir_path = Path(args.output_build_dir) build_dir_path.parent.mkdir(parents=True, exist_ok=True) - build_dir_path.write_text(build_dir) + build_dir_path.write_text(build_dir, encoding="utf-8") print(f"Wrote build directory to {args.output_build_dir}", file=sys.stderr) # Run detailed analysis if build directory available @@ -320,6 +303,19 @@ def main() -> int: else: print(f"{ram_bytes},{flash_bytes}") + # The build produced usable totals, so a missing detailed analysis means the + # build layout moved out from under this script rather than a broken build. + # Fail loudly: the comment would otherwise silently drop the component + # breakdown and the symbol tables, which is easy to miss for a long time. + if detailed_analysis is None: + print( + "::error::Detailed memory analysis unavailable even though the build " + f"succeeded (build directory: {build_dir or 'not detected'}). The PR " + "comment would be missing its component breakdown and symbol changes.", + file=sys.stderr, + ) + return 1 + return 0 diff --git a/script/clang-tidy b/script/clang-tidy index 7df46cb2d2..ad6c99d637 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -5,11 +5,13 @@ import os from pathlib import Path import queue import re +import shlex import shutil import subprocess import sys import tempfile import threading +from typing import Any import click import colorama @@ -29,7 +31,37 @@ from helpers import ( ) -def clang_options(idedata): +def gcc_multilib_directory(idedata: dict[str, Any]) -> str | None: + """The toolchain's active multilib subdirectory (e.g. "thumb"), if any. + + PlatformIO's idedata lists the generic toolchain include directories; GCC + resolves the active multilib subdirectory internally while searching them. + Toolchains without a default multilib (pico-quick-toolchain 5.0.0+) ship + the libstdc++ target config (bits/c++config.h) only inside the multilib + subdirectories, so clang needs the resolved directory spelled out. + """ + machine_flags = [f for f in idedata["cxx_flags"] if f.startswith("-m")] + cmd = [idedata["cxx_path"], *machine_flags, "-print-multi-directory"] + try: + multilib = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError) as err: + # Without the multilib dir, toolchains lacking a default multilib fail + # later with "bits/c++config.h not found"; point at the probe instead. + stderr = getattr(err, "stderr", "") or "" + print( + f"WARNING: multilib probe failed ({shlex.join(cmd)}): {err} {stderr}".strip(), + file=sys.stderr, + ) + return None + return None if multilib in ("", ".") else multilib + + +def clang_options(idedata, environment): cmd = [] # extract target architecture from triplet in g++ filename @@ -74,6 +106,8 @@ def clang_options(idedata): "-fno-jump-tables", "-fno-shrink-wrap", "-mno-target-align", + # GCC-only flag emitted by the LibreTiny build + "-mthumb-interwork", ) if "zephyr" in triplet: @@ -95,30 +129,59 @@ def clang_options(idedata): [ # disable built-in include directories from the host "-nostdinc", - # replace pgmspace.h, as it uses GNU extensions clang doesn't support - # https://github.com/earlephilhower/newlib-xtensa/pull/18 - "-D_PGMSPACE_H_", - "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", - "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", - "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", - "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", - "-DPROGMEM=", - "-DPGM_P=const char *", - "-DPSTR(s)=(s)", - # this next one is also needed with upstream pgmspace.h - # suppress warning about identifier naming in expansion of this macro - "-DPSTRN(s, n)=(s)", - # suppress warning about attribute cannot be applied to type - # https://github.com/esp8266/Arduino/pull/8258 - "-Ddeprecated(x)=", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers "-D_GLIBCXX_HAVE_TLS", + # suppress warning about attribute cannot be applied to type + # https://github.com/esp8266/Arduino/pull/8258 + # also keeps deprecation diagnostics consistent across environments + "-Ddeprecated(x)=", ] ) + if environment.startswith("rp2"): + # clang's ARM backend doesn't know GCC's long_call attribute (IRAM_ATTR) + cmd.append("-Wno-unknown-attributes") + elif environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + cmd.extend( + [ + # GCC on arm-none-eabi types (u)int32_t as (unsigned) long; clang + # types it as (unsigned) int, clashing with LibreTiny's lwip + # port typedefs. Match the GCC type model. + "-U__UINT32_TYPE__", + "-D__UINT32_TYPE__=long unsigned int", + "-U__INT32_TYPE__", + "-D__INT32_TYPE__=long int", + # newlib's machine/endian.h macroizes __bswap16 into + # __builtin_bswap16; the beken BDK then defines __bswap16 as a + # function, which GCC tolerates as a builtin redeclaration but + # clang rejects + "-D__MACHINE_ENDIAN_H__", + ] + ) + else: + # replace pgmspace.h, as it uses GNU extensions clang doesn't support + # https://github.com/earlephilhower/newlib-xtensa/pull/18 + # arduino-pico ships clang-parseable pgmspace inline functions, so the + # replacements are skipped there (they clash with those definitions). + cmd.extend( + [ + "-D_PGMSPACE_H_", + "-Dpgm_read_byte(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_byte_near(s)=(*(const uint8_t *)(s))", + "-Dpgm_read_word(s)=(*(const uint16_t *)(s))", + "-Dpgm_read_dword(s)=(*(const uint32_t *)(s))", + "-Dpgm_read_ptr(s)=(*(const void *const *)(s))", + "-DPROGMEM=", + "-DPGM_P=const char *", + "-DPSTR(s)=(s)", + # this next one is also needed with upstream pgmspace.h + # suppress warning about identifier naming in expansion of this macro + "-DPSTRN(s, n)=(s)", + ] + ) + # Copy compiler flags, dropping: ones clang doesn't understand; -Werror* # (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror # would bypass the -clang-diagnostic-* suppressions); and -std= (the native @@ -142,15 +205,42 @@ def clang_options(idedata): ) cmd.append("-std=gnu++20") - # defines - cmd.extend(f"-D{define}" for define in idedata["defines"]) + if environment.startswith(("bk72xx", "ln882h", "rtl87xx")): + # LibreTiny leaves function-like macro values unparenthesized + # (bugprone-macro-parentheses); its SDK-internal FAL_PART_TABLE macro trips the same + # check. Assumes define values are always expressions (never type- or char-literal), + # which holds for the current LibreTiny idedata. + def sanitize_define(define): + name, sep, value = define.partition("=") + if ( + sep + and value + and not value.startswith(("(", '"')) + and ("(" in name or not re.fullmatch(r"[\w.]+", value)) + ): + value = f"({value})" + return f"-D{name}{sep}{value}" + + # FAL_PART_TABLE and the delay() remap are library-scope LibreTiny flags that the real + # build never applies to esphome sources. Strip LibreTiny's shell quoting from define + # names first so the skip list matches regardless of which names it happens to quote. + for define in idedata["defines"]: + define = define.replace("'", "") + if define.startswith(("FAL_PART_TABLE", "delay(")): + continue + cmd.append(sanitize_define(define)) + else: + cmd.extend(f"-D{define}" for define in idedata["defines"]) # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + multilib = gcc_multilib_directory(idedata) toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: + if multilib and (multilib_dir := Path(directory) / multilib).is_dir(): + toolchain_includes.extend(["-isystem", str(multilib_dir)]) toolchain_includes.extend(["-isystem", directory]) # library include directories, using -isystem to suppress their errors @@ -207,6 +297,16 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): if sys.stdout.isatty(): invocation.append("--use-color") + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): + # MMIO peripheral access on these bare-metal platforms is all + # fixed-address. + # bugprone-pointer-arithmetic-on-polymorphic-object (and its + # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher + # recursion on lvgl_esphome.h under the RP2 defines. + invocation.append( + "--checks=-clang-analyzer-core.FixedAddressDereference," + "-bugprone-pointer-arithmetic-on-polymorphic-object,-cert-ctr56-cpp" + ) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") invocation.append(str(Path(path).resolve())) invocation.append("--") @@ -351,7 +451,7 @@ def main(): # Load idedata and options only if we have files to check idedata = load_idedata(args.environment) - options = clang_options(idedata) + options = clang_options(idedata, args.environment) tmpdir = None if args.fix: @@ -418,7 +518,9 @@ def main(): print("Error applying fixes.\n", file=sys.stderr) raise - return len(failed_files) + # Cap at 255: shells truncate exit codes to one byte, so 256 failures + # would otherwise report success + return min(len(failed_files), 255) if __name__ == "__main__": diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 57ca90711c..f4fd5a4dff 100644 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -18,6 +18,7 @@ from pathlib import Path # Root-relative paths whose contents affect clang-tidy results. CLANG_TIDY_GLOBAL_FILES = ( ".clang-tidy", + "script/clang-tidy", "platformio.ini", "requirements_dev.txt", "esphome/idf_component.yml", diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 061485c76c..e4d002975c 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -23,7 +23,7 @@ what files have changed. It outputs JSON with the following structure: } The CI workflow uses this information to: -- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci; +- Gate the unconditional jobs (ci-custom, pytest, lint-format) via core_ci; false when a pull_request only touches CI-irrelevant meta paths (other workflow files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**) so workflow-only PRs satisfy the required CI Status check without running the @@ -63,6 +63,7 @@ from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, PYTHON_FILE_EXTENSIONS, + base_python_changed, changed_files, core_changed, filter_component_and_test_cpp_files, @@ -657,16 +658,20 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( def should_run_benchmarks(branch: str | None = None) -> bool: - """Determine if C++ benchmarks should run based on changed files. + """Determine if benchmarks (C++ and Python) should run based on changed files. Benchmarks run when any of the following conditions are met: - 1. Core C++ files changed (esphome/core/*) - 2. The host platform changed (esphome/components/host/*) — benchmarks + 1. Core files changed (esphome/core/*, C++ or Python) + 2. Top-level Python files changed (esphome/*.py and esphome/*.pyi) — + the Python benchmarks exercise config loading (config.py, + yaml_util.py, ...), so a slowdown there is invisible unless the + benchmarks job runs + 3. 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, + 4. A directly changed component has benchmark files (no dependency expansion) + 5. 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. @@ -683,6 +688,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Top-level esphome/*.py modules are what the Python benchmarks in + # tests/benchmarks/python/ exercise + if base_python_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 @@ -708,7 +718,7 @@ def should_run_benchmarks(branch: str | None = None) -> bool: # Files / path patterns whose changes alone don't warrant running the -# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`). +# unconditional CI jobs (`ci-custom`, `pytest`, `lint-format`). # Single source of truth for what we treat as "CI-irrelevant" on # pull_request events; ci.yml used to encode this in its own # `pull_request.paths` filter, but that hid the required `CI Status` @@ -752,7 +762,7 @@ def _is_ci_irrelevant_path(path: str) -> bool: def should_run_core_ci(branch: str | None = None) -> bool: - """Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run. + """Determine if the unconditional CI jobs (ci-custom/pytest/lint-format) should run. Returns False only when every changed file is in the CI-irrelevant set above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we @@ -1177,7 +1187,7 @@ def main() -> None: # Determine what should run # core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest, - # pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release + # lint-format). Non-pull_request events (push to dev/beta/release # and merge_group) always run them so behavior like venv-cache saves on # push to dev is preserved. event_name = os.environ.get("GITHUB_EVENT_NAME", "") diff --git a/script/helpers.py b/script/helpers.py index 0086a00e85..7cc001d92f 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -421,7 +421,10 @@ def _get_github_event_data() -> dict | None: """ github_event_path = os.environ.get("GITHUB_EVENT_PATH") if github_event_path and Path(github_event_path).exists(): - with Path(github_event_path).open() as f: + # The event payload is UTF-8 JSON; without an explicit encoding + # Windows decodes it as cp1252 and any non ASCII byte (an ellipsis in + # a commit title is enough) raises UnicodeDecodeError. + with Path(github_event_path).open(encoding="utf-8") as f: return json.load(f) return None @@ -1377,6 +1380,27 @@ def core_changed(files: list[str]) -> bool: ) +def base_python_changed(files: list[str]) -> bool: + """Check if any Python file directly in esphome/ has changed. + + Matches top-level modules and stubs (.py and .pyi) like esphome/config.py + and esphome/yaml_util.py but not files in subdirectories such as + esphome/components/ or esphome/dashboard/. + + Args: + files: List of file paths to check + + Returns: + True if any top-level esphome Python file has changed + """ + return any( + f.startswith("esphome/") + and f.endswith(PYTHON_FILE_EXTENSIONS) + and "/" not in f.removeprefix("esphome/") + for f in files + ) + + def get_cpp_changed_components(files: list[str]) -> list[str]: """Get components that have changed C++ files or tests. diff --git a/script/import_time_budget.json b/script/import_time_budget.json index e810817507..cda426bb3e 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 20, - "cumulative_us": 95000 + "cumulative_us": 37200 } diff --git a/script/setup b/script/setup index 709eaee0f3..5dfc0efe5d 100755 --- a/script/setup +++ b/script/setup @@ -25,7 +25,11 @@ fi uv pip install setuptools wheel uv pip install -e ".[dev,test]" --config-settings editable_mode=compat -pre-commit install +# --overwrite replaces any hook already in place. Without it, prek finds a +# previously installed pre-commit hook, moves it aside to +# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would +# run both tools. +prek install --overwrite mkdir -p .temp diff --git a/script/setup.bat b/script/setup.bat index 003ea31b36..809d05ae93 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -17,7 +17,11 @@ pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.tx pip3 install setuptools wheel pip3 install -e ".[dev,test]" --config-settings editable_mode=compat -pre-commit install +rem --overwrite replaces any hook already in place. Without it, prek finds a +rem previously installed pre-commit hook, moves it aside to +rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would +rem run both tools. +prek install --overwrite echo . echo . diff --git a/script/setup_codspeed_lib.py b/script/setup_codspeed_lib.py index 4f5d1bff24..9ddfee27cc 100755 --- a/script/setup_codspeed_lib.py +++ b/script/setup_codspeed_lib.py @@ -84,7 +84,7 @@ def _read_codspeed_version(cmake_path: Path) -> str: """Extract CODSPEED_VERSION from core/CMakeLists.txt.""" if not cmake_path.exists(): return "0.0.0" - for line in cmake_path.read_text().splitlines(): + for line in cmake_path.read_text(encoding="utf-8").splitlines(): if line.startswith("set(CODSPEED_VERSION"): return line.split()[1].rstrip(")") return "0.0.0" diff --git a/script/test_build_components.py b/script/test_build_components.py index ce2a35add3..ddd8a6a67d 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -88,6 +88,38 @@ def show_disk_space_if_ci(esphome_command: str) -> None: sys.stdout.flush() +def start_log_group(title: str) -> None: + """Begin a collapsible log group in the GitHub Actions log viewer. + + Everything printed until the matching :func:`end_log_group` is folded away + by default, so the full ``esphome config``/``compile`` dump for one + configuration no longer pushes the pass/fail result thousands of lines down + the log. Outside CI this is a no-op so local runs stay plain. + + Args: + title: Text shown on the (collapsed) group header line. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + # Flush so the marker is ordered correctly relative to the child process + # output that follows (the subprocess writes straight to our stdout). + sys.stdout.flush() + print(f"::group::{title}") + sys.stdout.flush() + + +def end_log_group() -> None: + """Close the collapsible log group opened by :func:`start_log_group`. + + Outside CI this is a no-op. + """ + if not os.environ.get("GITHUB_ACTIONS"): + return + sys.stdout.flush() + print("::endgroup::") + sys.stdout.flush() + + def find_component_tests( components_dir: Path, component_pattern: str = "*", @@ -335,7 +367,7 @@ def run_esphome_test( output_file = build_dir / f"{component}.{test_name}.{platform_with_version}.yaml" # Copy base file and substitute component test file reference - base_content = base_file.read_text() + base_content = base_file.read_text(encoding="utf-8") # Get relative path from build dir to test file repo_root = Path(__file__).parent.parent component_test_ref = f"../../{test_file.relative_to(repo_root / 'tests')}" @@ -383,54 +415,48 @@ def run_esphome_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command - print(f"> [{component}] [{test_name}] [{platform_with_version}]") + # Run command inside a collapsible CI log group so the full esphome output + # for this configuration can be folded away by default. + group_title = f"[{component}] [{test_name}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") if use_testing_mode: print(" (using --testing-mode)") start_time = time.time() test_id = f"{component}.{test_name}.{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=[component], - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=[component], + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_test( @@ -498,7 +524,7 @@ def run_grouped_test( # Create test file that includes merged config output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml" - base_content = base_file.read_text() + base_content = base_file.read_text(encoding="utf-8") merged_ref = merged_config_file.name output_content = base_content.replace("$component_test_file", merged_ref) output_file.write_text(output_content) @@ -534,54 +560,48 @@ def run_grouped_test( # Build command string for display/logging cmd_str = " ".join(cmd) - # Run command + # Run command inside a collapsible CI log group so the full esphome output + # for this grouped configuration can be folded away by default. components_str = ", ".join(components) - print(f"> [GROUPED: {components_str}] [{platform_with_version}]") + group_title = f"[GROUPED: {components_str}] [{platform_with_version}]" + start_log_group(group_title) + print(f"> {group_title}") print(" (using --testing-mode)") start_time = time.time() test_id = f"GROUPED[{','.join(components)}].{platform_with_version}" + # Always close the group, even if the subprocess or disk-space reporting + # raises, so later output is never folded into the wrong CI log section. try: result = subprocess.run(cmd, check=False) - success = result.returncode == 0 - duration = time.time() - start_time - # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) + finally: + end_log_group() - if not success and not continue_on_fail: - # Print command immediately for failed tests - print(f"\n{'=' * 80}") - print("FAILED - Command to reproduce:") - print(f"{'=' * 80}") - print(cmd_str) - print() - raise subprocess.CalledProcessError(result.returncode, cmd) + success = result.returncode == 0 + duration = time.time() - start_time - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=success, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) - except subprocess.CalledProcessError: - duration = time.time() - start_time - # Re-raise if we're not continuing on fail - if not continue_on_fail: - raise - return TestResult( - test_id=test_id, - components=components, - platform=platform_with_version, - success=False, - duration=duration, - command=cmd_str, - test_type=esphome_command, - ) + if not success and not continue_on_fail: + # Print command immediately for failed tests. The group is already + # closed, so the failure and reproduce command stay visible. + print(f"\n{'=' * 80}") + print("FAILED - Command to reproduce:") + print(f"{'=' * 80}") + print(cmd_str) + print() + raise subprocess.CalledProcessError(result.returncode, cmd) + + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_component_tests( diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py index 5c8892f8d0..4d7821f704 100644 --- a/tests/benchmarks/python/test_compiled_config_bench.py +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None: Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, then ``update_storage_json`` writes both the StorageJSON sidecar and - the ``.validated.yaml`` compiled-config cache. + the ``.validated.json`` compiled-config cache. """ CORE.config_path = yaml_path config = read_config({}, skip_external_update=True) diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index bab27549e7..d8b068fb36 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -32,9 +32,10 @@ class SerialProxy { api::enums::SerialProxyPortType get_port_type() const { return {}; } api::APIConnection *get_api_connection() { return nullptr; } void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {} - void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {} - void write_from_client(const uint8_t *data, size_t len) {} - void set_modem_pins(uint32_t line_states) {} + void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, + uint32_t stop_bits, uint32_t data_size) {} + void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {} uint32_t get_modem_pins() const { return 0; } uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py new file mode 100644 index 0000000000..a6d86f0305 --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the ADC sensor component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_adc_temperature_pin_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`pin: TEMPERATURE` still works, but warns and points at internal_temperature.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_temperature->set_is_temperature();" in main_cpp + assert "`pin: TEMPERATURE` is deprecated" in caplog.text + assert "internal_temperature" in caplog.text + assert "2027.2.0" in caplog.text + + +def test_adc_regular_pin_is_not_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A normal ADC pin does not emit the temperature deprecation warning.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_voltage->set_is_temperature();" not in main_cpp + assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 diff --git a/tests/component_tests/adc/test_adc_sensor.yaml b/tests/component_tests/adc/test_adc_sensor.yaml new file mode 100644 index 0000000000..9455fef21b --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +rp2: + board: rpipicow + +sensor: + - platform: adc + pin: TEMPERATURE + name: Deprecated ADC Temperature + id: adc_temperature + + - platform: adc + pin: 26 + name: ADC Voltage + id: adc_voltage diff --git a/tests/unit_tests/components/api/__init__.py b/tests/component_tests/aqi/__init__.py similarity index 100% rename from tests/unit_tests/components/api/__init__.py rename to tests/component_tests/aqi/__init__.py diff --git a/tests/component_tests/aqi/test_aqi.py b/tests/component_tests/aqi/test_aqi.py new file mode 100644 index 0000000000..712c277508 --- /dev/null +++ b/tests/component_tests/aqi/test_aqi.py @@ -0,0 +1,35 @@ +"""Config-validation tests for the aqi sensor component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.aqi import CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE +from esphome.components.aqi.sensor import _validate_extended_range + + +def test_extended_range_rejected_with_caqi(): + """extended_range has no meaning for CAQI (no spec maximum) and must be rejected.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: True} + ) + + +def test_extended_range_rejected_with_caqi_even_when_false(): + """The option is not allowed at all with CAQI, regardless of its value.""" + with pytest.raises(Invalid, match="CAQI"): + _validate_extended_range( + {CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: False} + ) + + +def test_extended_range_allowed_with_aqi(): + """extended_range is valid for the US AQI calculation.""" + config = {CONF_CALCULATION_TYPE: "AQI", CONF_EXTENDED_RANGE: True} + assert _validate_extended_range(config) is config + + +def test_caqi_without_extended_range_ok(): + """CAQI is fine as long as extended_range is not set.""" + config = {CONF_CALCULATION_TYPE: "CAQI"} + assert _validate_extended_range(config) is config diff --git a/tests/component_tests/bk72xx_ble_tracker/__init__.py b/tests/component_tests/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..123d4296db --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -0,0 +1,45 @@ +esphome: + name: bk-trigger-codegen + on_boot: + then: + - bk72xx_ble_tracker.start_scan: + continuous: true + # Bare form: restores the configured scan_parameters mode — no + # set_continuous emitted (asserted in the codegen test). + - bk72xx_ble_tracker.start_scan: + - bk72xx_ble_tracker.stop_scan + +bk72xx: + board: cb2s + +bk72xx_ble_tracker: + scan_parameters: + continuous: false + active: false + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..777ae76b4f --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -0,0 +1,62 @@ +"""Codegen tests for the tracker automations. + +The shared trigger classes (ble_device_base/automation.h) are compiled by every +esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's +action templates and restart_scan_duration() — compiles on no CI board (the +bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and +validate fixtures never run to_code. The generated main is therefore the only +automated check on the setter spellings and the listener accounting.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions. + # Exactly one set_continuous: the bare start_scan emits none, pinning the + # restore-configured-mode divergence from esp32 against a future default=. + assert main_cpp.count("->set_continuous(") == 1 + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # scan_parameters continuous: false reaches the YAML-mode setter, not the + # runtime override. + assert "->set_configured_continuous(false)" in main_cpp + # active: false (non-default) flows through to the setter. + assert "->set_scan_active(false)" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/component_tests/ble_client/__init__.py b/tests/component_tests/ble_client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ble_client/test_validation.py b/tests/component_tests/ble_client/test_validation.py new file mode 100644 index 0000000000..1865812b74 --- /dev/null +++ b/tests/component_tests/ble_client/test_validation.py @@ -0,0 +1,86 @@ +"""Tests for ble_client config validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_client import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + notify_from_on_notify, + validate_descriptor_not_notify, +) +from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA +from esphome.components.ble_client.text_sensor import ( + CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA, +) +from esphome.const import ( + CONF_CHARACTERISTIC_UUID, + CONF_NAME, + CONF_NOTIFY, + CONF_SERVICE_UUID, + CONF_TYPE, +) +from esphome.types import ConfigType + +DESCRIPTOR_CONFIG: ConfigType = { + CONF_NAME: "test", + CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E", + CONF_DESCRIPTOR_UUID: "2902", +} + + +def test_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_on_notify_with_descriptor_uuid_rejected() -> None: + config: ConfigType = { + CONF_NOTIFY: False, + CONF_ON_NOTIFY: [{}], + CONF_DESCRIPTOR_UUID: "2902", + } + with pytest.raises(cv.Invalid, match="cannot send notifications"): + validate_descriptor_not_notify(config) + + +def test_descriptor_uuid_without_notify_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"} + assert validate_descriptor_not_notify(config) is config + + +def test_notify_without_descriptor_uuid_allowed() -> None: + config: ConfigType = {CONF_NOTIFY: True} + assert validate_descriptor_not_notify(config) is config + + +def test_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + SENSOR_SCHEMA(config) + + +def test_text_sensor_schema_rejects_notify_with_descriptor() -> None: + config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True} + with pytest.raises(cv.Invalid, match="cannot send notifications"): + TEXT_SENSOR_SCHEMA(config) + + +def test_sensor_schema_allows_descriptor_polling() -> None: + assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"}) + + +def test_text_sensor_schema_allows_descriptor_polling() -> None: + assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG)) + + +def test_on_notify_implies_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]} + assert notify_from_on_notify(config)[CONF_NOTIFY] is True + + +def test_notify_unchanged_without_on_notify() -> None: + config: ConfigType = {CONF_NOTIFY: False} + assert notify_from_on_notify(config)[CONF_NOTIFY] is False diff --git a/tests/component_tests/ble_device_base/__init__.py b/tests/component_tests/ble_device_base/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml new file mode 100644 index 0000000000..4d4dab0198 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-controller + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml new file mode 100644 index 0000000000..79e9644006 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-tracker + +bk72xx: + board: generic-bk7252 + +bk72xx_ble_tracker: diff --git a/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml new file mode 100644 index 0000000000..7500f2133b --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml @@ -0,0 +1,16 @@ +esphome: + name: slotcount-esp32-proxy + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +api: + +bluetooth_proxy: + active: true diff --git a/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml new file mode 100644 index 0000000000..46a76cfec8 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml @@ -0,0 +1,9 @@ +esphome: + name: slotcount-esp32-tracker + +esp32: + board: esp32dev + framework: + type: esp-idf + +esp32_ble_tracker: diff --git a/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml new file mode 100644 index 0000000000..891d65ecf6 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-ln882h-tracker + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: diff --git a/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml b/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml new file mode 100644 index 0000000000..e64b328051 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/rp2_controller_only.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-rp2-controller + +rp2: + board: rpipicow + +rp2040_ble: diff --git a/tests/component_tests/ble_device_base/config/rp2_tracker.yaml b/tests/component_tests/ble_device_base/config/rp2_tracker.yaml new file mode 100644 index 0000000000..31686dd236 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/rp2_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-rp2-tracker + +rp2: + board: rpipicow + +rp2_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_hub_binding.py b/tests/component_tests/ble_device_base/test_hub_binding.py new file mode 100644 index 0000000000..59aecc461b --- /dev/null +++ b/tests/component_tests/ble_device_base/test_hub_binding.py @@ -0,0 +1,231 @@ +"""Tests for the BLE hub provider registry and the missing-hub diagnostics.""" + +from collections.abc import Callable, Generator +from importlib import import_module +from pathlib import Path + +import pytest + +import esphome.codegen as cg +from esphome.components import ble_device_base +import esphome.config_validation as cv +from esphome.const import KEY_TARGET_PLATFORM, Platform +from esphome.core import CORE, ID, KEY_CORE +from esphome.cpp_generator import MockObjClass + +COMPONENTS_DIR = Path(ble_device_base.__file__).parent.parent + + +@pytest.fixture +def hub_registry() -> Generator[set[str]]: + """Save/restore _HUB_PROVIDERS — a module global with no reset hook. + + CORE state needs no bookkeeping here: conftest's autouse reset_core + fixture reassigns it after every test. + """ + saved = set(ble_device_base._HUB_PROVIDERS) + yield ble_device_base._HUB_PROVIDERS + ble_device_base._HUB_PROVIDERS.clear() + ble_device_base._HUB_PROVIDERS.update(saved) + + +def _generated_id() -> ID: + """An ID as cv.GenerateID leaves it before the ID-assignment pass.""" + return ID(None, is_declaration=False, type="ble_device_base::BLEHub") + + +def _set_platform(platform: str | None) -> None: + core_data = CORE.data.setdefault(KEY_CORE, {}) + if platform is None: + core_data.pop(KEY_TARGET_PLATFORM, None) + else: + core_data[KEY_TARGET_PLATFORM] = platform + + +# The missing-hub diagnostics: one test per path so a regression in one +# scenario cannot mask the others. The hub binding must fail with a +# tracker-naming message, not use_id's C++-class error, regardless of +# config-step ordering internals. + + +def test_empty_registry_names_every_in_tree_tracker(hub_registry: set[str]) -> None: + # The common failure: a fresh CLI process where the tracker was simply + # forgotten, so no tracker module was ever imported and the registry is + # empty. The error must still name the in-tree trackers. + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform(None) + with pytest.raises( + cv.Invalid, + match="add one of: bk72xx_ble_tracker, esp32_ble_tracker, ln882h_ble_tracker, rp2_ble_tracker", + ): + ble_device_base._require_hub(_generated_id()) + + +def test_platform_filters_the_suggested_trackers(hub_registry: set[str]) -> None: + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform("esp32") + with pytest.raises(cv.Invalid, match="add one of: esp32_ble_tracker$"): + ble_device_base._require_hub(_generated_id()) + + +def test_ble_less_platform_is_not_misdirected(hub_registry: set[str]) -> None: + # A known platform with no in-tree hub must not be pointed at other + # platforms' trackers; out-of-tree BLE hubs are not supported. + hub_registry.clear() + CORE.loaded_integrations.clear() + _set_platform("esp8266") + with pytest.raises( + cv.Invalid, + match="No BLE tracker exists for esp8266; BLE components are not supported", + ): + ble_device_base._require_hub(_generated_id()) + + +def test_explicit_id_bypasses_the_registry(hub_registry: set[str]) -> None: + # Explicit ble_hub_id: is the multi-hub disambiguation case; the ID pass + # owns that diagnosis and its error names the missing id. + hub_registry.clear() + CORE.loaded_integrations.clear() + explicit = ID("my_hub", is_declaration=False, type="ble_device_base::BLEHub") + assert ble_device_base._require_hub(explicit) is explicit + + +def test_registered_and_loaded_provider_passes(hub_registry: set[str]) -> None: + hub_registry.add("esp32_ble_tracker") + CORE.loaded_integrations.add("esp32_ble_tracker") + generated = _generated_id() + assert ble_device_base._require_hub(generated) is generated + + +def _module_name(path: Path) -> str: + """Dotted module name for a file under esphome/components.""" + rel = path.relative_to(COMPONENTS_DIR.parent) + parts = rel.with_suffix("").parts + if parts[-1] == "__init__": + parts = parts[:-1] + return "esphome." + ".".join(parts) + + +def _hub_component_modules() -> list[str]: + """Components whose codegen class inherits ble_device_base.BLEHub. + + The source-text pass only selects import candidates (importing all ~900 + component packages is too slow); membership is decided by the class + hierarchy via MockObjClass.inherits_from on every module whose source + matched — nested declaring modules included — so a comment mentioning + BLEHub in a consumer cannot produce a false positive. + """ + hub_modules = [] + for pkg in sorted(COMPONENTS_DIR.iterdir()): + if pkg.name == "ble_device_base" or not (pkg / "__init__.py").is_file(): + continue + matched = [ + path + for path in pkg.rglob("*.py") + if "BLEHub" in path.read_text(encoding="utf-8") + ] + if not matched: + continue + for path in matched: + mod = import_module(_module_name(path)) + if any( + isinstance(attr, MockObjClass) + and attr is not ble_device_base.BLEHub + and attr.inherits_from(ble_device_base.BLEHub) + for attr in vars(mod).values() + ): + hub_modules.append(pkg.name) + break + return hub_modules + + +def test_every_in_tree_hub_registers_as_provider() -> None: + """A BLEHub subclass that forgets register_hub_provider() makes _require_hub + reject valid configs for that platform — fail CI instead of the user.""" + hub_modules = _hub_component_modules() + assert hub_modules, "hub discovery found no BLEHub subclasses — scan stale?" + for name in hub_modules: + assert name in ble_device_base._HUB_PROVIDERS, ( + f"{name} subclasses ble_device_base.BLEHub but never calls " + "register_hub_provider(); a valid config using it would be rejected" + ) + # The per-platform error table must know every in-tree hub, keyed by real + # platform names — a typo'd key would silently route that platform into + # the no-in-tree-tracker branch. + assert set(ble_device_base._IN_TREE_HUB_PROVIDERS.values()) == set(hub_modules) + platforms = {platform.value for platform in Platform} + assert set(ble_device_base._IN_TREE_HUB_PROVIDERS) <= platforms + + +def test_ble_device_schema_declares_the_binding_key(hub_registry: set[str]) -> None: + """Extending BLE_DEVICE_SCHEMA keeps ble_hub_id a declared key on a strict + schema, for both the generated and the explicit form, and the missing-hub + rejection surfaces through the schema itself.""" + schema = cv.Schema({}).extend(ble_device_base.BLE_DEVICE_SCHEMA) + hub_registry.clear() + CORE.loaded_integrations.discard("esp32_ble_tracker") + with pytest.raises(cv.Invalid, match="No BLE tracker configured"): + schema({}) + hub_registry.add("esp32_ble_tracker") + CORE.loaded_integrations.add("esp32_ble_tracker") + generated = schema({})[ble_device_base.CONF_BLE_HUB_ID] + assert isinstance(generated, ID) and generated.id is None + explicit = schema({"ble_hub_id": "my_hub"})[ble_device_base.CONF_BLE_HUB_ID] + assert explicit.id == "my_hub" + + +def test_rename_legacy_hub_id_migrates_the_old_key() -> None: + validator = ble_device_base.rename_legacy_hub_id("my_sensor") + migrated = validator({"esp32_ble_id": "tracker1"}) + assert migrated == {ble_device_base.CONF_BLE_HUB_ID: "tracker1"} + untouched = validator({"name": "x"}) + assert untouched == {"name": "x"} + + +def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -> None: + emitted: list[str] = [] + monkeypatch.setattr( + "esphome.components.ble_device_base.cg.add", lambda e: emitted.append(str(e)) + ) + var = cg.MockObj("trig") + ble_device_base.add_service_uuid(var, "11AA") + ble_device_base.add_service_uuid(var, "11223344") + ble_device_base.add_service_uuid(var, "11223344-5566-7788-99aa-bbccddeeff00") + assert "set_service_uuid16" in emitted[0] + assert "set_service_uuid32" in emitted[1] + assert "set_service_uuid128" in emitted[2] + # BLE wire order: the 128-bit array must be byte-reversed — as_hex_array + # in its place would still emit the right setter name and silently never + # match on-air. + assert "0x00,0xff,0xee,0xdd" in emitted[2] + with pytest.raises(ValueError, match="Unsupported UUID format"): + ble_device_base.add_service_uuid(var, "123") + + +@pytest.mark.parametrize( + ("config_name", "define"), + [ + ("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"), + ("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"), + ("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"), + ("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"), + ], +) +def test_every_tracker_emits_its_alias_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_name: str, + define: str, +) -> None: + """Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the + ble_hub_impl.h alias ladder selects on it. Checked through real codegen + (the other two legs of the invariant, the ladder arm and the defines.h + mirror, are compile-enforced: a missing arm fails any build containing a + BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses + on every tracker platform - and clang-tidy compiles each arm's + static_assert).""" + generate_main(component_config_path(config_name)) + + assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen" diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py new file mode 100644 index 0000000000..2549125a43 --- /dev/null +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -0,0 +1,164 @@ +"""Tests for the shared BLE tracker scan parameter validation.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.components.bk72xx_ble_tracker import ( + SCAN_PARAMETERS_SCHEMA as BK72XX_SCHEMA, +) +from esphome.components.ble_device_base import to_ble_units +from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA +from esphome.components.ln882h_ble_tracker import ( + SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA, +) +from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA + + +def _validate(**kwargs: str | bool) -> dict: + """Run a scan_parameters config through the bk72xx tracker's real schema.""" + return BK72XX_SCHEMA(kwargs) + + +# --- to_ble_units --- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("2500us", 4), # controller minimum, 2.5 ms + ("30ms", 48), + ("100ms", 160), + ("10240ms", 16384), # controller maximum, 0x4000 + ], +) +def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None: + """A time is converted to whole 0.625 ms units.""" + assert to_ble_units(cv.positive_time_period(value)) == expected + + +def test_to_ble_units_truncates() -> None: + """Sub-unit remainders are dropped, which is what makes collapse possible.""" + assert to_ble_units(cv.positive_time_period("3000us")) == 4 + assert to_ble_units(cv.positive_time_period("2500us")) == 4 + + +# --- the real per-chip schemas --- + + +def test_bk72xx_defaults_are_valid() -> None: + """bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window — + and exposes active (default on, like every active-capable tracker).""" + config = _validate() + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 48 + assert config["active"] is True + + +def test_esp32_defaults_are_valid() -> None: + """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + config = ESP32_SCHEMA({}) + assert to_ble_units(config["interval"]) == 512 + assert to_ble_units(config["window"]) == 48 + assert config["active"] is True + + +def test_rp2_defaults_are_valid() -> None: + """rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the + shared CYW43 radio mostly free for WiFi — and exposes active (default on).""" + config = RP2_SCHEMA({}) + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 48 + assert config["active"] is True + + +def test_ln882h_defaults_are_valid() -> None: + """ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window + (50 % duty) — and exposes active (default on).""" + config = LN882H_SCHEMA({}) + assert to_ble_units(config["interval"]) == 160 + assert to_ble_units(config["window"]) == 80 + assert config["active"] is True + + +def test_esp32_active_can_disable() -> None: + config = ESP32_SCHEMA({"active": False}) + assert config["active"] is False + + +def test_bk72xx_active_can_disable() -> None: + config = _validate(active=False) + assert config["active"] is False + + +# --- accepted configurations --- + + +def test_minimum_separation_accepted() -> None: + """Values one unit apart at the 2.5 ms floor are honest, not collapsed.""" + config = _validate(interval="5000us", window="2500us") + assert to_ble_units(config["interval"]) == 8 + assert to_ble_units(config["window"]) == 4 + + +def test_maximum_interval_accepted() -> None: + """The documented 10240 ms ceiling is inclusive, and maps to 0x4000. + + Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the + reject cases alone would let the bound silently become exclusive. + """ + config = _validate(interval="10240ms", window="30ms") + assert to_ble_units(config["interval"]) == 16384 + + +def test_maximum_window_accepted() -> None: + """The ceiling applies to the window too, and is likewise inclusive.""" + config = _validate(interval="10240ms", window="10240ms") + assert to_ble_units(config["window"]) == 16384 + + +def test_window_equal_to_interval_accepted() -> None: + """A deliberate 100 % duty cycle is allowed; only an accidental one is not.""" + config = _validate(interval="100ms", window="100ms") + assert to_ble_units(config["interval"]) == to_ble_units(config["window"]) + + +def test_duration_equal_to_three_intervals_accepted() -> None: + """The three-interval floor is inclusive, mirroring the ceilings above.""" + _validate(duration="3s", interval="1s", window="500ms") + + +# --- rejected configurations --- + + +def test_window_larger_than_interval_rejected() -> None: + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _validate(interval="30ms", window="100ms") + + +@pytest.mark.parametrize( + ("interval", "window", "offender"), + [ + ("2ms", "1ms", "interval"), # below the 2.5 ms controller floor + ("20s", "1s", "interval"), # above the 10240 ms controller ceiling + ("100ms", "1ms", "window"), # window below the floor + ], +) +def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None: + """Values the controller cannot represent are rejected, not silently wrapped.""" + with pytest.raises( + cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms" + ): + _validate(interval=interval, window=window) + + +def test_unit_collapse_rejected() -> None: + """3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.""" + with pytest.raises(cv.Invalid, match="both truncate to 4 x 0.625 ms"): + _validate(interval="3000us", window="2500us") + + +def test_duration_shorter_than_three_intervals_rejected() -> None: + with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"): + _validate(duration="1s", interval="500ms", window="100ms") diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py new file mode 100644 index 0000000000..86ee53fe8a --- /dev/null +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -0,0 +1,130 @@ +"""Tests for the shared slot_counter codegen factory. + +The factory is exercised end to end through the real controllers: a tracker +config must emit the platform's scan listener count define, and a +controller-only config must emit nothing so the guarded StaticVector storage +compiles out. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +from ..helpers import get_define_value + + +@pytest.mark.parametrize( + ("config", "define"), + [ + ("bk72xx_tracker.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"), + ("rp2_tracker.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"), + ], +) +def test_tracker_requests_one_slot( + config: str, + define: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The tracker's to_code requests a slot; the FINAL job emits the count. + + The neutral listener count must stay absent from the same build: no BLE + consumer registered through register_ble_device(). + """ + generate_main(component_config_path(config)) + assert get_define_value(define) == "1" + assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None + + +@pytest.mark.parametrize( + ("config", "define"), + [ + ("bk72xx_controller_only.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"), + ("rp2_controller_only.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"), + ], +) +def test_controller_only_emits_no_count( + config: str, + define: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """No consumer, no define — the guarded listener storage compiles out.""" + generate_main(component_config_path(config)) + assert get_define_value(define) is None + + +def test_neutral_listener_count_emitted_when_requested() -> None: + """Registering through register_ble_device() emits the neutral count. + + No in-tree sensor registers through ble_device_base.register_ble_device() + yet (consumer migration is a follow-up), so the coroutine is driven with a + mock hub instead of a config; every tracker's #ifdef-guarded listener + storage keys on this define, and a broken emit path would compile the + storage out silently. + """ + import esphome.codegen as cg + from esphome.components import ble_device_base + from esphome.core import ID + + hub_id = ID("hub", type=ble_device_base.BLEHub) + CORE.register_variable(hub_id, cg.MockObj("hub")) + CORE.add_job( + ble_device_base.register_ble_device, + cg.MockObj("listener"), + {ble_device_base.CONF_BLE_HUB_ID: hub_id}, + ) + CORE.flush_tasks() + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1" + + +def test_esp32_tracker_handler_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A bare tracker registers its four esp32_ble handlers and nothing else.""" + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None + # No advertisement listener or client is registered, so both storages compile out. + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None + + +def test_esp32_bluetooth_proxy_requests_client_slots_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The proxy requests a client slot per connection (three by default with + active: true); advertisements and scanner state arrive through the hub + callbacks, so no listener slot exists.""" + generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + # One neutral GATT backend slot per connection (the hub-model flip). + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" + + +def test_counts_reset_between_compiles( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A second compile in the same process starts from zero. + + The module level counters this change removes leaked across compiles in a + long lived host process (dashboard, device-builder), growing the handler + counts by one per compile and oversizing the StaticCallbackManager storage. + """ + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + CORE.reset() + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" diff --git a/tests/component_tests/bluetooth_proxy/__init__.py b/tests/component_tests/bluetooth_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py new file mode 100644 index 0000000000..58e463b32a --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py @@ -0,0 +1,19 @@ +"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together. + +The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and +the esp32 schema builder lazily imports esp32_ble and asserts the two values +agree, but that assert only fires while building the esp32 schema. This test +catches drift when the upstream constant changes without any esp32 config +being validated. +""" + +from esphome.components import esp32_ble +from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS + + +def test_mirror_matches_esp32_ble() -> None: + assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, ( + "bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with " + "esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in " + "esphome/components/bluetooth_proxy/__init__.py" + ) diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py new file mode 100644 index 0000000000..765b2e48d4 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -0,0 +1,74 @@ +"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk +them without importing the esp32 BLE stack; pin the two declarations together. + +The outer schema carries no defaults (the per-platform schema applies them), so +drift cannot surface in validation output — a key renamed or removed in +_esp32_config_schema() but not here would silently vanish from the dashboard's +field extractor. This test is what catches that. The outer schema bounds +connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range +walkers see a real Range; per-platform schemas tighten it, and the cap itself +is pinned by test_idf_max_connections_mirror. +""" + +import voluptuous as vol + +from esphome import config_validation as cv +from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + + +def _esp32_schema_keys() -> dict[str, object]: + # The builder names its platform explicitly, so no CORE state is needed + # (this also mirrors how the language-schema dumper calls it). + return _keys(_schema_of(_esp32_config_schema())) + + +# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA +# plumbing (derived, so a future core key does not fail this component's test), +# generated IDs (not user-walkable options), and connections (must validate +# exactly once — see the comment above CONFIG_SCHEMA). +_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | { + "connections" +} + + +def _schema_of(validator: cv.All) -> vol.Schema: + """The vol.Schema stage of a cv.All chain, found by type rather than by + position so reordering the chain cannot silently break these tests.""" + schemas = [v for v in validator.validators if isinstance(v, vol.Schema)] + assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}" + return schemas[0] + + +def _keys(schema: vol.Schema) -> dict[str, object]: + return {str(key.schema): key for key in schema.schema} + + +def test_outer_scalar_keys_exist_in_esp32_schema() -> None: + outer = _keys(_schema_of(CONFIG_SCHEMA)) + esp32 = _esp32_schema_keys() + missing = set(outer) - set(esp32) + assert not missing, ( + f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " + "does not; update one of them in " + "esphome/components/bluetooth_proxy/__init__.py" + ) + + +def test_esp32_scalars_all_walkable() -> None: + """Every non-generated esp32 scalar option must appear in the outer schema + (connections is deliberately excluded — it must validate exactly once).""" + outer = _keys(_schema_of(CONFIG_SCHEMA)) + esp32 = _esp32_schema_keys() + scalar = { + name + for name, key in esp32.items() + if isinstance(key, vol.Optional) + and not isinstance(key, cv.GenerateID) + and name not in _NOT_MIRRORED + } + missing = scalar - set(outer) + assert not missing, ( + f"esp32 scalar options {sorted(missing)} are missing from the outer " + "CONFIG_SCHEMA and invisible to schema tooling; update " + "esphome/components/bluetooth_proxy/__init__.py" + ) diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py new file mode 100644 index 0000000000..8fc7ffd23b --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -0,0 +1,263 @@ +"""The three platform-gate branches: BLE-less platforms are rejected with the +real reason, hub platforms reject GATT-only options by name, and the +advertisement-only arm applies its own defaults.""" + +from pathlib import Path +import re + +import pytest + +from esphome import config_validation as cv +from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy +from esphome.config_helpers import frameworks_for_platforms +from esphome.const import ( + CONF_ACTIVE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_LN882X, + PLATFORM_RP2, + PlatformFramework, +) +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +# Advertisement-only hub platforms; rp2 runs the full proxy and has its own +# tests below. +HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, +] + +HUB_TRACKERS = { + PLATFORM_BK72XX: "bk72xx_ble_tracker", + PLATFORM_LN882X: "ln882h_ble_tracker", + PLATFORM_RP2: "rp2_ble_tracker", +} + + +def test_hub_platform_list_covers_every_hub_platform() -> None: + # A platform added to _HUB_PLATFORMS would otherwise get no gate coverage + # at all; GATT platforms have their own tests. + advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( + bluetooth_connection.HUB_MAX_CONNECTIONS + ) + assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only + assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) + + +def _set_platform(platform: str | None) -> None: + # For arms set_core_config cannot express (bare platform, no framework). + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform + + +def _register_tracker(platform: str) -> None: + # The ble_hub_id guard needs a loaded tracker, normally registered as an + # import side effect of the tracker module. + tracker = HUB_TRACKERS[platform] + ble_device_base.register_hub_provider(tracker) + CORE.loaded_integrations.add(tracker) + + +def test_ble_less_platform_gets_the_real_reason( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="not supported on esp8266"): + bluetooth_proxy.CONFIG_SCHEMA({}) + + +def test_ble_less_platform_connection_keys_fall_through( + set_core_config: SetCoreConfigCallable, +) -> None: + # The key-level rejection must not fire here — it would imply an + # advertisement-only proxy exists on this platform. + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="not supported on esp8266"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None: + # set_core_config cannot express "no platform"; script/build_codeowners.py + # sets exactly this shape, and the key gate returns early on it so the + # platform gate is what reports. + CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} + with pytest.raises(cv.Invalid, match="not supported on None"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_rejects_active( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) + with pytest.raises(cv.Invalid, match="Active connections are not supported"): + bluetooth_proxy.CONFIG_SCHEMA({"active": True}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +@pytest.mark.parametrize( + ("key", "value"), + [ + ("connection_slots", 2), + ("cache_services", True), + # Absent from the outer CONFIG_SCHEMA, so this gate is the only test + # that touches it. + ("connections", [{}]), + ], +) +def test_hub_platform_rejects_connection_keys_by_name( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + key: str, + value: object, +) -> None: + set_core_config(platform_framework) + with pytest.raises(cv.Invalid, match=f"'{key}' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({key: value}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_accepts_the_advertisement_only_shape( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) + validated = bluetooth_proxy.CONFIG_SCHEMA({}) + assert validated[CONF_ACTIVE] is False + + +def test_rp2_defaults_to_the_full_proxy( + set_core_config: SetCoreConfigCallable, +) -> None: + # esp32 parity: active defaults to true, with the platform's slot limit, + # and one populated connection entry for the codegen to index. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({}) + assert validated[CONF_ACTIVE] is True + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3 + + +def test_rp2_accepts_explicit_passive( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False}) + assert validated[CONF_ACTIVE] is False + assert bluetooth_proxy.CONF_CONNECTIONS not in validated + + +def test_rp2_rejects_slots_beyond_the_btstack_limit( + set_core_config: SetCoreConfigCallable, +) -> None: + # The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="at most 3 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4}) + # Fewer slots than the cap stay accepted (the prebuilt single-client pool + # path for 1, the wrap path for 2). + validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1}) + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + # Values past even the loosest platform cap stop at the outer walkable + # schema, which stays bounded for range walkers (device-builder sync); + # in-range values get the platform message above. + with pytest.raises(cv.Invalid, match="at most 9"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12}) + + +def test_rp2_rejects_esp32_only_keys_by_name( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"): + bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) + with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"): + bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) + + +def test_hub_source_filter_covers_every_hub_platform() -> None: + # bluetooth_connection cannot import this module to derive the hub.cpp + # framework set, so pin it here: a platform admitted to the proxy but + # missing from the filter would validate, then fail at link. + expected = frameworks_for_platforms( + [*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32] + ) + hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[ + "bluetooth_connection_hub.cpp" + ] + assert expected == hub_frameworks + + +def test_bluetooth_connection_auto_load_covers_its_includes() -> None: + # The backend registers with its platform BLE stack (and the Bluedroid + # header includes the tracker's), so that closure lives here and + # consumers stay platform-blind; the platform-less arm is the union for + # manifest-resolving tooling. + _set_platform("esp32") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] + _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] + _set_platform(None) + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] + + +def test_every_registered_hub_platform_has_a_schema_arm() -> None: + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. + registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) + assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) + assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) + # The outer walkable schema's bound must stay the loosest platform cap. + assert ( + max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) + <= bluetooth_proxy._IDF_MAX_CONNECTIONS + ) + + +def test_defines_h_mirrors_the_rp2_slot_cap() -> None: + # esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS + # for static analysis; pin it to the real rp2 cap. + defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text() + cap = bluetooth_connection.RP2_MAX_CONNECTIONS + # The rp2 arm's define, tolerating blank/comment lines in between. + match = re.search( + r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)", + defines, + ) + assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS" + assert int(match.group(1)) == cap, ( + f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" + ) + # The static-analysis client count scales with the same cap. Scoped to + # the USE_RP2 block: the esp32 arm carries its own count. + rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines) + assert rp2_block is not None, "no USE_RP2 platform block in defines.h" + match = re.search( + r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1) + ) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block" + assert int(match.group(1)) == cap, ( + f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + ) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 84128d75d7..f105ed5888 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -33,6 +33,43 @@ def test_deep_sleep_run_duration_simple(generate_main): assert "deepsleep->set_run_duration(10000);" in main_cpp +def test_deep_sleep_on_wake_trigger(generate_main): + """ + When deep sleep is configured with a component-level on_wake automation, + a WakeTrigger component should be registered with the wakeup cause as + the automation argument. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::WakeTrigger();" in main_cpp + assert "Automation" in main_cpp + + +def test_deep_sleep_ext1_on_wake_triggers(generate_main): + """ + Each esp32_ext1_wakeup pin with an on_wake automation should get its own + Ext1WakeTrigger with the pin number, and all pins (including the legacy + bare-pin shorthand) should contribute to the ext1 wakeup mask. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml") + + assert "deep_sleep::Ext1WakeTrigger(2);" in main_cpp + assert "deep_sleep::Ext1WakeTrigger(4);" in main_cpp + # GPIO13 has no on_wake, so no trigger is created for it + assert "deep_sleep::Ext1WakeTrigger(13)" not in main_cpp + # mask covers GPIO2, GPIO4 and GPIO13 + assert ".mask = 8212," in main_cpp + + +def test_deep_sleep_no_on_wake_no_triggers(generate_main): + """ + Without any on_wake automations, no wake trigger code should be generated. + """ + main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml") + + assert "WakeTrigger" not in main_cpp + + def test_deep_sleep_run_duration_dictionary(generate_main): """ When deep sleep is configured with dictionary run duration, it should be set. diff --git a/tests/component_tests/deep_sleep/test_deep_sleep3.yaml b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml new file mode 100644 index 0000000000..71a0340b65 --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep3.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + run_duration: 10s + on_wake: + - lambda: 'ESP_LOGD("test", "cause %d", static_cast(cause));' + esp32_ext1_wakeup: + mode: ANY_HIGH + pins: + - pin: GPIO2 + on_wake: + - lambda: 'ESP_LOGD("test", "left");' + - pin: + number: GPIO4 + on_wake: + - lambda: 'ESP_LOGD("test", "right");' + - number: GPIO13 diff --git a/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml b/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml new file mode 100644 index 0000000000..d98d19f545 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ethernet_priority.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml new file mode 100644 index 0000000000..0d504e36a2 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: ecdsa256 diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml new file mode 100644 index 0000000000..8d0dad947a --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: ecdsa_v1 + verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem diff --git a/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml new file mode 100644 index 0000000000..f63f3ab690 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 diff --git a/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml new file mode 100644 index 0000000000..1a42301e9e --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 + signing_key: ../../../components/esp32/dummy_signing_key.pem diff --git a/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml new file mode 100644 index 0000000000..28966eba40 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 + verification_keys: + - ../../../components/esp32/dummy_signing_key.pem diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3feaea0c88..1fd835076d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,11 +10,16 @@ from typing import Any import pytest from esphome.components.esp32 import ( + KEY_FATFS_REQUIRED, + KEY_VFS_DIR_REQUIRED, + KEY_VFS_SELECT_REQUIRED, + KEY_VFS_TERMIOS_REQUIRED, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, + _reconcile_vfs_fatfs_sdkconfig, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -270,6 +275,53 @@ def test_nvs_encryption_sdkconfig( assert "PERMANENT and IRREVERSIBLE" in caplog.text +@pytest.mark.parametrize( + ("fixture", "multi_key", "idf_on_update"), + [ + # Externally-signed RSA with a declared trusted-key list hands + # verification to ESPHome's multi-key verifier, so IDF's single-block + # on-update check must be OFF. It defaults ON under + # SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False + # explicitly -- not merely omitted. + ("signed_ota_verification_keys_s3.yaml", True, False), + # Externally-signed RSA without a trusted-key list has no trust anchor, + # so it falls back to IDF's built-in check. + ("signed_ota_external_rsa_s3.yaml", False, True), + # Build-time signing and the other schemes keep IDF's check. + ("signed_ota_signing_key_s3.yaml", False, True), + ("signed_ota_ecdsa256_c6.yaml", False, True), + ("signed_ota_ecdsa_v1.yaml", False, True), + ], +) +def test_signed_ota_verification_sdkconfig( + fixture: str, + multi_key: bool, + idf_on_update: bool, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only external RSA disables IDF's on-update check and uses ESPHome's verifier.""" + generate_main(component_config_path(fixture)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # The padded, externally-signable image is always produced. + assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True + # Explicit value (never left to the Kconfig default) decides who verifies. + assert ( + sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update + ) + defines = {define.name for define in CORE.defines} + assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key + if multi_key: + # The padding / reserved signature sector the verifier depends on keys + # off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS + # (which the explicit `n` above drives to n). Pin the real dependency. + assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True + # The compiled-in trust anchor: the fixture lists one key. + define_values = {define.name: str(define.value) for define in CORE.defines} + assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1" + assert "OTA_TRUSTED_KEY_DIGESTS" in define_values + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ @@ -567,6 +619,160 @@ def test_reconcile_network_sdkconfig( assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected +@pytest.mark.parametrize( + ("requires", "fatfs_required", "disables", "preset", "expected"), + [ + # Nothing required and every disable_* flag off (NOT the shipped defaults, which + # disable everything): VFS enabled, FATFS left untouched entirely. + pytest.param( + {}, + False, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="nothing_disabled_nothing_required", + ), + # The shipped out-of-the-box path: every disable_* flag defaults to True and nothing + # is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume). + pytest.param( + {}, + False, + (True, True, True, True), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": False, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": False, + "CONFIG_FATFS_LFN_NONE": True, + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="all_disabled_fatfs_fallback", + ), + # A component's require_* beats the user's disable_* flag for every VFS feature. + pytest.param( + { + KEY_VFS_TERMIOS_REQUIRED: True, + KEY_VFS_SELECT_REQUIRED: True, + KEY_VFS_DIR_REQUIRED: True, + }, + False, + (True, True, True, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="require_beats_disable", + ), + # A user sdkconfig_options preset wins over a require (the set_opt guard). + pytest.param( + {KEY_VFS_SELECT_REQUIRED: True}, + False, + (False, False, False, False), + {"CONFIG_VFS_SUPPORT_SELECT": False}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="user_preset_wins_over_require", + ), + # require_fatfs() with no user preset: long filenames on the heap, 255 chars, + # four volumes. + pytest.param( + {}, + True, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": False, + "CONFIG_FATFS_LFN_HEAP": True, + "CONFIG_FATFS_MAX_LFN": 255, + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_required_defaults", + ), + # CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member + # (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_STACK": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_STACK": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_stack_untouched", + ), + # disable_fatfs (the shipped default) with a user LFN pick: the choice group is the + # user's -- no LFN_NONE=y written next to their member, only the volume fallback. + pytest.param( + {}, + False, + (False, False, False, True), + {"CONFIG_FATFS_LFN_HEAP": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_HEAP": "y", + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="disable_fatfs_user_lfn_untouched", + ), + # Same for an explicit LFN_NONE preset: the group is the user's, only the volume + # count default is added. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_NONE": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_none_untouched", + ), + ], +) +def test_reconcile_vfs_fatfs_sdkconfig( + set_core_config: SetCoreConfigCallable, + requires: dict[str, bool], + fatfs_required: bool, + disables: tuple[bool, bool, bool, bool], + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves the VFS feature flags and the FATFS + defaults from the recorded require_* calls, with user sdkconfig_options winning + and the LFN Kconfig choice treated as one group.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)} + if fatfs_required: + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + for key, value in requires.items(): + CORE.data[key] = value + + asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables)) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + def test_network_wifi_only_reconciles_end_to_end( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], @@ -612,6 +818,23 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig +def test_network_wifi_ethernet_priority_keeps_wifi_enabled( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: with both WiFi and Ethernet declared under network: priority:, + the reconciler must NOT disable the WiFi stack or coexistence (the + multi-interface case unlocked by composing network priority with the + sdkconfig reconciler).""" + generate_main(component_config_path("network_wifi_ethernet_priority.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig + # WiFi has no AP here, so SoftAP/DHCP server are still dropped. + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + + def test_esp32_build_internals_are_yaml_only() -> None: """ESP32 raw framework / build inputs are ``YAML_ONLY``. @@ -690,6 +913,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None: # V1 ECDSA: exactly one of signing key / verification key. {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + # External RSA with a compiled-in trusted-key list (digests). + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]}, + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]}, ], ) def test_signed_ota_keys_valid_combinations(config: dict) -> None: @@ -744,6 +970,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) - }, "not both", ), + # A trusted-key list only applies to external RSA. + ( + {"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]}, + "only used with signing scheme 'rsa3072'", + ), + # Can't both auto-sign and verify against a fixed trusted set. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_keys": ["ab" * 32], + }, + "cannot be combined with", + ), + # The singular V1 key and the RSA trusted-key list are mutually exclusive. + ( + { + "signing_scheme": "rsa3072", + "verification_key": "key.bin", + "verification_keys": ["ab" * 32], + }, + "at most one", + ), + # Duplicate trusted keys are rejected. + ( + {"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]}, + "must be unique", + ), ], ) def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: @@ -753,6 +1007,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: _validate_signed_ota_keys(config) +def test_sbv2_rsa_key_digest_known_answer() -> None: + """The compiled-in trust anchor is the block-format digest the device + computes per signature block; pin it to espsecure's known output for the + shipped dummy key so a future change to the derivation can't drift silently. + """ + from esphome.components.esp32 import _sbv2_rsa_key_digest + + key = ( + Path(__file__).parent.parent.parent + / "components" + / "esp32" + / "dummy_signing_key.pem" + ) + assert ( + _sbv2_rsa_key_digest(key).hex() + == "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65" + ) + + +def test_validate_trusted_key_hex_forms() -> None: + """The digest-input branch: the same key as an uppercase 64-hex digest + normalizes to the PEM-derived value (the two forms are interchangeable), and + a mangled digest fails clearly instead of as a missing file. + """ + from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key + + key = ( + Path(__file__).parent.parent.parent + / "components" + / "esp32" + / "dummy_signing_key.pem" + ) + pem_digest = _sbv2_rsa_key_digest(key).hex() + assert _validate_trusted_key(pem_digest.upper()) == pem_digest + for bad in (pem_digest[:-1], "0x" + pem_digest): + with pytest.raises(cv.Invalid, match="64 hex"): + _validate_trusted_key(bad) + # An unquoted 0x.../all-digit digest reaches the validator as a YAML int. + with pytest.raises(cv.Invalid, match="Quote the digest"): + _validate_trusted_key(0x957671F5EC1B55B3) + + @pytest.mark.parametrize( ("value", "expected"), [ diff --git a/tests/component_tests/ethernet/__init__.py b/tests/component_tests/ethernet/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py new file mode 100644 index 0000000000..9308d0b099 --- /dev/null +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -0,0 +1,92 @@ +"""Tests for the ethernet final-validation coexistence gate and schema bounds.""" + +import pytest +from voluptuous import Invalid + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate +from esphome.components.network import _validate_priority_list +from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.core import CORE +import esphome.final_validate as fv + +from ..types import SetCoreConfigCallable + +_CH390_CONFIG = { + "type": "CH390", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + + +@pytest.fixture(autouse=True) +def _reset_full_config(): + """Reset fv.full_config so each test starts with a clean slate.""" + token = fv.full_config.set({}) + yield + fv.full_config.reset(token) + + +def test_rejects_wifi_and_ethernet_without_priority() -> None: + """Wi-Fi + ethernet without a network: priority: list must be rejected.""" + fv.full_config.set({"wifi": {}, "ethernet": {}}) + with pytest.raises(Invalid, match="cannot be used together with component wifi"): + _final_validate({}) + + +def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: + """A priority list missing an interface is rejected and names what's missing.""" + fv.full_config.set( + { + "wifi": {}, + "ethernet": {}, + "network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])}, + } + ) + with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): + _final_validate({}) + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"]) +def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """CH390 SCK is rated to 72MHz, so the schema must accept the whole range.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + with pytest.raises(Invalid, match="value must be at most 72000000"): + CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 3778cf8aa5..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) @@ -254,6 +244,31 @@ def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: assert CONF_RESET_PIN in result +def test_config_guition_model_applies_defaults(tmp_path: Path) -> None: + """The GUITION model populates transform and calibration defaults.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "guition-jc8012p4a1", + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "GUITION-JC8012P4A1" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": False, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 880 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1648 + assert result[CONF_INTERRUPT_PIN]["number"] == 21 + assert result[CONF_RESET_PIN]["number"] == 22 + + def test_config_rejects_non_dict() -> None: """A non-dict configuration is rejected.""" with pytest.raises(cv.Invalid, match="expected a dictionary"): diff --git a/tests/component_tests/heatpumpir/__init__.py b/tests/component_tests/heatpumpir/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/heatpumpir/test_init.py b/tests/component_tests/heatpumpir/test_init.py new file mode 100644 index 0000000000..85da0c8bf5 --- /dev/null +++ b/tests/component_tests/heatpumpir/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the heatpumpir climate config validation.""" + +from esphome.components.heatpumpir.climate import _default_visual +from esphome.const import CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, CONF_VISUAL +from esphome.types import ConfigType + + +def test_default_visual_seeds_from_required_min_max() -> None: + """Without a visual block, the required min/max_temperature seed the visual + range so the entity reports it in Home Assistant instead of 0-100 (#17983).""" + config: ConfigType = {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30} + _default_visual(config) + assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18 + assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30 + + +def test_default_visual_keeps_explicit() -> None: + """An explicit visual min/max is not overwritten by the required temps.""" + config: ConfigType = { + CONF_MIN_TEMPERATURE: 16, + CONF_MAX_TEMPERATURE: 32, + CONF_VISUAL: {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30}, + } + _default_visual(config) + assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18 + assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30 diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 2eb588c0ca..3b5e5bbd6e 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -27,3 +27,19 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int: match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) assert match, f"configure call not found for {var_name}" return int(match.group(1)) + + +def get_define_value(name: str) -> str | None: + """Rendered value of a CORE define, or None when absent. + + Values are codegen expressions (IntLiteral); they are compared rendered. + A value-less define (e.g. USE_BK72XX_BLE) is present but renders as the + string "None", while an absent define returns the None object — easy to + conflate in assertions, so use this helper for valued defines only. + """ + from esphome.core import CORE + + for define in CORE.defines: + if define.name == name: + return str(define.value) + return None diff --git a/tests/component_tests/ld6002b/__init__.py b/tests/component_tests/ld6002b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py new file mode 100644 index 0000000000..0bb091533b --- /dev/null +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -0,0 +1,216 @@ +"""Tests for the ld6002b validators that reach across platforms. + +wake needs a pin on its own hub, apply_area needs a select on its own hub, and +area_config needs both a button and a select on its own hub. Every one of them +is a same-instance check, which is the half that breaks quietly. +""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN +from esphome.components.ld6002b.number import ( + CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + CONF_ID, + CONF_WAKEUP_PIN, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld6002b_hub" +OTHER_HUB_ID = "ld6002b_other" + + +def _full_config( + hub: ConfigType, + *, + selects: list[ConfigType] | None = None, + buttons: list[ConfigType] | None = None, +) -> Config: + """A full config carrying one ld6002b hub, as the ID pass leaves it. + + final_validate resolves the hub through get_path_for_id, so the declaring + path has to be registered the way validate_config registers it: the path of + the id value itself, whose parent is the hub's own config. + + The platform lists are what the cross-platform validators scan, so a test can + say which of them exist and which hub each one names. + """ + full = Config() + full["ld6002b"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + if selects is not None: + full["select"] = selects + if buttons is not None: + full[CONF_BUTTON] = buttons + return full + + +def _hub(*, wakeup_pin: bool) -> ConfigType: + hub: ConfigType = {CONF_ID: ID(HUB_ID, is_declaration=True, type="ld6002b")} + if wakeup_pin: + hub[CONF_WAKEUP_PIN] = {"number": 4} + return hub + + +def _buttons(**buttons: str) -> ConfigType: + """A button platform config naming the given buttons on the shared hub.""" + config: ConfigType = { + "ld6002b_id": ID(HUB_ID, is_declaration=False, type="ld6002b") + } + config.update({key: {"name": name} for key, name in buttons.items()}) + return config + + +def _select(*, hub_id: str = HUB_ID) -> ConfigType: + """A select platform config naming area_id on the given hub.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_ID: {"name": "Area ID"}, + } + + +def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType: + """A number platform config carrying one area_config bound.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}}, + } + + +def _validated(config: ConfigType) -> ConfigType: + """Run the button schema, then the final validation the hub is checked in.""" + config = BUTTON_CONFIG_SCHEMA(config) + BUTTON_FINAL_VALIDATE_SCHEMA(config) + return config + + +def _validated_numbers(config: ConfigType) -> ConfigType: + """The same two passes for the number platform.""" + config = NUMBER_CONFIG_SCHEMA(config) + NUMBER_FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_wake_without_wakeup_pin_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The wake button drives the pin directly, so a hub without one cannot serve it.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises(cv.Invalid, match="wake requires wakeup_pin"): + _validated(_buttons(wake="Wake")) + + +def test_wake_with_wakeup_pin_passes(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=True)) + ) + + _validated(_buttons(wake="Wake")) + + +def test_other_buttons_do_not_need_the_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only wake drives the pin; the query buttons stay usable without one.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + _validated(_buttons(get_delay="Get Delay")) + + +def test_apply_area_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """apply_area sends the staged bounds to whichever area the select names.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_apply_area_select_on_another_hub_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A select exists, but on a second ld6002b -- which cannot serve this one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_area_config_without_apply_area_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The six numbers only stage a write; apply_area is what sends it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires button\.apply_area for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) + + +def test_area_config_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The validator's other half: the staged bounds also need an area to land in.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires select\.area_id for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) diff --git a/tests/component_tests/ln882h_ble_tracker/__init__.py b/tests/component_tests/ln882h_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..883d20b7ce --- /dev/null +++ b/tests/component_tests/ln882h_ble_tracker/config/test_automations.yaml @@ -0,0 +1,44 @@ +esphome: + name: ln-trigger-codegen + on_boot: + then: + - ln882h_ble_tracker.start_scan: + continuous: true + # Bare form: restores the configured scan_parameters mode — no + # set_continuous emitted (asserted in the codegen test). + - ln882h_ble_tracker.start_scan: + - ln882h_ble_tracker.stop_scan: + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: + scan_parameters: + continuous: false + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..608a4c6694 --- /dev/null +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -0,0 +1,55 @@ +"""Codegen tests for the tracker automations: the generated main is the +automated check on the setter calls and the listener accounting (the +test.ln882x-ard.yaml compile fixture proves linkage, not codegen shape).""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions. + # Exactly one set_continuous: the bare start_scan emits none, pinning the + # restore-configured-mode divergence from esp32 against a future default=. + assert main_cpp.count("->set_continuous(") == 1 + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # scan_parameters continuous: false reaches the YAML-mode setter, not the + # runtime override. + assert "->set_configured_continuous(false)" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/component_tests/lvgl/config/set_z_index_test.yaml b/tests/component_tests/lvgl/config/set_z_index_test.yaml new file mode 100644 index 0000000000..61a248ff99 --- /dev/null +++ b/tests/component_tests/lvgl/config/set_z_index_test.yaml @@ -0,0 +1,60 @@ +esphome: + name: test-set-z-index + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - label: + id: label_a + text: "A" + - label: + id: label_b + text: "B" + - button: + id: trigger_btn + on_click: + - lvgl.widget.set_z_index: + id: label_a + position: top + - lvgl.widget.set_z_index: + id: label_a + position: bottom + - lvgl.widget.set_z_index: + id: label_a + position: up + - lvgl.widget.set_z_index: + id: label_a + position: down + - lvgl.widget.set_z_index: + id: label_a + position: 3 + - lvgl.widget.set_z_index: + id: label_a + position: -2 + - lvgl.widget.set_z_index: + id: [label_a, label_b] + position: up diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py index 1a2cde632c..ce9a162d99 100644 --- a/tests/component_tests/lvgl/test_animation.py +++ b/tests/component_tests/lvgl/test_animation.py @@ -169,14 +169,27 @@ class TestTimingSchema: def test_round_trip_string(self) -> None: assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + def test_round_trip_default_pause(self) -> None: + # Back-compat default: no pause, matching the pre-existing round_trip behavior. + assert TIMING_SCHEMA("round_trip")["pause"] == pytest.approx(0.0) + + def test_round_trip_pause_percentage_string(self) -> None: + result = TIMING_SCHEMA({"type": "round_trip", "pause": "50%"}) + assert result["pause"] == pytest.approx(0.5) + + def test_round_trip_pause_rejects_one(self) -> None: + # pause == 1.0 would make moving_length_ zero and divide by zero in map_progress. + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "round_trip", "pause": 1.0}) + def test_ease_in_out_default_weight(self) -> None: result = TIMING_SCHEMA("ease_in_out") assert result["type"] == "ease_in_out" - assert result["weight"] == pytest.approx(2.0) + assert result["weight"] == pytest.approx(1.0) def test_ease_in_out_custom_weight(self) -> None: - result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) - assert result["weight"] == pytest.approx(3.0) + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) def test_gravity_defaults(self) -> None: result = TIMING_SCHEMA("gravity") diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py new file mode 100644 index 0000000000..b63b7618e7 --- /dev/null +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -0,0 +1,55 @@ +"""Tests for LVGL's multi-instance config cross-checks.""" + +from __future__ import annotations + +import pytest + +from esphome.components.lvgl import defines as df, multi_conf_validate +from esphome.components.lvgl.schemas import theme_schema +from esphome.config_validation import Invalid + + +def _config(displays: list[str], theme: dict | None = None) -> dict: + config = { + df.CONF_DISPLAYS: displays, + "log_level": "WARN", + "color_depth": 16, + "byte_order": "big_endian", + df.CONF_TRANSPARENCY_KEY: 0x000400, + } + if theme is not None: + config[df.CONF_THEME] = theme + return config + + +class TestThemeOnMultipleInstances: + def test_raises_when_two_instances_have_theme(self) -> None: + configs = [ + _config(["disp_a"], theme={df.CONF_DARK_MODE: True}), + _config(["disp_b"], theme={df.CONF_DARK_MODE: False}), + ] + with pytest.raises(Invalid, match="'theme' may only be set on one"): + multi_conf_validate(configs) + + def test_raises_even_with_an_empty_theme_block(self) -> None: + # `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted + # by the schema), so it should be treated the same as a populated one. + # Run it through the real schema rather than hand-building the dict, + # so this actually pins that defaulting behaviour. + configs = [ + _config(["disp_a"], theme=theme_schema({})), + _config(["disp_b"], theme=theme_schema({})), + ] + with pytest.raises(Invalid, match="'theme' may only be set on one"): + multi_conf_validate(configs) + + def test_passes_when_only_one_instance_has_theme(self) -> None: + configs = [ + _config(["disp_a"], theme={df.CONF_DARK_MODE: True}), + _config(["disp_b"]), + ] + multi_conf_validate(configs) + + def test_passes_when_no_instance_has_theme(self) -> None: + configs = [_config(["disp_a"]), _config(["disp_b"])] + multi_conf_validate(configs) diff --git a/tests/component_tests/lvgl/test_schema_dict_helpers.py b/tests/component_tests/lvgl/test_schema_dict_helpers.py index 16714f54d7..c8b3a76bf9 100644 --- a/tests/component_tests/lvgl/test_schema_dict_helpers.py +++ b/tests/component_tests/lvgl/test_schema_dict_helpers.py @@ -13,12 +13,7 @@ import pytest import voluptuous as vol from esphome import config_validation as cv -import esphome.components.lvgl -from esphome.components.lvgl import ( - _theme_schema, - defines as df, - schemas as lvgl_schemas, -) +from esphome.components.lvgl import defines as df, schemas as lvgl_schemas from esphome.components.lvgl.schemas import ( ALIGN_TO_SCHEMA, FLAG_SCHEMA, @@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import ( obj_schema, part_dict, part_schema, + theme_schema, + theme_update_schema, ) from esphome.components.lvgl.types import LvType from esphome.components.lvgl.widgets import WidgetType @@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]: cache.clear() # The lazily-built theme schema is cached on _build_theme_schema; clear it # too so each test starts from a clean slate. - build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None) + build_theme = getattr(lvgl_schemas, "_build_theme_schema", None) if build_theme is not None and hasattr(build_theme, "cache_clear"): build_theme.cache_clear() yield @@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None: def test_theme_schema_merges_obj_dict_and_full_style_props() -> None: - # _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema + # theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema # share many STYLE_SCHEMA marker instances. Exercise the merged schema # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict # and a FULL_STYLE-only property) to lock the behaviour against future # regressions in either source. - out = _theme_schema( + out = theme_schema( { df.CONF_DARK_MODE: True, "obj": { @@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non # any_widget_schema explicitly supports external components registering # widgets lazily, and the device builder revalidates in-process, so a # widget registered after first use must invalidate the cached snapshot. - _theme_schema({df.CONF_DARK_MODE: True}) # populate the cache + theme_schema({df.CONF_DARK_MODE: True}) # populate the cache name = "test_self_heal_widget" assert name not in WIDGET_TYPES @@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non # manually so the next theme call sees the new entry. WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True) try: - out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) + out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}}) assert out[name]["bg_color"] == 0x010203 finally: WIDGET_TYPES.pop(name, None) +@pytest.mark.parametrize( + ("config", "expected_path"), + [ + ({"button": {"styles": ["foo"]}}, ["button", "styles"]), + ( + {"button": {"pressed": {"styles": ["foo"]}}}, + ["button", "pressed", "styles"], + ), + ( + {"arc": {"indicator": {"styles": ["foo"]}}}, + ["arc", "indicator", "styles"], + ), + ( + {"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}}, + ["arc", "indicator", "pressed", "styles"], + ), + ], +) +def test_theme_schema_rejects_styles_key( + config: dict, expected_path: list[str] +) -> None: + # `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA + # but silently dropped by style_set when building a theme's hidden style + # -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing, + # at the top level and when nested under a part and/or state. + with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info: + theme_schema(config) + assert exc_info.value.path == expected_path + + +def test_theme_update_schema_rejects_styles_key() -> None: + with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info: + theme_update_schema({"label": {"styles": ["foo"]}}) + assert exc_info.value.path == ["label", "styles"] + + +def test_theme_update_schema_does_not_request_untargeted_main_default() -> None: + # collect_parts() unconditionally seeds a main/default entry even when + # only a specific state (here "pressed") was targeted -- registering a + # request for that spurious entry would make theme_to_code create an + # unused, empty style and attach it to every widget of this type. + theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}}) + assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None} + + +def test_theme_update_schema_requests_explicit_main_default() -> None: + theme_update_schema({"label": {"text_color": 0x010203}}) + assert df.get_theme_update_requests()["label"] == {("main", "default"): None} + + @pytest.mark.parametrize( "schema", [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA], ) def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None: - # _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key + # theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key # collision, dict-spread keeps the first source's marker (and its default) # but the last source's value, whereas .extend() would take both from the # later source. The two are equivalent today because the overlapping diff --git a/tests/component_tests/lvgl/test_set_z_index.py b/tests/component_tests/lvgl/test_set_z_index.py new file mode 100644 index 0000000000..cf2a072ca0 --- /dev/null +++ b/tests/component_tests/lvgl/test_set_z_index.py @@ -0,0 +1,128 @@ +"""Tests for the ``lvgl.widget.set_z_index`` action: schema validation and +code generation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.components.lvgl.automation import SET_Z_INDEX_SCHEMA +from esphome.config_validation import Invalid + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestSetZIndexSchemaValidation: + """Test that SET_Z_INDEX_SCHEMA accepts the documented forms and rejects + everything else. + """ + + @pytest.mark.parametrize("position", ["top", "bottom", "up", "down"]) + def test_keyword_position_accepted(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", ["Top", "BOTTOM", "Up", "dOwN"]) + def test_keyword_position_case_insensitive(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", [0, 1, 5, -1, -5]) + def test_integer_position_accepted(self, position: int) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position + + def test_unknown_keyword_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "sideways"}) + + def test_float_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": 1.5}) + + def test_missing_id_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"position": "top"}) + + def test_missing_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget"}) + + def test_single_id_is_wrapped_in_list(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "top"}) + assert len(config["id"]) == 1 + assert config["id"][0]["id"].id == "my_widget" + + def test_list_of_ids_accepted(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": ["widget_a", "widget_b"], "position": "top"}) + assert [entry["id"].id for entry in config["id"]] == ["widget_a", "widget_b"] + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared set_z_index YAML config once + per module. See ``test_widget_state.py`` for why this is module-scoped + and self-contained rather than using the function-scoped ``generate_main`` + fixture from ``conftest.py``. + """ + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + from esphome.core import CORE + + config_path = Path(request.fspath).parent / "config" / "set_z_index_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_top_emits_move_foreground(main_cpp: str) -> None: + assert "lv_obj_move_foreground(label_a);" in main_cpp + + +def test_bottom_emits_move_background(main_cpp: str) -> None: + assert "lv_obj_move_background(label_a);" in main_cpp + + +def test_up_emits_unguarded_index_increment(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);" in main_cpp + + +def test_down_emits_guarded_index_decrement(main_cpp: str) -> None: + """``down`` must be guarded so that a widget already at index 0 isn't + reinterpreted by LVGL as "move to the top" (LVGL treats a negative + index as "count from the back"). + """ + assert "if (lv_obj_get_index(label_a) > 0) {" in main_cpp + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) - 1);" in main_cpp + + +def test_positive_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, 3);" in main_cpp + + +def test_negative_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, -2);" in main_cpp + + +def test_list_of_ids_applies_to_each_widget(main_cpp: str) -> None: + """``id: [label_a, label_b]`` must emit the move call once per widget.""" + assert ( + main_cpp.count("lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);") + == 2 + ) + assert "lv_obj_move_to_index(label_b, lv_obj_get_index(label_b) + 1);" in main_cpp diff --git a/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml new file mode 100644 index 0000000000..0226d680a4 --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml @@ -0,0 +1,10 @@ +mitsubishi_cn105: + id: ac_hub + +climate: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac_hub + name: AC + current_temperature_min_interval: 30s + uart_id: uart_bus + update_interval: 10s diff --git a/tests/component_tests/mitsubishi_cn105/test_climate.py b/tests/component_tests/mitsubishi_cn105/test_climate.py new file mode 100644 index 0000000000..e4e3da9c7f --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/test_climate.py @@ -0,0 +1,30 @@ +"""Tests for Mitsubishi CN105 climate configuration migration diagnostics.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mitsubishi_cn105 import climate +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.yaml_util import load_yaml + + +def test_top_level_hub_rejects_leftover_legacy_climate_keys( + component_fixture_path: Callable[[str], Path], +) -> None: + config = load_yaml( + component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml") + ) + CORE.raw_config = config + + with pytest.raises(cv.Invalid) as exc_info: + climate.CONFIG_SCHEMA(config["climate"][0]) + + message = str(exc_info.value) + assert "'current_temperature_min_interval'" in message + assert "'uart_id'" in message + assert "'update_interval'" in message + assert "top-level 'mitsubishi_cn105:' block" in message + assert "'telemetry_request_min_interval'" in message diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py new file mode 100644 index 0000000000..0e53c55b50 --- /dev/null +++ b/tests/component_tests/modbus/test_modbus.py @@ -0,0 +1,39 @@ +"""Tests for modbus configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address +from esphome.const import CONF_ADDRESS + + +def test_server_address_accepts_valid_unit_address() -> None: + # A normal unit address (1-247) is accepted and returned as an int. + assert _validate_server_address(1) == 1 + assert _validate_server_address(247) == 247 + + +def test_server_address_accepts_hex_string() -> None: + # hex_uint8_t parses hex strings, and the validator returns the parsed int. + assert _validate_server_address("0x10") == 0x10 + + +def test_server_address_zero_rejected() -> None: + # Address 0 is the Modbus broadcast address and cannot identify a server device. + with pytest.raises(cv.Invalid, match="broadcast address"): + _validate_server_address(0) + + +def test_server_schema_rejects_address_zero() -> None: + # The server-role schema wires in _validate_server_address, so address 0 is rejected there too. + schema = modbus.modbus_device_schema(0x01, role="server") + with pytest.raises(cv.Invalid, match="broadcast address"): + schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0}) + + +def test_client_schema_still_accepts_address_zero() -> None: + # Not rejected for clients today, but not supported either: a client broadcast gets no reply and + # stalls the hub for the full send-wait. + schema = modbus.modbus_device_schema(0x01) + assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py new file mode 100644 index 0000000000..7966048dd7 --- /dev/null +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -0,0 +1,158 @@ +"""Tests for modbus_client configuration validation. + +Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions +guard is a safety property: these tests pin it to every handler slot. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus_client +from esphome.components.modbus_client import ( + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, + CONF_ON_SENT, + CONF_PDU, + CONFIG_SCHEMA, + MODBUS_CLIENT_SEND_SCHEMA, +) +from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.core import Lambda +from esphome.types import ConfigType + +# Every handler slot on modbus_client.send. All five must reject deferring actions. +HANDLER_KEYS = [ + CONF_ON_SENT, + CONF_ON_RESPONSE, + CONF_ON_ERROR, + CONF_ON_NO_RESPONSE, + CONF_ON_NOT_SENT, +] + +# A deferring action (registered synchronous=False) and a synchronous one, for contrast. +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +# The same deferring action buried inside nested control flow, which the guard must still find. +NESTED_ACTIONS = [ + pytest.param( + [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}], + id="if", + ), + pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"), + pytest.param( + [ + { + "repeat": { + "count": 2, + "then": [ + { + "if": { + "condition": TRUE_CONDITION, + "then": [DEFERRING_ACTION], + } + } + ], + } + } + ], + id="repeat_if", + ), +] + +DEFER_MESSAGE = "Deferring actions" + + +def _config(handler_key: str, actions: list) -> ConfigType: + """A minimal valid modbus_client.send config with one handler populated.""" + return { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + handler_key: {"then": actions}, + } + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_synchronous_handler_accepted(handler_key: str) -> None: + # The guard must not get in the way of an ordinary inline handler. + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +def test_deferring_action_rejected(handler_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION])) + + +@pytest.mark.parametrize("handler_key", HANDLER_KEYS) +@pytest.mark.parametrize("actions", NESTED_ACTIONS) +def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None: + # has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught. + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions)) + + +def test_on_no_response_lambda_form_accepted() -> None: + # The returning-lambda form has no action list; the guard is a no-op on it. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: Lambda("return false;"), + } + ) + + +def test_on_no_response_retry_lambda_accepted() -> None: + # The automation form may also carry a nested retry: lambda. + MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0x01, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01], + CONF_ON_NO_RESPONSE: { + "then": [SYNCHRONOUS_ACTION], + "retry": Lambda("return true;"), + }, + } + ) + + +# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin +# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other +# components rather than this one. + + +def test_component_requires_an_address() -> None: + """The address identifies the device on the bus, so there is no sensible default.""" + with pytest.raises(cv.Invalid, match=CONF_ADDRESS): + CONFIG_SCHEMA({CONF_ID: "bare_client"}) + + +def test_component_requires_an_id() -> None: + """The device is reachable only through id() in a lambda, so a generated id would be dead config.""" + with pytest.raises(cv.Invalid, match=CONF_ID): + CONFIG_SCHEMA({CONF_ADDRESS: 0x01}) + + +def test_component_accepts_an_id_and_address() -> None: + """modbus_id stays optional: it resolves to the single hub when only one is declared.""" + config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01}) + assert config[CONF_ADDRESS] == 0x01 + + +def test_component_rejects_an_out_of_range_address() -> None: + """A Modbus device address is one byte.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100}) + + +def test_multi_conf_no_default_is_set() -> None: + """Load-bearing: the modbus hub auto-loads this component to register its actions. + + Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required + address above - breaking every configuration that uses modbus but never declares a modbus_client + block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason. + """ + assert modbus_client.MULTI_CONF is True + assert modbus_client.MULTI_CONF_NO_DEFAULT is True diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 7c978a5cd5..ce1098fbca 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -7,8 +7,13 @@ from esphome.components.modbus_server import ( SERVER_SENSOR_VALUE_TYPE, _validate_no_overlapping_registers, _validate_register_ranges, + _validate_unique_bit_addresses, +) +from esphome.components.modbus_server.const import ( + CONF_BITS, + CONF_REGISTERS, + CONF_VALUE_TYPE, ) -from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE from esphome.const import CONF_ADDRESS @@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict: } +def _bits_config(addresses: list[int]) -> dict: + return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]} + + def test_non_overlapping_registers_pass() -> None: # Values that tile the address space without gaps or overlaps are accepted. config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) @@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None: _validate_no_overlapping_registers(config) +def test_unique_bit_addresses_pass() -> None: + config = _bits_config([0x00, 0x01, 0x02]) + assert _validate_unique_bit_addresses(config) is config + + +def test_duplicate_bit_address_rejected() -> None: + # Coils and discrete inputs share one bit address space, so a repeated address is rejected. + config = _bits_config([0x05, 0x05]) + with pytest.raises(cv.Invalid, match="more than once"): + _validate_unique_bit_addresses(config) + + def test_multi_register_value_overlapping_neighbour_rejected() -> None: # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) @@ -82,3 +103,5 @@ def test_raw_value_type_rejected() -> None: with pytest.raises(cv.Invalid): validator("RAW") assert validator("U_WORD") == "U_WORD" + assert validator("U_WORD_S") == "U_WORD_S" + assert validator("S_WORD_S") == "S_WORD_S" diff --git a/tests/component_tests/network/__init__.py b/tests/component_tests/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/network/config/priority_arduino.yaml b/tests/component_tests/network/config/priority_arduino.yaml new file mode 100644 index 0000000000..b66f676601 --- /dev/null +++ b/tests/component_tests/network/config/priority_arduino.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_ethernet_first.yaml b/tests/component_tests/network/config/priority_ethernet_first.yaml new file mode 100644 index 0000000000..d98d19f545 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_rp2040.yaml b/tests/component_tests/network/config/priority_rp2040.yaml new file mode 100644 index 0000000000..984f2dcbb4 --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_single.yaml b/tests/component_tests/network/config/priority_single.yaml new file mode 100644 index 0000000000..bd23697808 --- /dev/null +++ b/tests/component_tests/network/config/priority_single.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + priority: + - wifi diff --git a/tests/component_tests/network/config/priority_wifi_first.yaml b/tests/component_tests/network/config/priority_wifi_first.yaml new file mode 100644 index 0000000000..65247f005f --- /dev/null +++ b/tests/component_tests/network/config/priority_wifi_first.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - wifi + - ethernet diff --git a/tests/component_tests/network/config/wifi_only.yaml b/tests/component_tests/network/config/wifi_only.yaml new file mode 100644 index 0000000000..61dfde3e03 --- /dev/null +++ b/tests/component_tests/network/config/wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py new file mode 100644 index 0000000000..017f0711a3 --- /dev/null +++ b/tests/component_tests/network/test_priority.py @@ -0,0 +1,270 @@ +"""Tests for the ``network: priority:`` list validator.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest +from voluptuous import Invalid + +from esphome.components.network import ( + _SETUP_PRIORITY_AFTER_WIFI, + KEY_NETWORK_PRIORITY, + NETWORK_PRIORITY_BASE, + NETWORK_PRIORITY_STEP, + _final_validate, + _validate_priority_list, + get_network_priority, +) +from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.core import CORE +import esphome.final_validate as fv +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture(autouse=True) +def _clear_core_data(): + """Wipe CORE.data and reset fv.full_config so each test starts clean.""" + CORE.data.clear() + token = fv.full_config.set({}) + yield + fv.full_config.reset(token) + CORE.data.clear() + + +def test_validates_plain_string_list() -> None: + result = _validate_priority_list(["ethernet", "wifi"]) + assert result == [{"interface": "ethernet"}, {"interface": "wifi"}] + + +def test_normalizes_mixed_case_to_lowercase() -> None: + # Regression check: mixed-case input must be lowercased so downstream + # callers like get_network_priority("ethernet") find a match. + result = _validate_priority_list(["Ethernet", "WIFI"]) + assert result == [{"interface": "ethernet"}, {"interface": "wifi"}] + + +def test_accepts_all_supported_interface_types() -> None: + # Only ethernet and wifi are currently accepted. Other interface types + # (openthread, modem) will be added when their setup-priority consumers + # land — see NETWORK_PLAN.md. + result = _validate_priority_list(["ethernet", "wifi"]) + assert [e["interface"] for e in result] == ["ethernet", "wifi"] + + +def test_rejects_not_yet_supported_interface() -> None: + # openthread / modem are in the long-term roadmap but no setup-priority + # consumer is wired yet, so VALID_NETWORK_TYPES excludes them today. + with pytest.raises(Invalid): + _validate_priority_list(["ethernet", "openthread"]) + with pytest.raises(Invalid): + _validate_priority_list(["wifi", "modem"]) + + +def test_single_interface_is_valid() -> None: + result = _validate_priority_list(["ethernet"]) + assert result == [{"interface": "ethernet"}] + + +def test_rejects_unknown_interface() -> None: + with pytest.raises(Invalid): + _validate_priority_list(["ethernet", "bluetooth"]) + + +def test_rejects_duplicate_entries() -> None: + with pytest.raises(Invalid, match="Duplicate entries"): + _validate_priority_list(["ethernet", "ethernet"]) + + +def test_rejects_duplicates_regardless_of_case() -> None: + # Same interface in mixed cases should still trip the duplicate check + # after normalization. + with pytest.raises(Invalid, match="Duplicate entries"): + _validate_priority_list(["ethernet", "Ethernet"]) + + +def test_rejects_mapping_form() -> None: + # The mapping form (- ethernet: { timeout: 30s }) was removed when the + # timeout option moved to its consumer PR. Verify we reject it cleanly + # instead of silently accepting a no-op. + with pytest.raises(Invalid): + _validate_priority_list([{"ethernet": {"timeout": "30s"}}]) + + +def test_get_network_priority_returns_none_when_unset() -> None: + assert get_network_priority("ethernet") is None + + +def test_get_network_priority_assigns_base_to_first_entry() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"]) + assert get_network_priority("ethernet") == NETWORK_PRIORITY_BASE + + +def test_get_network_priority_steps_down_by_step_per_position() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"]) + assert get_network_priority("wifi") == NETWORK_PRIORITY_BASE - NETWORK_PRIORITY_STEP + + +def test_get_network_priority_is_case_insensitive_on_query() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"]) + assert get_network_priority("Ethernet") == NETWORK_PRIORITY_BASE + + +def test_get_network_priority_returns_none_for_unlisted_interface() -> None: + CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"]) + assert get_network_priority("wifi") is None + + +def test_final_validate_rejects_priority_iface_without_component() -> None: + """An interface named in 'priority' with no matching component block is rejected.""" + # priority lists wifi, but only ethernet is present in the full config. + fv.full_config.set({"ethernet": {}}) + config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])} + with pytest.raises( + Invalid, match=r"'wifi' is listed in 'network: priority:' but no 'wifi:'" + ): + _final_validate(config) + + +def test_final_validate_accepts_when_all_priority_ifaces_present() -> None: + """No error when every interface in 'priority' has a matching component block.""" + fv.full_config.set({"ethernet": {}, "wifi": {}}) + config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])} + _final_validate(config) # must not raise + + +def test_final_validate_noop_without_priority_list() -> None: + """A network config without a 'priority' list imposes no component requirements.""" + fv.full_config.set({}) + _final_validate({}) # must not raise + + +def test_final_validate_rejects_unsupported_arbitration_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + """The ethernet/wifi-only arbitration tripwire fails as a clean config error. + + Unreachable through the public schema today (VALID_NETWORK_TYPES gates the + list), so the config is hand-built to simulate a future interface type that + was added to the schema without extending NetworkComponent::loop(). + """ + set_core_config(PlatformFramework.ESP32_IDF) + fv.full_config.set({"openthread": {}, "wifi": {}}) + config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]} + with pytest.raises(Invalid, match="arbitration does not support: openthread"): + _final_validate(config) + + +def _cpp_setup_priority(name: str) -> float: + """Read a setup_priority constant straight from esphome/core/component.h.""" + header = Path(__file__).parents[3] / "esphome" / "core" / "component.h" + match = re.search( + rf"inline constexpr float {name} = ([\d.]+)f;", header.read_text() + ) + assert match is not None, f"setup_priority::{name} not found in component.h" + return float(match.group(1)) + + +def test_priority_band_constants_match_cpp_setup_priority() -> None: + """The Python priority-band constants mirror the C++ setup_priority values. + + NETWORK_PRIORITY_BASE must equal the historical setup_priority::WIFI / + ::ETHERNET default so a single-entry priority list reproduces the legacy + setup order, and the band guard must track setup_priority::AFTER_WIFI. + Reading the values from component.h turns a silent desync into a CI + failure if either side is ever rebalanced. + """ + assert _cpp_setup_priority("WIFI") == NETWORK_PRIORITY_BASE + assert _cpp_setup_priority("ETHERNET") == NETWORK_PRIORITY_BASE + assert _cpp_setup_priority("AFTER_WIFI") == _SETUP_PRIORITY_AFTER_WIFI + # Must stay below AFTER_BLUETOOTH (NetworkComponent's own priority) so + # interfaces never set up before esp_netif_init(). + assert _cpp_setup_priority("AFTER_BLUETOOTH") > NETWORK_PRIORITY_BASE + + +def test_wifi_first_priority_emits_primary_interface_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A wifi-first priority list emits USE_NETWORK_PRIMARY_INTERFACE_WIFI.""" + generate_main(component_config_path("priority_wifi_first.yaml")) + defines = {d.name for d in CORE.defines} + assert "USE_NETWORK_PRIMARY_INTERFACE_WIFI" in defines + # Emitted by cg.set_setup_priority() at the wifi/ethernet call sites. + assert "USE_SETUP_PRIORITY_OVERRIDE" in defines + + +def test_ethernet_first_priority_emits_no_primary_interface_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Ethernet-first matches the built-in preference order, so no define is emitted.""" + generate_main(component_config_path("priority_ethernet_first.yaml")) + assert not any( + d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines + ) + # The setup-priority overrides themselves are still emitted. + assert "USE_SETUP_PRIORITY_OVERRIDE" in {d.name for d in CORE.defines} + + +def test_no_primary_interface_define_without_priority( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without a priority list, no primary-interface define is emitted.""" + generate_main(component_config_path("wifi_only.yaml")) + assert not any( + d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines + ) + + +def _dns_per_default_netif_option() -> bool | None: + from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS + + if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( + "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" + ) + + +@pytest.mark.parametrize( + "config_file", + [ + "priority_wifi_first.yaml", + "priority_ethernet_first.yaml", + "priority_arduino.yaml", + ], +) +def test_multi_interface_priority_enables_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """More than one interface in 'priority' enables default-route arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is True + + +@pytest.mark.parametrize( + "config_file", + [ + # Single-entry priority list / no list at all. + "priority_single.yaml", + "wifi_only.yaml", + # Dual-interface on rp2040: validates, but the arbitration is ESP32-only + # (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the + # define here would be a hard build break. + "priority_rp2040.yaml", + ], +) +def test_single_interface_has_no_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Single-interface and non-ESP32 configs must not compile in the arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is None diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 39bffd31b7..418cab5ea1 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, _substitute_package_definition, @@ -1694,3 +1695,34 @@ def test_resolve_packages_does_not_apply_extend_remove() -> None: # over the package value during merge), and the marker is not # resolved by this wrapper. assert isinstance(result[CONF_WIFI], Remove) + + +@patch("esphome.git.clone_or_update") +def test_remote_package_registers_checkout_for_secret_scan( + mock_clone_or_update, tmp_path: Path +) -> None: + """Loading a remote package registers its path-narrowed checkout dir + as a bundle secret-scan dir (issue 18023).""" + repo_root = tmp_path / "repo" + package_dir = repo_root / "packages" + package_dir.mkdir(parents=True) + (package_dir / "base.yml").write_text( + f"sensor:\n - platform: {TEST_SENSOR_PLATFORM_1}\n name: {TEST_SENSOR_NAME_1}\n" + ) + mock_clone_or_update.return_value = (repo_root, None) + + config = { + CONF_PACKAGES: { + "package1": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_PATH: "packages", + CONF_FILES: ["base.yml"], + CONF_REFRESH: "1d", + } + } + } + packages_pass(config) + + assert package_dir in bundle._get_data().secret_scan_dirs + assert repo_root not in bundle._get_data().secret_scan_dirs diff --git a/tests/component_tests/preferences/__init__.py b/tests/component_tests/preferences/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/preferences/config/bk72xx.yaml b/tests/component_tests/preferences/config/bk72xx.yaml new file mode 100644 index 0000000000..9ea4154bca --- /dev/null +++ b/tests/component_tests/preferences/config/bk72xx.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +bk72xx: + board: generic-bk7252 diff --git a/tests/component_tests/preferences/config/esp32.yaml b/tests/component_tests/preferences/config/esp32.yaml new file mode 100644 index 0000000000..586979d7b6 --- /dev/null +++ b/tests/component_tests/preferences/config/esp32.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp32: + board: esp32dev diff --git a/tests/component_tests/preferences/config/esp8266.yaml b/tests/component_tests/preferences/config/esp8266.yaml new file mode 100644 index 0000000000..b8a1035159 --- /dev/null +++ b/tests/component_tests/preferences/config/esp8266.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp8266: + board: esp01_1m diff --git a/tests/component_tests/preferences/config/host.yaml b/tests/component_tests/preferences/config/host.yaml new file mode 100644 index 0000000000..047f8693a8 --- /dev/null +++ b/tests/component_tests/preferences/config/host.yaml @@ -0,0 +1,4 @@ +esphome: + name: preftest + +host: diff --git a/tests/component_tests/preferences/config/nrf52.yaml b/tests/component_tests/preferences/config/nrf52.yaml new file mode 100644 index 0000000000..00892addb5 --- /dev/null +++ b/tests/component_tests/preferences/config/nrf52.yaml @@ -0,0 +1,6 @@ +esphome: + name: preftest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 diff --git a/tests/component_tests/preferences/config/rp2.yaml b/tests/component_tests/preferences/config/rp2.yaml new file mode 100644 index 0000000000..d57b96a54e --- /dev/null +++ b/tests/component_tests/preferences/config/rp2.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +rp2: + board: rpipicow diff --git a/tests/component_tests/preferences/test_key_lookup_gate.py b/tests/component_tests/preferences/test_key_lookup_gate.py new file mode 100644 index 0000000000..7726113aab --- /dev/null +++ b/tests/component_tests/preferences/test_key_lookup_gate.py @@ -0,0 +1,39 @@ +"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from +codegen (key-lookup backends) or must not (slot-based backends, whose managers +have no load_from_key()). Run each platform's real codegen and assert the +emission, mirroring the split the deny-list in esphome/core/defines.h assumes +for static analysis. + +The fixtures cover every distinct preferences backend today: ln882x and +rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is +an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new +fixture here.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + + +@pytest.mark.parametrize( + ("fixture", "emits"), + [ + ("esp32.yaml", True), + ("bk72xx.yaml", True), # libretiny + ("host.yaml", True), + ("nrf52.yaml", True), # zephyr + ("esp8266.yaml", False), + ("rp2.yaml", False), + ], +) +def test_key_lookup_define_matches_the_platform_backend( + fixture: str, + emits: bool, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + defines = {define.name for define in CORE.defines} + assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits diff --git a/tests/component_tests/rp2040_ble/__init__.py b/tests/component_tests/rp2040_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml new file mode 100644 index 0000000000..93c769283f --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml @@ -0,0 +1,15 @@ +esphome: + name: poolwrap-rp2-default + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml new file mode 100644 index 0000000000..4e9c94df59 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-single + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 1 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml new file mode 100644 index 0000000000..c631562743 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-two + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 2 diff --git a/tests/component_tests/rp2040_ble/test_connection_slots.py b/tests/component_tests/rp2040_ble/test_connection_slots.py new file mode 100644 index 0000000000..f33180e2d0 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_connection_slots.py @@ -0,0 +1,41 @@ +"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and +final validation rejects over-subscription with the consumer list.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import rp2040_ble +from esphome.core import CORE + + +def test_proxy_claims_its_slots_through_the_shared_accounting( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # A default (3-slot) proxy build records one claim per slot, attributed + # to the consumer, and passes final validation. + generate_main(component_config_path("rp2_proxy_default.yaml")) + used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS] + assert used == ["bluetooth_proxy"] * 3 + + +def test_oversubscription_is_rejected_with_the_consumer_list() -> None: + # No YAML shape reaches this today (the proxy schema caps at the same + # limit); the guard exists for a second consumer such as ble_client. + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.consume_connection_slots(1, "ble_client")({}) + with pytest.raises( + cv.Invalid, + match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client", + ): + rp2040_ble.validate_connection_slots() + + +def test_at_cap_passes() -> None: + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.validate_connection_slots() diff --git a/tests/component_tests/rp2040_ble/test_pool_wrap.py b/tests/component_tests/rp2040_ble/test_pool_wrap.py new file mode 100644 index 0000000000..291ca5eb58 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_pool_wrap.py @@ -0,0 +1,52 @@ +"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags +that swap the prebuilt single-client pools for the codegen-sized ones; +single-slot builds emit none and stay byte-identical to previous releases.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +from ..helpers import get_define_value + +# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo +# in the component's list fails here instead of mirroring into the test. +WRAP_FLAGS = ( + "-Wl,--wrap=btstack_memory_gatt_client_get", + "-Wl,--wrap=btstack_memory_gatt_client_free", + "-Wl,--wrap=btstack_memory_hci_connection_get", + "-Wl,--wrap=btstack_memory_hci_connection_free", +) + + +def test_default_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_default.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3" + + +def test_two_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # Two slots: the wrap pools are smaller than the cap, sized from the count. + generate_main(component_config_path("rp2_proxy_two_slots.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2" + + +def test_single_slot_keeps_the_prebuilt_pools( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_single_slot.yaml")) + assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1" diff --git a/tests/component_tests/safe_mode/__init__.py b/tests/component_tests/safe_mode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/safe_mode/test_safe_mode.py b/tests/component_tests/safe_mode/test_safe_mode.py new file mode 100644 index 0000000000..617a3b4855 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode.py @@ -0,0 +1,45 @@ +"""Tests for the safe_mode component.""" + +from collections.abc import Callable + +from esphome.core import CORE + +SHUTDOWN_DEFINE = "USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN" + + +def _has_define(name: str) -> bool: + return any(define.name == name for define in CORE.defines) + + +def test_boot_is_good_on_shutdown_default( + generate_main: Callable[[str], str], +) -> None: + """By default, an orderly shutdown confirms the app image.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_default.yaml" + ) + + assert "safe_mode::SafeModeComponent" in main_cpp + assert _has_define(SHUTDOWN_DEFINE) + + +def test_boot_is_good_on_shutdown_disabled( + generate_main: Callable[[str], str], +) -> None: + """With boot_is_good_on_shutdown: false, the define is not added.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml" + ) + + assert "safe_mode::SafeModeComponent" in main_cpp + assert not _has_define(SHUTDOWN_DEFINE) + + +def test_safe_mode_disabled(generate_main: Callable[[str], str]) -> None: + """With safe_mode disabled, no component and no define are generated.""" + main_cpp = generate_main( + "tests/component_tests/safe_mode/test_safe_mode_disabled.yaml" + ) + + assert "safe_mode::SafeModeComponent" not in main_cpp + assert not _has_define(SHUTDOWN_DEFINE) diff --git a/tests/component_tests/safe_mode/test_safe_mode_default.yaml b/tests/component_tests/safe_mode/test_safe_mode_default.yaml new file mode 100644 index 0000000000..07c38250aa --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: diff --git a/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml b/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml new file mode 100644 index 0000000000..ac9b224191 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_disabled.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: + disabled: true diff --git a/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml b/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml new file mode 100644 index 0000000000..64df87b381 --- /dev/null +++ b/tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +safe_mode: + boot_is_good_on_shutdown: false diff --git a/tests/component_tests/sendspin/__init__.py b/tests/component_tests/sendspin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py new file mode 100644 index 0000000000..be3b7d6684 --- /dev/null +++ b/tests/component_tests/sendspin/test_image.py @@ -0,0 +1,114 @@ +"""Validation tests for the sendspin image platform. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data +from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _slot_config(**overrides: Any) -> ConfigType: + """Build a minimal valid artwork slot config, allowing field overrides.""" + config: ConfigType = { + "id": "album_slot", + "format": "JPEG", + "type": "RGB565", + "resize": "240x240", + "current_image": {"id": "album_art"}, + } + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config()) + + assert config["slot"] == 0 + assert config["source"] == "ALBUM" + assert config["display_offset"].total_milliseconds == 0 + + +@pytest.mark.parametrize("image_format", ["JPEG", "JPG"]) +def test_jpeg_alias_maps_to_one_enum( + set_core_config: SetCoreConfigCallable, image_format: str +) -> None: + """runtime_image takes JPG as an alias for JPEG, so both spellings must reach the + library's single JPEG enum.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_slot_config(format=image_format)) + + assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG + + +def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" + set_core_config(PlatformFramework.ESP32_IDF) + + for slot in range(MAX_ARTWORK_SLOTS): + assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot + + with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"): + CONFIG_SCHEMA(_slot_config(id="one_too_many")) + + +@pytest.mark.parametrize( + "resize", + [f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"], +) +def test_oversized_resize_rejected( + set_core_config: SetCoreConfigCallable, resize: str +) -> None: + """Either dimension past the decoder's limit is refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"): + CONFIG_SCHEMA(_slot_config(resize=resize)) + + +def test_sub_millisecond_display_offset_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The library field is whole milliseconds, so finer values are refused + rather than silently rounded down to zero.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"): + CONFIG_SCHEMA(_slot_config(display_offset="500us")) + + +@pytest.mark.parametrize("display_offset", ["61s", "-61s"]) +def test_out_of_range_display_offset_rejected( + set_core_config: SetCoreConfigCallable, display_offset: str +) -> None: + """Offsets more than a minute either side of the boundary are refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="value must be at (most|least)"): + CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + +@pytest.mark.parametrize( + ("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)] +) +def test_display_offset_accepted( + set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int +) -> None: + """Whole-millisecond offsets pass through in both directions.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + assert config["display_offset"].total_milliseconds == expected_ms diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py index 82635b26da..183c586a36 100644 --- a/tests/component_tests/web_server/test_web_server_auth.py +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, ) -> None: - """Auth type basic builds Basic and does not warn.""" - generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + """Auth type basic on ESP32 uses plaintext credentials and does not warn.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic.yaml" + ) + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp assert _has_define("USE_WEBSERVER_AUTH") assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") assert _DEFAULT_CHANGE_WARNING not in caplog.text +def test_web_server_auth_basic_esp8266_uses_precomputed_hash( + generate_main: Callable[[str], str], +) -> None: + """Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml" + ) + + assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp + assert "set_auth_username" not in main_cpp + assert "set_auth_password" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + + +def test_web_server_auth_digest_esp8266_uses_plaintext_credentials( + generate_main: Callable[[str], str], +) -> None: + """Auth type digest on ESP8266 uses plaintext credentials, not the basic hash.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + + def test_web_server_auth_explicit_digest( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, diff --git a/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml new file mode 100644 index 0000000000..79e0c0ccf5 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml new file mode 100644 index 0000000000..59565f8733 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/README.md b/tests/components/README.md index 145a3440d2..be5e887767 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -28,6 +28,7 @@ create an `__init__.py` in your component's test directory and define `override_ ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: # Re-enable the component's own to_code (needed when the component must # emit C++ setup code that the test binary depends on at link time). @@ -39,6 +40,7 @@ Or supply a lightweight stub instead of the real `to_code`: ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config): # Only emit what the C++ tests actually need @@ -54,6 +56,7 @@ e.g. `tests/components/my_sensor/sensor/__init__.py`): ```python from tests.testing_helpers import ComponentManifestOverride + def override_manifest(manifest: ComponentManifestOverride) -> None: manifest.enable_codegen() ``` diff --git a/tests/components/adc/validate.rp2040-ard.yaml b/tests/components/adc/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..cbe15f2746 --- /dev/null +++ b/tests/components/adc/validate.rp2040-ard.yaml @@ -0,0 +1,7 @@ +# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform. +# Remove before 2027.2.0 +sensor: + - id: adc_temperature_sensor + platform: adc + pin: TEMPERATURE + name: ADC Test temperature diff --git a/tests/components/airthings_ble/common-ln.yaml b/tests/components/airthings_ble/common-ln.yaml new file mode 100644 index 0000000000..292192f052 --- /dev/null +++ b/tests/components/airthings_ble/common-ln.yaml @@ -0,0 +1 @@ +airthings_ble: diff --git a/tests/components/airthings_ble/common.yaml b/tests/components/airthings_ble/common.yaml new file mode 100644 index 0000000000..347f6640ad --- /dev/null +++ b/tests/components/airthings_ble/common.yaml @@ -0,0 +1,6 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/airthings_ble/test.esp32-idf.yaml b/tests/components/airthings_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..5883578909 --- /dev/null +++ b/tests/components/airthings_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + airthings_ble: !include common.yaml diff --git a/tests/components/airthings_ble/test.ln882x-ard.yaml b/tests/components/airthings_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..aa0ec6f7ab --- /dev/null +++ b/tests/components/airthings_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + airthings_ble: !include common-ln.yaml diff --git a/tests/components/airthings_ble/validate.bk72xx-ard.yaml b/tests/components/airthings_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..efcde0990e --- /dev/null +++ b/tests/components/airthings_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/aqi/benchmark.yaml b/tests/components/aqi/benchmark.yaml new file mode 100644 index 0000000000..d0d54c50b0 --- /dev/null +++ b/tests/components/aqi/benchmark.yaml @@ -0,0 +1,16 @@ +# Declares the component graph the C++ unit test build needs so that the aqi +# component's sources (which include sensor.h) compile. to_code is suppressed by +# the test harness; this only pulls the sensor + aqi source/include paths in. +# Loaded with plain yaml.safe_load, so avoid lambdas / ESPHome-tagged values here. +sensor: + - platform: template + id: pm25_sensor + name: "PM2.5" + - platform: template + id: pm10_sensor + name: "PM10" + - platform: aqi + name: "AQI" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI diff --git a/tests/components/aqi/common.yaml b/tests/components/aqi/common.yaml index 4c8cbbfa3f..cddc1f77cd 100644 --- a/tests/components/aqi/common.yaml +++ b/tests/components/aqi/common.yaml @@ -20,3 +20,10 @@ sensor: pm_2_5: pm25_sensor pm_10_0: pm10_sensor calculation_type: CAQI + + - platform: aqi + name: "Air Quality Index (AQI, extended)" + pm_2_5: pm25_sensor + pm_10_0: pm10_sensor + calculation_type: AQI + extended_range: true diff --git a/tests/components/aqi/test_aqi_calculator.cpp b/tests/components/aqi/test_aqi_calculator.cpp new file mode 100644 index 0000000000..ab95d3ac92 --- /dev/null +++ b/tests/components/aqi/test_aqi_calculator.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/aqi/aqi_calculator.h" +#include "esphome/components/aqi/caqi_calculator.h" + +namespace esphome::aqi::testing { + +// US AQI (EPA 2024): PM2.5 225.5-500.4 -> 301-500, PM10 425-604 -> 301-500. + +TEST(USAQI, LowRangeUnaffectedByExtendedFlag) { + AQICalculator calc; + // PM2.5 25 drives over PM10 50; well below the top band, so the flag changes nothing. + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 81); + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, true), 81); +} + +TEST(USAQI, HazardousInterpolatesNotPinnedAt301) { + AQICalculator calc; + // Regression guard: the old FLT_MAX top bucket collapsed every hazardous reading to 301. + EXPECT_EQ(calc.get_aqi(225.5f, 0.0f, false), 301); // band start + EXPECT_EQ(calc.get_aqi(250.0f, 0.0f, false), 319); // interpolated, not 301 + EXPECT_EQ(calc.get_aqi(500.4f, 0.0f, false), 500); // band top +} + +TEST(USAQI, DefaultClampsAtStandardMaximum) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, false), 500); + EXPECT_EQ(calc.get_aqi(0.0f, 604.0f, false), 500); // PM10 top breakpoint +} + +TEST(USAQI, ExtendedRangeExtrapolatesBeyond500) { + AQICalculator calc; + EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, true), 572); + EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, true), 862); + EXPECT_EQ(calc.get_aqi(0.0f, 700.0f, true), 607); // PM10 extrapolated past 500 +} + +TEST(USAQI, ExtendedRangeSaturatesUint16NoWraparound) { + AQICalculator calc; + // An absurd concentration would overflow uint16_t; it must saturate, not wrap to a small value. + EXPECT_EQ(calc.get_aqi(100000.0f, 0.0f, true), 65535); +} + +TEST(USAQI, WorseOfTwoPollutantsWins) { + AQICalculator calc; + // PM10 604 -> 500 dominates PM2.5 25 -> 81. + EXPECT_EQ(calc.get_aqi(25.0f, 604.0f, false), 500); +} + +// CAQI (CITEAIR): no maximum by spec -- the top ">100" class is open, so it is always unbounded +// and the extended_range flag does not apply. + +TEST(CAQI, LowRange) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 50); +} + +TEST(CAQI, ContinuousAt100NoPinAt101) { + CAQICalculator calc; + // Old code pinned everything above the top breakpoint to 101; now it reaches exactly 100. + EXPECT_EQ(calc.get_aqi(110.1f, 0.0f, false), 100); +} + +TEST(CAQI, UnboundedAboveTopBand) { + CAQICalculator calc; + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, false), 139); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, false), 925); +} + +TEST(CAQI, ExtendedRangeFlagIsIgnored) { + CAQICalculator calc; + // CAQI is always unbounded, so the flag must make no difference either way. + EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, true), calc.get_aqi(200.0f, 0.0f, false)); + EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, true), calc.get_aqi(2000.0f, 0.0f, false)); +} + +TEST(CAQI, SaturatesUint16NoWraparound) { + CAQICalculator calc; + // CAQI is unbounded, so an extreme reading can extrapolate past uint16_t; it must saturate, + // not wrap around to a small (falsely "good") value. + EXPECT_EQ(calc.get_aqi(200000.0f, 0.0f, false), 65535); +} + +} // namespace esphome::aqi::testing diff --git a/tests/components/atc_mithermometer/common-ln.yaml b/tests/components/atc_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..78787099dc --- /dev/null +++ b/tests/components/atc_mithermometer/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Temperature + humidity: + name: ATC Humidity diff --git a/tests/components/atc_mithermometer/common.yaml b/tests/components/atc_mithermometer/common.yaml index 0248090c23..c6da2fa173 100644 --- a/tests/components/atc_mithermometer/common.yaml +++ b/tests/components/atc_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: atc_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: ATC Temperature diff --git a/tests/components/atc_mithermometer/test.ln882x-ard.yaml b/tests/components/atc_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..144ba0e3f8 --- /dev/null +++ b/tests/components/atc_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + atc_mithermometer: !include common-ln.yaml diff --git a/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..44bc401caa --- /dev/null +++ b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,10 @@ +# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates. +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + - platform: atc_mithermometer + esp32_ble_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Legacy Key Temperature diff --git a/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..7528dbf8dd --- /dev/null +++ b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,17 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: atc_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK ATC Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK ATC Implicit Temperature diff --git a/tests/components/b_parasite/common-ln.yaml b/tests/components/b_parasite/common-ln.yaml new file mode 100644 index 0000000000..797b94c76f --- /dev/null +++ b/tests/components/b_parasite/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature diff --git a/tests/components/b_parasite/common.yaml b/tests/components/b_parasite/common.yaml index 262e891bb2..e603d058b7 100644 --- a/tests/components/b_parasite/common.yaml +++ b/tests/components/b_parasite/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: b_parasite + ble_hub_id: ble_tracker_hub mac_address: F0:CA:F0:CA:01:01 humidity: name: b-parasite Air Humidity diff --git a/tests/components/b_parasite/test.ln882x-ard.yaml b/tests/components/b_parasite/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f711f63aa7 --- /dev/null +++ b/tests/components/b_parasite/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + b_parasite: !include common-ln.yaml diff --git a/tests/components/b_parasite/validate.bk72xx-ard.yaml b/tests/components/b_parasite/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fab4895d7b --- /dev/null +++ b/tests/components/b_parasite/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: b_parasite + ble_hub_id: ble_tracker_hub + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature + moisture: + name: b-parasite Soil Moisture + battery_voltage: + name: b-parasite Battery Voltage + illuminance: + name: b-parasite Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:02 + temperature: + name: BK b-parasite Implicit Temperature diff --git a/tests/components/bk72xx_ble/common.yaml b/tests/components/bk72xx_ble/common.yaml new file mode 100644 index 0000000000..5ada71a141 --- /dev/null +++ b/tests/components/bk72xx_ble/common.yaml @@ -0,0 +1,2 @@ +bk72xx_ble: + enable_on_boot: true diff --git a/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e5009aa940 --- /dev/null +++ b/tests/components/bk72xx_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble: !include common.yaml diff --git a/tests/components/bk72xx_ble_tracker/common-boundary.yaml b/tests/components/bk72xx_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..24844e18a5 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common-boundary.yaml @@ -0,0 +1,11 @@ +bk72xx_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + continuous: false diff --git a/tests/components/bk72xx_ble_tracker/common.yaml b/tests/components/bk72xx_ble_tracker/common.yaml new file mode 100644 index 0000000000..d8787a9347 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common.yaml @@ -0,0 +1,7 @@ +bk72xx_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 30ms + duration: 5min + continuous: true diff --git a/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml new file mode 100644 index 0000000000..e110369b0b --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml @@ -0,0 +1,54 @@ +packages: + bk72xx_ble_tracker: !include common.yaml + +esphome: + on_boot: + then: + - bk72xx_ble_tracker.start_scan + - bk72xx_ble_tracker.start_scan: + continuous: true + - bk72xx_ble_tracker.stop_scan + - bk72xx_ble_tracker.stop_scan: ble_tracker + +bk72xx_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); + - mac_address: + - AC:37:43:77:5F:4C + - AC:37:43:77:5F:4D + then: + - lambda: |- + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); + on_ble_service_data_advertise: + - service_uuid: ABCD + # mac_address exercises the UUID triggers' set_address() codegen branch. + mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + - service_uuid: ABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "32-bit service data is %zu", x.size()); + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended"); diff --git a/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml new file mode 100644 index 0000000000..932eb4fb55 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common-boundary.yaml diff --git a/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml new file mode 100644 index 0000000000..ad25fd6b64 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Passive scanning variant: the package merge keeps the shared parameters from +# common.yaml and overrides only the mode. +packages: + bk72xx_ble_tracker: !include common.yaml + +bk72xx_ble_tracker: + scan_parameters: + active: false diff --git a/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fc89479d06 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common.yaml diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py new file mode 100644 index 0000000000..4dbd0becd8 --- /dev/null +++ b/tests/components/ble_device_base/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # resolve_irk() is compiled only when a sensor configures irk: + # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has + # no sensors, so emit the define here to put the real IRK path under test. + # Likewise the scan-response merger (emitted by the split-report trackers) + # and the listener vector it dispatches into (codegen-sized by consumers). + async def to_code_testing(config): + cg.add_define("USE_BLE_DEVICE_IRK") + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4) + + manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp new file mode 100644 index 0000000000..7ff318b66b --- /dev/null +++ b/tests/components/ble_device_base/test_address.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// from_scan_result() ingests BLE controller order (LSB-first); the public +// accessors must expose the historical esp32 semantics: address() in printable +// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str_to() +// printed MSB-first. +namespace { +// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. +const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}; +} // namespace + +TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) { + ESPBTDevice device; + device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0); + + const uint8_t *raw = device.address(); + EXPECT_EQ(raw[0], 0xaa); // MSB first, like ESP-IDF's bda + EXPECT_EQ(raw[5], 0xff); + + EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL); + + char buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + EXPECT_STREQ(device.address_str_to(buf), "AA:BB:CC:DD:EE:FF"); + + // The deprecated wrapper must keep returning the same string until its 2027.2.0 removal. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF"); +#pragma GCC diagnostic pop +} + +// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement +// callback delivers into the printable-order uint64 the native API speaks — the +// value esp32_ble::ble_addr_to_uint64() has always produced for that address. +TEST(BleDeviceAddress, MacLsbFirstToUint64MatchesWireValue) { + EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), 0xAABBCCDDEEFFULL); +} + +// The helper and the parsed-device accessor are two routes to the same wire +// value: byte order must agree no matter which path an advertisement takes. +TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) { + ESPBTDevice device; + device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0); + EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64()); +} + +// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields +// printable (MSB-first) order, and round-tripping through the LSB-first +// packer restores the original value. +TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) { + uint8_t msb_first[6]; + uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first); + EXPECT_EQ(msb_first[0], 0xaa); + EXPECT_EQ(msb_first[5], 0xff); + uint8_t lsb_first[6]; + for (int i = 0; i < 6; i++) + lsb_first[i] = msb_first[5 - i]; + EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_adv_name.cpp b/tests/components/ble_device_base/test_adv_name.cpp new file mode 100644 index 0000000000..44fb3525ea --- /dev/null +++ b/tests/components/ble_device_base/test_adv_name.cpp @@ -0,0 +1,91 @@ +#include "esphome/components/ble_device_base/ble_device.h" + +#include + +#include +#include +#include + +namespace esphome::ble_device_base { +namespace { + +// AD types under test +constexpr uint8_t AD_SHORT_NAME = 0x08; +constexpr uint8_t AD_COMPLETE_NAME = 0x09; + +void append_name(std::vector &adv, uint8_t ad_type, const char *name) { + size_t len = strlen(name); + adv.push_back(static_cast(len + 1)); + adv.push_back(ad_type); + adv.insert(adv.end(), name, name + len); +} + +ESPBTDevice device_from(const std::vector &adv) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace + +TEST(BleAdvName, ParsesACompleteName) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "TP96"); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name(), "TP96"); + // The backing buffer is NUL-terminated so c_str() is usable directly. + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, LongestNameWinsShortenedThenComplete) { + // A merged adv + scan-response frame can carry both forms; the shortened + // one must never replace the complete one. + std::vector adv; + append_name(adv, AD_SHORT_NAME, "Radon"); + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, LongestNameWinsCompleteThenShortened) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + append_name(adv, AD_SHORT_NAME, "Radon"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, MaxLengthNameFitsAndTerminates) { + // 29 bytes is the largest name a legacy AD element can carry and exactly + // fills the fixed buffer. + std::string max_name(29, 'a'); + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, max_name.c_str()); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name().size(), 29u); + EXPECT_EQ(device.get_name(), max_name); + EXPECT_STREQ(device.get_name().c_str(), max_name.c_str()); +} + +TEST(BleAdvName, ReparseResetsThePreviousName) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + std::vector first; + append_name(first, AD_COMPLETE_NAME, "RadonEye"); + std::vector second; + append_name(second, AD_COMPLETE_NAME, "TP96"); + + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, first.data(), static_cast(first.size())); + ASSERT_EQ(device.get_name(), "RadonEye"); + // A shorter name from a fresh report must fully replace the longer one: + // the longest-name rule applies within one report, not across reports. + device.from_scan_result(mac, -59, 0, second.data(), static_cast(second.size())); + EXPECT_EQ(device.get_name(), "TP96"); + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, NoNamePresentIsEmpty) { + std::vector adv = {0x02, 0x0A, 0x00}; // TX power only + EXPECT_TRUE(device_from(adv).get_name().empty()); +} + +} // namespace esphome::ble_device_base diff --git a/tests/components/ble_device_base/test_aes_ccm.cpp b/tests/components/ble_device_base/test_aes_ccm.cpp new file mode 100644 index 0000000000..c844a30b2c --- /dev/null +++ b/tests/components/ble_device_base/test_aes_ccm.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_aes_ccm.h" + +namespace esphome::ble_device_base::testing { + +// Reference vector generated with Python `cryptography` AESCCM(tag_length=4), +// using the same AES-128-CCM parameters BTHome advertisements use: a 16-byte +// key, a 13-byte nonce, a 4-byte authentication tag and no associated data. +namespace { +const uint8_t KEY[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; +const uint8_t NONCE[13] = {0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c}; +const uint8_t CIPHERTEXT[7] = {0x68, 0xb4, 0xf6, 0xc5, 0x2b, 0xf8, 0xaf}; +const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56}; +const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01}; +} // namespace + +// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD +// (0x11). Both the AAD block and the l = 3 length encoding are only reachable +// through this shape, so they need their own vector. Generated the same way. +namespace { +const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b}; +const uint8_t AAD_XIAOMI[1] = {0x11}; +const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23}; +const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6}; +const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00}; +} // namespace + +TEST(BleAesCcm, DecryptsXiaomiShapedVector) { + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); + EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI))); +} + +TEST(BleAesCcm, RejectsWrongAssociatedData) { + uint8_t bad_aad[sizeof(AAD_XIAOMI)]; + memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI)); + bad_aad[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); +} + +TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) { + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, + sizeof(TAG))); + EXPECT_EQ(0, memcmp(out, PLAINTEXT, sizeof(PLAINTEXT))); +} + +TEST(BleAesCcm, RejectsTamperedTag) { + uint8_t bad_tag[sizeof(TAG)]; + memcpy(bad_tag, TAG, sizeof(TAG)); + bad_tag[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, bad_tag, + sizeof(bad_tag))); +} + +TEST(BleAesCcm, RejectsTamperedCiphertext) { + uint8_t bad_ct[sizeof(CIPHERTEXT)]; + memcpy(bad_ct, CIPHERTEXT, sizeof(CIPHERTEXT)); + bad_ct[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE( + aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, bad_ct, sizeof(bad_ct), out, TAG, sizeof(TAG))); +} + +TEST(BleAesCcm, RejectsWrongKey) { + uint8_t bad_key[sizeof(KEY)]; + memcpy(bad_key, KEY, sizeof(KEY)); + bad_key[0] ^= 0xFF; + uint8_t out[sizeof(PLAINTEXT)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(bad_key, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, + sizeof(TAG))); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_ble_uuid.cpp b/tests/components/ble_device_base/test_ble_uuid.cpp new file mode 100644 index 0000000000..7e99ce8a95 --- /dev/null +++ b/tests/components/ble_device_base/test_ble_uuid.cpp @@ -0,0 +1,95 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// A 16- or 32-bit UUID must compare equal to its 128-bit Bluetooth Base UUID form, matching +// esp32_ble_tracker. The 128-bit raw is the base UUID (LSB-first) with the short value at +// bytes 12.. : here 0x1234 -> bytes [12]=0x34, [13]=0x12. +TEST(BleDeviceUuid, ShortFormMatchesEquivalentLongForm) { + const ESPBTUUID u16 = ESPBTUUID::from_uint16(0x1234); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128); + EXPECT_TRUE(u16 == u128); + EXPECT_TRUE(u128 == u16); // symmetric +} + +TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) { + const ESPBTUUID u32 = ESPBTUUID::from_uint32(0x1122AAFF); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0xFF, 0xAA, 0x22, 0x11}; + const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128); + EXPECT_TRUE(u32 == u128); +} + +// A default-constructed UUID is UNSET, the historical "not configured" sentinel +// (len 0 through the esp32 get_uuid() adapter). +TEST(BleDeviceUuid, DefaultConstructedIsUnset) { + const ESPBTUUID unset; + EXPECT_EQ(unset.type(), ESPBTUUID::Type::UNSET); + EXPECT_TRUE(unset == ESPBTUUID()); + EXPECT_FALSE(unset == ESPBTUUID::from_uint16(0x1234)); + EXPECT_FALSE(unset.contains(0x00, 0x00)); +} + +// Every factory yields a non-UNSET UUID, even for 0x0000: only default construction and a +// failed text parse are unset, keeping type() != UNSET equivalent to the old len > 0 check. +TEST(BleDeviceUuid, AllFactoriesProduceSetUuids) { + const uint8_t raw[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + EXPECT_NE(ESPBTUUID::from_uint16(0x0000).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_uint32(0).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw(raw).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw_reversed(raw).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("180F", 4).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("0000180F", 8).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw(reinterpret_cast(raw), 16).type(), ESPBTUUID::Type::UNSET); + EXPECT_NE(ESPBTUUID::from_raw("6E400001-B5A3-F393-E0A9-E50E24DCCA9E").type(), ESPBTUUID::Type::UNSET); +} + +// 0x0000 is a valid short UUID on real devices (esphome/aioesphomeapi#1742); an unset +// UUID must never compare equal to it. Unset equals only unset. +TEST(BleDeviceUuid, UnsetIsNotEqualToZeroUuid) { + EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_uint16(0x0000)); + EXPECT_FALSE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID()); + const uint8_t base[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_raw(base)); + EXPECT_TRUE(ESPBTUUID() == ESPBTUUID()); + EXPECT_FALSE(ESPBTUUID().as_128bit().is_set()); // widening preserves the unset state + // A configured 0x0000 still matches its own 128-bit base UUID expansion. + EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID::from_raw(base)); +} + +// is_set() is the sentinel check; an unset UUID prints as "None" instead of a +// valid-looking all-zero 128-bit UUID. +TEST(BleDeviceUuid, IsSetAndUnsetToStr) { + char buf[UUID_STR_LEN]; + EXPECT_FALSE(ESPBTUUID().is_set()); + EXPECT_STREQ(ESPBTUUID().to_str(buf), "None"); + EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000).is_set()); + EXPECT_STREQ(ESPBTUUID::from_uint16(0x0000).to_str(buf), "0x0000"); +} + +// Text parsing of an invalid length historically produced a len-0 (unset) UUID. +TEST(BleDeviceUuid, InvalidTextFormParsesToUnset) { + EXPECT_EQ(ESPBTUUID::from_raw("nope", 3).type(), ESPBTUUID::Type::UNSET); +} + +TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) { + EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235)); + const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00}; + // Same low bytes but a non-base prefix is a genuinely different 128-bit UUID. + uint8_t custom[16]; + memcpy(custom, raw128, 16); + custom[0] ^= 0x01; + EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_raw(custom)); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp new file mode 100644 index 0000000000..89eb56642e --- /dev/null +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -0,0 +1,82 @@ +// The GATT client contract compiles in no real build until a hub backend is +// configured; this TU pins it on the host so the header cannot rot unseen. +// The contract is a concept (BLEGattConnection is a per-platform alias), so +// the minimal backend here proves the concept stays satisfiable and routes +// events through the GattClientListener interface the way a real backend does. +#define USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#include + +namespace esphome::ble_device_base::testing { + +// Overrides only what it records; the interface's defaults cover the rest. +class RecordingListener : public GattClientListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; } + + bool connected_{false}; + int discovery_error_{0}; + uint16_t write_handle_{0}; +}; + +class MinimalConnection { + public: + void set_listener(GattClientListener *listener) { this->listener_ = listener; } + + int connect(uint64_t address, uint8_t addr_type) { + this->listener_->on_connection_state(true, 517, 0); + return 0; + } + bool cancel_gatt_disconnect() { return false; } + int gatt_disconnect() { return 0; } + int discover_services() { + this->listener_->on_service_discovery_done(0); + return 0; + } + int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->listener_->on_write_result(handle, 0); + return 0; + } + int read_descriptor(uint16_t handle) { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } + int notify_characteristic(uint16_t handle, bool enable) { return 0; } + int pair() { return GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return 0; + } + GattServiceTable get_service_table() { return {}; } + void release_services() {} + void set_connection_type(ConnectionType ct) {} + + protected: + GattClientListener *listener_{nullptr}; +}; + +static_assert(BLEGattConnectionContract, + "a minimal backend must satisfy the contract the alias asserts"); + +TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { + MinimalConnection connection; + RecordingListener listener; + connection.set_listener(&listener); + EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); + EXPECT_TRUE(listener.connected_); + EXPECT_EQ(connection.discover_services(), 0); + EXPECT_EQ(listener.discovery_error_, 0); + EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); + + // A default table is empty and safe to walk. + GattServiceTable table = connection.get_service_table(); + EXPECT_EQ(table.service_count, 0); + EXPECT_EQ(table.characteristic_count, 0); + EXPECT_EQ(table.descriptor_count, 0); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_ibeacon.cpp b/tests/components/ble_device_base/test_ibeacon.cpp new file mode 100644 index 0000000000..b154742ee2 --- /dev/null +++ b/tests/components/ble_device_base/test_ibeacon.cpp @@ -0,0 +1,158 @@ +#include + +#include +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// from_manufacturer_data() accepts exactly the iBeacon frame: Apple company ID, +// 23 payload bytes, and the 0x02/0x15 sub-type/length prefix. The prefix check +// is stricter than the legacy esp32 parser (which surfaced any 23-byte Apple +// payload as a beacon) — a declared behavior change; these tests pin the +// accept/reject boundary. +namespace { + +ServiceData make_apple_payload(uint8_t sub_type, uint8_t length, size_t size = 23) { + ServiceData data; + data.uuid = ESPBTUUID::from_uint16(0x004C); // Apple company ID + data.data.assign(size, 0); + if (size >= 2) { + data.data[0] = sub_type; + data.data[1] = length; + } + // BeaconData layout: sub_type[0], length[1], proximity_uuid[2..17], + // major[18..19], minor[20..21], signal_power[22] — all wire values big-endian. + if (size >= 23) { + data.data[18] = 0x12; // major 0x1234 + data.data[19] = 0x34; + data.data[20] = 0x56; // minor 0x5678 + data.data[21] = 0x78; + data.data[22] = 0xC5; // signal power -59 dBm + } + return data; +} + +} // namespace + +TEST(BleIBeacon, AcceptsWellFormedFrame) { + auto beacon = ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15)); + ASSERT_TRUE(beacon.has_value()); + // Explicit guard: clang-tidy's unchecked-optional-access models neither + // gtest's ASSERT_TRUE nor value() as a check. + if (beacon.has_value()) { + // Pins every scalar accessor's offset and the on-wire big-endian order. + EXPECT_EQ(beacon->get_major(), 0x1234); + EXPECT_EQ(beacon->get_minor(), 0x5678); + EXPECT_EQ(beacon->get_signal_power(), -59); + } +} + +TEST(BleIBeacon, RejectsWrongSubType) { + // Apple "nearby" and other frames of coincidental length must not parse. + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15)).has_value()); +} + +TEST(BleIBeacon, RejectsWrongLengthByte) { + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x14)).has_value()); +} + +TEST(BleIBeacon, RejectsWrongPayloadSize) { + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 22)).has_value()); + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 24)).has_value()); +} + +TEST(BleIBeacon, RejectsNonAppleCompany) { + auto data = make_apple_payload(0x02, 0x15); + data.uuid = ESPBTUUID::from_uint16(0x0059); // Nordic + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(data).has_value()); +} + +TEST(BleIBeacon, PrefixRejectedFlagsOnlyTheSubTypeCase) { + // The out-param drives the get_ibeacon() diagnostic for frames the legacy + // parser accepted: exactly the 23-byte Apple payload with a wrong prefix. + // Wrong size and non-Apple frames were never accepted and must stay silent. + bool flagged = false; + EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15), &flagged).has_value()); + EXPECT_TRUE(flagged); + + flagged = false; + ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15), &flagged); + EXPECT_FALSE(flagged); + + flagged = false; + ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15, 22), &flagged); + EXPECT_FALSE(flagged); + + flagged = false; + auto nordic = make_apple_payload(0x10, 0x15); + nordic.uuid = ESPBTUUID::from_uint16(0x0059); + ESPBLEiBeacon::from_manufacturer_data(nordic, &flagged); + EXPECT_FALSE(flagged); +} + +namespace { + +// One AD manufacturer-data record: [len][0xFF][company LE][payload...]. +void append_mfr_record(std::vector &adv, uint16_t company, const std::vector &payload) { + adv.push_back(static_cast(1 + 2 + payload.size())); + adv.push_back(0xFF); + adv.push_back(static_cast(company & 0xFF)); + adv.push_back(static_cast(company >> 8)); + adv.insert(adv.end(), payload.begin(), payload.end()); +} + +std::vector beacon_payload(uint8_t sub_type, uint8_t length) { + std::vector p(23, 0); + p[0] = sub_type; + p[1] = length; + p[18] = 0x12; + p[19] = 0x34; + p[20] = 0x56; + p[21] = 0x78; + p[22] = 0xC5; + return p; +} + +ESPBTDevice device_from(const std::vector &adv) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace + +// get_ibeacon() wraps the parser with first-rejection capture and the log +// gate; pin its short circuits so a regression there needs a code change, not +// a review, to surface. +TEST(BleIBeacon, GetIbeaconReturnsBeaconDespitePrecedingRejectedFrame) { + std::vector adv; + append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); // rejected prefix + append_mfr_record(adv, 0x004C, beacon_payload(0x02, 0x15)); // real iBeacon + auto device = device_from(adv); + auto beacon = device.get_ibeacon(); + ASSERT_TRUE(beacon.has_value()); + if (beacon.has_value()) { + EXPECT_EQ(beacon->get_major(), 0x1234); + } +} + +TEST(BleIBeacon, GetIbeaconEmptyWhenOnlyRejectedFrames) { + std::vector adv; + append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); + auto device = device_from(adv); + EXPECT_FALSE(device.get_ibeacon().has_value()); +} + +TEST(BleIBeacon, GetIbeaconEmptyWithoutManufacturerData) { + std::vector adv; + adv.push_back(0x02); // flags record only + adv.push_back(0x01); + adv.push_back(0x06); + auto device = device_from(adv); + EXPECT_FALSE(device.get_ibeacon().has_value()); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_irk.cpp b/tests/components/ble_device_base/test_irk.cpp new file mode 100644 index 0000000000..4f507968c6 --- /dev/null +++ b/tests/components/ble_device_base/test_irk.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::ble_device_base::testing { + +// Reference vector generated with Python `cryptography` AES-128-ECB following +// the RPA resolution procedure (Bluetooth Core, Vol 3 Part H §2.2.2): +// hash = e(IRK, prand), where prand is the top 3 address bytes and the hash +// must equal the low 3 address bytes. +namespace { +const uint8_t IRK[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; +// 4A:2B:7C:FB:7B:21 — prand 4A:2B:7C (two MSBs = 01, an RPA), hash FB:7B:21. +const uint8_t RPA_LSB_FIRST[6] = {0x21, 0x7b, 0xfb, 0x7c, 0x2b, 0x4a}; + +ESPBTDevice make_device(const uint8_t mac_lsb_first[6]) { + ESPBTDevice device; + device.from_scan_result(mac_lsb_first, /*rssi=*/-60, /*addr_type=*/BLE_ADDR_TYPE_RPA_RANDOM, nullptr, 0); + return device; +} +} // namespace + +TEST(BleIrk, ResolvesMatchingRpa) { + ESPBTDevice device = make_device(RPA_LSB_FIRST); + EXPECT_TRUE(device.resolve_irk(IRK)); +} + +TEST(BleIrk, RejectsWrongIrk) { + uint8_t wrong_irk[16]; + for (int i = 0; i < 16; i++) + wrong_irk[i] = IRK[i] ^ 0xff; + ESPBTDevice device = make_device(RPA_LSB_FIRST); + EXPECT_FALSE(device.resolve_irk(wrong_irk)); +} + +TEST(BleIrk, RejectsWrongAddress) { + uint8_t other_mac[6]; + for (int i = 0; i < 6; i++) + other_mac[i] = RPA_LSB_FIRST[i]; + other_mac[0] ^= 0x01; // corrupt one hash byte + ESPBTDevice device = make_device(other_mac); + EXPECT_FALSE(device.resolve_irk(IRK)); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp new file mode 100644 index 0000000000..62a9aebb81 --- /dev/null +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -0,0 +1,96 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Exercises the hub contract around RawAdvertisementCallback, not just the +// struct: a hub stores one slot via set_raw_advertisement_callback(), fires it +// only when set ("no subscriber" is the default-constructed slot), and a new +// registration replaces the old ("one consumer at a time"). +// +// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against +// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are +// pinned here through a minimal host hub carrying only the slot under test. +namespace { + +class FakeHub { + public: + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; } + + /// The emit path every tracker implements: fire only when a subscriber is set. + void emit(const RawAdvertisement &adv) { + if (this->callback_.is_set()) + this->callback_.invoke(adv); + } + + protected: + RawAdvertisementCallback callback_; // default-constructed: no subscriber +}; + +struct CapturingSubscriber { + RawAdvertisement last{}; + int calls{0}; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *sub = static_cast(self); + sub->last = adv; + sub->calls++; + } +}; + +// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it. +constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL; +const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00}; + +RawAdvertisement make_test_adv() { + return RawAdvertisement{ + .address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; +} + +} // namespace + +TEST(RawAdvertisementCallback, DefaultConstructedSlotIsNotSet) { + const RawAdvertisementCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) { + FakeHub hub; + CapturingSubscriber subscriber; + hub.set_raw_advertisement_callback({&subscriber, CapturingSubscriber::trampoline}); + + hub.emit(make_test_adv()); + + ASSERT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last.address, TEST_ADDRESS); + EXPECT_EQ(subscriber.last.data, ADV_DATA); + EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA)); + EXPECT_EQ(subscriber.last.rssi, -63); + EXPECT_EQ(subscriber.last.addr_type, 1); +} + +TEST(RawAdvertisementCallback, NoSubscriberDoesNotFire) { + FakeHub hub; + // No set_raw_advertisement_callback(): emitting must be a guarded no-op, + // not a jump through a garbage pointer. + hub.emit(make_test_adv()); +} + +TEST(RawAdvertisementCallback, NewSubscriberReplacesOld) { + FakeHub hub; + CapturingSubscriber first; + CapturingSubscriber second; + hub.set_raw_advertisement_callback({&first, CapturingSubscriber::trampoline}); + hub.set_raw_advertisement_callback({&second, CapturingSubscriber::trampoline}); + + hub.emit(make_test_adv()); + + EXPECT_EQ(first.calls, 0); // one consumer at a time + ASSERT_EQ(second.calls, 1); + EXPECT_EQ(second.last.rssi, -63); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp new file mode 100644 index 0000000000..ec157715fd --- /dev/null +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -0,0 +1,193 @@ +// The host test build gets this from the manifest override; clang-tidy does not. +#ifndef USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_SCAN_RESPONSE_MERGER +#endif + +#include + +#include +#include +#include + +#include "esphome/components/ble_device_base/scan_response_merger.h" + +namespace esphome::ble_device_base::testing { +namespace { + +// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot +// bookkeeping, the same-device reuse path, the table-full fallback, the +// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate. +// Delivery is observed through a real AdvDispatcher: the raw callback sees +// every frame (including raw_only), a listener only the parsed ones. + +struct DeliveredFrame { + uint64_t address; + std::vector data; + int8_t rssi; +}; + +struct RawCapture { + std::vector frames; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *capture = static_cast(self); + capture->frames.push_back({adv.address, std::vector(adv.data, adv.data + adv.data_len), adv.rssi}); + } +}; + +class CountingListener : public ESPBTDeviceListener { + public: + bool parse_device(const ESPBTDevice &device) override { + this->parsed++; + return true; // claimed: keeps the discovered log quiet + } + int parsed{0}; +}; + +class ScanResponseMergerTest : public ::testing::Test { + protected: + void SetUp() override { + this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline}); + this->dispatcher_.register_listener(&this->listener_); + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test"); + } + + void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) { + std::vector data(data_len, fill); + this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now); + } + + void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) { + std::vector data(data_len, fill); + this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len); + } + + ScanResponseMerger merger_; + AdvDispatcher dispatcher_; + RawCapture raw_; + CountingListener listener_; + bool scan_continuous_{true}; +}; + +constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; +constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16}; + +TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) { + this->stash_(MAC_A, -40, 20, 0xAA); + EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered + + this->scan_rsp_(MAC_A, -70, 10, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 1u); + const auto &frame = this->raw_.frames[0]; + ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame + EXPECT_EQ(frame.data[0], 0xAA); + EXPECT_EQ(frame.data[19], 0xAA); + EXPECT_EQ(frame.data[20], 0xBB); + // The advertisement's RSSI, never the scan response's. + EXPECT_EQ(frame.rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) { + this->stash_(MAC_A, -40, 20, 0xAA); + this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 20u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot + + this->scan_rsp_(MAC_A, -70, 5, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot + EXPECT_EQ(this->raw_.frames[1].rssi, -45); +} + +TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) { + uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00}; + for (uint8_t i = 0; i < 8; i++) { + mac[5] = i; + this->stash_(mac, -50, 10, i); + } + EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held + + mac[5] = 8; + this->stash_(mac, -50, 10, 8); // 9th device: no slot left + ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + + this->merger_.flush(); // the 8 held frames are all still intact + EXPECT_EQ(this->raw_.frames.size(), 9u); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) { + this->stash_(MAC_A, -40, 31, 0xAA); + this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 62u); + EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB); + EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB); +} + +TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) { + this->scan_rsp_(MAC_B, -60, 12, 0xDD); + ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path + EXPECT_EQ(this->raw_.frames[0].rssi, -60); + EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners +} + +TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) { + std::vector adv(20, 0xAA); + this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0); + std::vector rsp(10, 0xBB); + this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size()); + // Same MAC, different addr_type: no merge — the response goes out raw_only. + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + EXPECT_EQ(this->listener_.parsed, 0); + EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held +} + +TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout + EXPECT_TRUE(this->raw_.frames.empty()); + this->merger_.sweep(1301); + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000); + this->merger_.flush(); + EXPECT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->listener_.parsed, 2); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { + ScanResponseMerger unbound; + std::vector data(20, 0xAA); + unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + unbound.sweep(1000); + unbound.flush(); // no null jump anywhere + EXPECT_TRUE(unbound.empty()); +} + +TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) { + ScanResponseMerger partial; + partial.bind(&this->dispatcher_, nullptr, "test"); + std::vector data(20, 0xAA); + partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + partial.flush(); // dropped, not dispatched through half a binding + EXPECT_TRUE(this->raw_.frames.empty()); +} + +} // namespace +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_scanner_state_callback.cpp b/tests/components/ble_device_base/test_scanner_state_callback.cpp new file mode 100644 index 0000000000..7515b2f38e --- /dev/null +++ b/tests/components/ble_device_base/test_scanner_state_callback.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback: +// a default-constructed slot is "no subscriber", a set slot delivers the +// state, and a new registration replaces the old. +namespace { + +struct CapturingSubscriber { + ScannerState last{ScannerState::IDLE}; + int calls{0}; + + static void trampoline(void *self, ScannerState state) { + auto *sub = static_cast(self); + sub->last = state; + sub->calls++; + } +}; + +} // namespace + +TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) { + const ScannerStateCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(ScannerStateCallback, SubscriberSeesState) { + CapturingSubscriber subscriber; + ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline}; + ASSERT_TRUE(callback.is_set()); + callback.invoke(ScannerState::RUNNING); + EXPECT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last, ScannerState::RUNNING); +} + +TEST(ScannerStateCallback, NewSubscriberReplacesOld) { + CapturingSubscriber first; + CapturingSubscriber second; + ScannerStateCallback callback{&first, CapturingSubscriber::trampoline}; + callback = {&second, CapturingSubscriber::trampoline}; + callback.invoke(ScannerState::STOPPED); + EXPECT_EQ(first.calls, 0); + EXPECT_EQ(second.calls, 1); + EXPECT_EQ(second.last, ScannerState::STOPPED); +} + +} // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_presence/common-ln.yaml b/tests/components/ble_presence/common-ln.yaml new file mode 100644 index 0000000000..2cc5075efe --- /dev/null +++ b/tests/components/ble_presence/common-ln.yaml @@ -0,0 +1,4 @@ +binary_sensor: + - platform: ble_presence + mac_address: 11:22:33:44:55:66 + name: BLE Test Presence diff --git a/tests/components/ble_presence/common.yaml b/tests/components/ble_presence/common.yaml index 2ba6aa0754..bd2bb9fecc 100644 --- a/tests/components/ble_presence/common.yaml +++ b/tests/components/ble_presence/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_presence + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: ESP32 BLE Tracker Google Home Mini - platform: ble_presence diff --git a/tests/components/ble_presence/test.ln882x-ard.yaml b/tests/components/ble_presence/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6a359c772c --- /dev/null +++ b/tests/components/ble_presence/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_presence: !include common-ln.yaml diff --git a/tests/components/ble_presence/validate.bk72xx-ard.yaml b/tests/components/ble_presence/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..92b39f1255 --- /dev/null +++ b/tests/components/ble_presence/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +binary_sensor: + - platform: ble_presence + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE Presence + - platform: ble_presence + irk: 1234567890abcdef1234567890abcdef + name: BK BLE Presence IRK diff --git a/tests/components/ble_rssi/common-ln.yaml b/tests/components/ble_rssi/common-ln.yaml new file mode 100644 index 0000000000..f0ccc2df06 --- /dev/null +++ b/tests/components/ble_rssi/common-ln.yaml @@ -0,0 +1,5 @@ +sensor: + - platform: ble_rssi + # irk: is the only thing that emits USE_BLE_DEVICE_IRK off ESP32 + irk: 1234567890abcdef1234567890abcdef + name: BLE Test RSSI diff --git a/tests/components/ble_rssi/common.yaml b/tests/components/ble_rssi/common.yaml index 43bed1d0e7..bbedf17c37 100644 --- a/tests/components/ble_rssi/common.yaml +++ b/tests/components/ble_rssi/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_rssi + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: BLE Google Home Mini RSSI value - platform: ble_rssi @@ -14,7 +17,9 @@ sensor: service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 name: BLE Test Service 128 - platform: ble_rssi - service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_major: 100 + ibeacon_minor: 1 name: BLE Test iBeacon UUID - platform: ble_rssi irk: 1234567890abcdef1234567890abcdef diff --git a/tests/components/ble_rssi/test.ln882x-ard.yaml b/tests/components/ble_rssi/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3554484ca3 --- /dev/null +++ b/tests/components/ble_rssi/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_rssi: !include common-ln.yaml diff --git a/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..926701117e --- /dev/null +++ b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,11 @@ +# Config-only: pins the esp32_ble_id: -> ble_hub_id: deprecation alias — the +# legacy key must keep validating (with a rename warning) until its removal +# release (2027.2.0). +esp32_ble_tracker: + id: legacy_tracker + +sensor: + - platform: ble_rssi + esp32_ble_id: legacy_tracker + mac_address: AC:37:43:77:5F:4C + name: Legacy Key RSSI diff --git a/tests/components/ble_rssi/validate.bk72xx-ard.yaml b/tests/components/ble_rssi/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8fb6bdd201 --- /dev/null +++ b/tests/components/ble_rssi/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: ble_rssi + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE RSSI + - platform: ble_rssi + irk: 1234567890abcdef1234567890abcdef + name: BK BLE RSSI IRK diff --git a/tests/components/ble_scanner/common-ln.yaml b/tests/components/ble_scanner/common-ln.yaml new file mode 100644 index 0000000000..6c732031d8 --- /dev/null +++ b/tests/components/ble_scanner/common-ln.yaml @@ -0,0 +1,3 @@ +text_sensor: + - platform: ble_scanner + name: BLE Test Scanner diff --git a/tests/components/ble_scanner/common.yaml b/tests/components/ble_scanner/common.yaml index 935a5a5a19..5c8d09892f 100644 --- a/tests/components/ble_scanner/common.yaml +++ b/tests/components/ble_scanner/common.yaml @@ -1,5 +1,8 @@ esp32_ble_tracker: + id: ble_tracker_hub text_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_scanner + ble_hub_id: ble_tracker_hub name: Scanner diff --git a/tests/components/ble_scanner/test.ln882x-ard.yaml b/tests/components/ble_scanner/test.ln882x-ard.yaml new file mode 100644 index 0000000000..26dbe4476d --- /dev/null +++ b/tests/components/ble_scanner/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_scanner: !include common-ln.yaml diff --git a/tests/components/ble_scanner/validate.bk72xx-ard.yaml b/tests/components/ble_scanner/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..cb025d74b7 --- /dev/null +++ b/tests/components/ble_scanner/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +text_sensor: + - platform: ble_scanner + ble_hub_id: ble_hub + name: BK Scanner + # No ble_hub_id: exercises the generated binding _require_hub guards. + - platform: ble_scanner + name: BK Scanner Implicit diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..9c1ad4e74d --- /dev/null +++ b/tests/components/bluetooth_connection/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; + # emit the backend define so the host build exercises it. + async def to_code_testing(config): + # These defines are global to the merged host test binary; safe + # because no co-compiled test observes them. + cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") + cg.add_define("USE_BLUETOOTH_PROXY") + # Gates the connection half of the API surface, which is what + # close_service_batch and the GATT response types live behind. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) + + manifest.to_code = to_code_testing + # The batcher sizes api protobuf messages. + manifest.dependencies = manifest.dependencies + ["api"] diff --git a/tests/components/bluetooth_connection/common.yaml b/tests/components/bluetooth_connection/common.yaml new file mode 100644 index 0000000000..5e84f4a678 --- /dev/null +++ b/tests/components/bluetooth_connection/common.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + +api: diff --git a/tests/components/bluetooth_connection/test_close_service_batch.cpp b/tests/components/bluetooth_connection/test_close_service_batch.cpp new file mode 100644 index 0000000000..601ff9202b --- /dev/null +++ b/tests/components/bluetooth_connection/test_close_service_batch.cpp @@ -0,0 +1,58 @@ +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +#include "esphome/components/api/api_pb2.h" + +namespace esphome::bluetooth_connection { + +// The three cursor behaviors: a fitting service advances and continues, an +// overflowing batch with >1 service pops and retries it, and a single +// oversized service is force-advanced so the stream cannot wedge. + +static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) { + resp.services.emplace_back(); + auto &svc = resp.services.back(); + svc.handle = resp.services.size(); + svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + svc.characteristics.init(characteristics); + for (uint16_t i = 0; i < characteristics; i++) { + auto &chr = svc.characteristics.emplace_back(); + chr.handle = 100 + i; + chr.properties = 0x12; + chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + } +} + +TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + size_t current_size = 0; + int16_t cursor = 0; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE); + EXPECT_EQ(cursor, 1); + EXPECT_GT(current_size, 0u); +} + +TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + add_service(resp, 1); + size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes + int16_t cursor = 5; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch + EXPECT_EQ(cursor, 5); // not advanced: retried next batch +} + +TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 60); // ~30 bytes per characteristic, far past the budget + ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE); + size_t current_size = 0; + int16_t cursor = 7; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on +} + +} // namespace esphome::bluetooth_connection diff --git a/tests/components/bluetooth_connection/test_gatt_uuid.cpp b/tests/components/bluetooth_connection/test_gatt_uuid.cpp new file mode 100644 index 0000000000..b3596a4364 --- /dev/null +++ b/tests/components/bluetooth_connection/test_gatt_uuid.cpp @@ -0,0 +1,49 @@ +// Pins the shared UUID wire packing and the size-estimate budget the service +// streamers rely on, in both efficient and legacy client modes. +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +namespace esphome::bluetooth_connection::testing { + +using ble_device_base::ESPBTUUID; + +TEST(GattUuidPacking, ShortUuidUsedWhenClientSupportsIt) { + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), true); + EXPECT_EQ(short_uuid, 0x180Fu); + EXPECT_EQ(uuid128[0], 0u); + EXPECT_EQ(uuid128[1], 0u); +} + +TEST(GattUuidPacking, LegacyClientGetsBaseUuidExpansion) { + // 0000180F-0000-1000-8000-00805F9B34FB + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), false); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x0000180F00001000ULL); + EXPECT_EQ(uuid128[1], 0x800000805F9B34FBULL); +} + +TEST(GattUuidPacking, FullUuidPassesThroughBigEndian) { + // 12345678-90AB-CDEF-1122-334455667788, stored little-endian in ESPBTUUID. + const uint8_t big_endian[16] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + std::array uuid128{}; + uint32_t short_uuid = 0; + // Efficient mode must still use the 128-bit form for 128-bit UUIDs. + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_raw_reversed(big_endian), true); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x1234567890ABCDEFULL); + EXPECT_EQ(uuid128[1], 0x1122334455667788ULL); +} + +TEST(GattUuidPacking, EstimateGrowsWithCharacteristicsAndMode) { + // The estimate only gates batching; pin its shape, not exact bytes. + EXPECT_LT(estimate_service_size(0, true), estimate_service_size(0, false)); + EXPECT_LT(estimate_service_size(1, false), estimate_service_size(2, false)); +} + +} // namespace esphome::bluetooth_connection::testing diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..d3674b8406 --- /dev/null +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Distinct shape from bluetooth_proxy's own rp2 fixtures: explicit slot count +# on the platform whose backend lives in this component (validate-only, so it +# never collides with grouped builds). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +# Two slots: the one shape where the wrap pools are smaller than the cap. +bluetooth_proxy: + active: true + connection_slots: 2 diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml new file mode 100644 index 0000000000..b3445f16c8 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -0,0 +1,12 @@ +# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is +# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# address-scoped maintenance path that a connections build never exercises. +# Under batch grouping the active default build is what runs; the standalone +# compile of this fixture is what exercises the passive gating. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml new file mode 100644 index 0000000000..ae0f00d765 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on rp2 by explicit choice. Variant tests compile +# as their own builds when this component is tested individually; under CI +# batch grouping the active default build is what runs, so this fixture's +# guarantee is the individual run plus config validation. +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/components/bluetooth_proxy/test.ln882x-ard.yaml b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml new file mode 100644 index 0000000000..ae1aed7c22 --- /dev/null +++ b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml @@ -0,0 +1,10 @@ +# Advertisement-only proxy on the ln882x BLE hub (active-scan-capable, in-tree +# since #16691) — a target CI fully compiles. Same bare-hub arrangement as +# test.rp2040-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with ln882h_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +ln882h_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..77ed2ea32d --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -0,0 +1,15 @@ +# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), +# so this compiles the BTstack GATT client backend with the default three +# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap +# link. +# No explicit ble_hub_id: the generated binding resolves the single declared +# hub, and an inline id here would collide with rp2_ble_tracker's own fixture +# once CI merges both components into one grouped rp2040-ard build (grouped +# component dicts collapse; only one id survives). The explicit-key form is +# covered by validate.rp2040-ard.yaml, which never participates in grouping. +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/test.rp2350-ard.yaml b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml new file mode 100644 index 0000000000..1abc62cedb --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml @@ -0,0 +1,9 @@ +# Pico 2 W build of the full proxy: links the rp2350 framework archive, so +# the pool --wrap overrides and their per-architecture layout asserts are +# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..331d679510 --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the +# tracker's packed-command start). Config-only: the CI base board generic-bk7252 +# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement +# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with bk72xx_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +bk72xx_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/bluetooth_proxy/validate.esp32-idf.yaml b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml new file mode 100644 index 0000000000..a92716ebeb --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml @@ -0,0 +1,13 @@ +# Connections given as a bare list, with no explicit per-entry id. The ids are +# generated during validation, so this config breaks if the schema validates the +# connections list more than once. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: true + connections: + - {} + - {} diff --git a/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..fa385dd5bc --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Explicit ble_hub_id on the rp2 hub — the documented disambiguator once a +# platform has more than one tracker. Validate-only: never merged into grouped +# builds, so the inline id cannot collide with rp2_ble_tracker's own fixture. +packages: + common: !include common.yaml + +rp2_ble_tracker: + id: ble_hub + +bluetooth_proxy: + ble_hub_id: ble_hub diff --git a/tests/components/bthome_mithermometer/common-ln.yaml b/tests/components/bthome_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..947f8890f8 --- /dev/null +++ b/tests/components/bthome_mithermometer/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:78 + # bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BTHome Temperature + humidity: + name: BTHome Humidity diff --git a/tests/components/bthome_mithermometer/common.yaml b/tests/components/bthome_mithermometer/common.yaml index 7a68fae966..d61738bbe5 100644 --- a/tests/components/bthome_mithermometer/common.yaml +++ b/tests/components/bthome_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: bthome_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: eef418daf699a0c188f3bfd17e4565d9 temperature: diff --git a/tests/components/bthome_mithermometer/test.ln882x-ard.yaml b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..d03ff4a4d2 --- /dev/null +++ b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + bthome_mithermometer: !include common-ln.yaml diff --git a/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4a50dd6e1a --- /dev/null +++ b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: bthome_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK BTHome Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK BTHome Implicit Temperature diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py deleted file mode 100644 index b13c81912c..0000000000 --- a/tests/components/captive_portal/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Test-manifest overrides for the captive_portal C++ unit tests. - -``json_escape`` lives in a standalone, dependency-free header -(``esphome/components/captive_portal/json_escape.h``). The rest of the -captive_portal component and its auto-loaded dependencies (``web_server_base``, -``ota.web_server``) do not build for the ``host`` platform that the C++ unit -test harness targets. Strip those away and replace the real schema -- which is -restricted to non-host platforms via ``cv.only_on`` and requires a -``web_server_base`` instance via ``use_id`` -- with an empty one so the host -test config validates. ``to_code`` stays suppressed (the default), so -``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an -empty translation unit; only ``json_escape.h`` is exercised by the test. -""" - -import esphome.config_validation as cv -from tests.testing_helpers import ComponentManifestOverride - - -def override_manifest(manifest: ComponentManifestOverride) -> None: - manifest.auto_load = [] - manifest.dependencies = [] - manifest.config_schema = cv.Schema({}) - manifest.final_validate_schema = None diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 9784bfce8b..4d2411e021 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -17,6 +17,15 @@ cc1101: sync0: 0x91 sync1: 0xD3 num_preamble: 2 + foc_bs_cs_gate: true + foc_pre_k: "2K" + foc_post_k: "K/2" + foc_limit: "BW/4" + bs_pre_ki: "3KI" + bs_pre_kp: "4KP" + bs_post_ki: "KI/2" + bs_post_kp: "KP" + bs_limit: "12.5%" on_packet: then: - lambda: |- diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 468185787f..a9a940392f 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,4 +55,32 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { + StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; + StaticVector big = small; + ASSERT_EQ(big.size(), small.size()); + for (size_t i = 0; i < small.size(); i++) { + EXPECT_EQ(big[i], small[i]) << "mismatch at index " << i; + } +} + +TEST(StaticVectorTest, ConvertingConstructorPartiallyFilledAndEmpty) { + StaticVector partial{0xAA, 0xBB}; + StaticVector from_partial = partial; + ASSERT_EQ(from_partial.size(), 2u); + EXPECT_EQ(from_partial[0], 0xAA); + EXPECT_EQ(from_partial[1], 0xBB); + + StaticVector empty; + StaticVector from_empty = empty; + EXPECT_TRUE(from_empty.empty()); +} + +TEST(StaticVectorTest, ConvertingConstructorSameSize) { + StaticVector src{1, 2, 3}; + StaticVector dst = src; + ASSERT_EQ(dst.size(), 3u); + EXPECT_EQ(dst[2], 3); +} + } // namespace esphome diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/core/json_escape_test.cpp similarity index 66% rename from tests/components/captive_portal/json_escape_test.cpp rename to tests/components/core/json_escape_test.cpp index 98b5ce4ff7..4db6dce2ea 100644 --- a/tests/components/captive_portal/json_escape_test.cpp +++ b/tests/components/core/json_escape_test.cpp @@ -2,9 +2,10 @@ #include -#include "esphome/components/captive_portal/json_escape.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" -namespace esphome::captive_portal::testing { +namespace esphome::testing { namespace { @@ -17,30 +18,36 @@ std::string escape(const std::string &value) { return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); } +// Same, but with the short control forms turned off. +std::string escape_long(const std::string &value) { + char buf[TEST_BUFFER_SIZE]; + return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()), false); +} + } // namespace // Plain ASCII with no special characters is passed through unchanged. -TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { +TEST(JsonEscape, PlainStringUnchanged) { EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); EXPECT_EQ(escape(""), ""); } // A double quote is escaped so it does not terminate the surrounding JSON string. -TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { +TEST(JsonEscape, EscapesDoubleQuote) { EXPECT_EQ(escape("a\"b"), "a\\\"b"); // A double quote followed by other characters stays inside the JSON string. EXPECT_EQ(escape("\">end"), "\\\">end"); } // A backslash is doubled so it does not start an escape sequence in the output. -TEST(CaptivePortalJsonEscape, EscapesBackslash) { +TEST(JsonEscape, EscapesBackslash) { EXPECT_EQ(escape("a\\b"), "a\\\\b"); // A trailing backslash must not escape the closing quote of the JSON string. EXPECT_EQ(escape("net\\"), "net\\\\"); } // The control characters with short JSON forms use those forms. -TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { +TEST(JsonEscape, EscapesShortFormControls) { EXPECT_EQ(escape("\n"), "\\n"); EXPECT_EQ(escape("\r"), "\\r"); EXPECT_EQ(escape("\t"), "\\t"); @@ -49,7 +56,7 @@ TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { } // Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. -TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { +TEST(JsonEscape, EscapesOtherControlsAsUnicode) { EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); EXPECT_EQ(escape("\x01"), "\\u0001"); EXPECT_EQ(escape("\x10"), "\\u0010"); @@ -58,8 +65,28 @@ TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { EXPECT_EQ(escape("\x7f"), "\x7f"); } +// With the short forms turned off, every control character is written as \u00XX instead. +TEST(JsonEscape, LongControlEscapes) { + EXPECT_EQ(escape_long("\n"), "\\u000a"); + EXPECT_EQ(escape_long("\r"), "\\u000d"); + EXPECT_EQ(escape_long("\t"), "\\u0009"); + EXPECT_EQ(escape_long("\b"), "\\u0008"); + EXPECT_EQ(escape_long("\f"), "\\u000c"); + // Controls without a short form are unaffected by the flag. + EXPECT_EQ(escape_long("\x01"), "\\u0001"); +} + +// The flag only affects control characters. A quote or backslash is never written as \u00XX, because that form is +// no shorter and both modes have always emitted the two character escape. +TEST(JsonEscape, LongModeStillUsesTwoCharQuoteAndBackslash) { + EXPECT_EQ(escape_long("a\"b"), "a\\\"b"); + EXPECT_EQ(escape_long("a\\b"), "a\\\\b"); + // Ordinary text is untouched in either mode. + EXPECT_EQ(escape_long("MyDevice"), "MyDevice"); +} + // Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. -TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { +TEST(JsonEscape, PassesThroughUtf8) { // "café" in UTF-8 (é == 0xC3 0xA9). EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); // Emoji (📶, 4-byte UTF-8) survives unchanged. @@ -67,10 +94,10 @@ TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { } // A mix of special and normal characters is escaped in place without disturbing the rest. -TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } +TEST(JsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } // A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. -TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { +TEST(JsonEscape, WorstCaseInputFitsExactly) { constexpr size_t input_len = 8; char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; const std::string input(input_len, '\x01'); @@ -82,7 +109,7 @@ TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { // An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null // terminated. -TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { +TEST(JsonEscape, DropsEscapeThatWouldNotFit) { // Room for one \u00XX sequence plus the null terminator, but two are requested. char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; const std::string input(2, '\x01'); @@ -92,16 +119,16 @@ TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { } // Plain characters are truncated at the buffer size, leaving room for the null terminator. -TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { +TEST(JsonEscape, TruncatesPlainInput) { char buf[5]; const std::string input(20, 'a'); EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); } // A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. -TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { +TEST(JsonEscape, EmptyBufferIsSafe) { const std::string input("test"); EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); } -} // namespace esphome::captive_portal::testing +} // namespace esphome::testing diff --git a/tests/components/core/test_event_pool.cpp b/tests/components/core/test_event_pool.cpp new file mode 100644 index 0000000000..da13924c65 --- /dev/null +++ b/tests/components/core/test_event_pool.cpp @@ -0,0 +1,136 @@ +#include "esphome/core/event_pool.h" + +#include + +#include + +namespace esphome::core::testing { + +struct PoolItem { + int value{0}; + // EventPool contract: release() cleans up per-object state; nothing here. + void release() {} +}; + +TEST(EventPool, AllocateUpToCapacityThenNull) { + esphome::EventPool pool; + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + // At capacity: the pool refuses rather than growing past SIZE. + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, FullDrainRetainsEveryObject) { + // Pins the SIZE + 1 free-list sizing: a fully returned pool must hold all + // SIZE objects. With a SIZE-slot ring (capacity SIZE - 1) the last release() + // of a full drain was dropped, permanently orphaning one object. + esphome::EventPool pool; + PoolItem *items[4]; + for (auto *&item : items) + item = pool.allocate(); + for (auto *item : items) + pool.release(item); + + // Every object must be allocatable again — no orphan, no new creation + // (total_created_ is already at SIZE, so a lost object would surface as a + // nullptr on the fourth allocation). + std::set seen; + for (int i = 0; i < 4; i++) { + PoolItem *item = pool.allocate(); + ASSERT_NE(item, nullptr); + seen.insert(item); + } + // And they are the same four objects, recycled rather than re-created. + for (auto *item : items) + EXPECT_TRUE(seen.count(item) == 1); + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, RepeatedDrainCyclesAreStable) { + esphome::EventPool pool; + // Several full allocate/release cycles: capacity must not shrink over time. + for (int cycle = 0; cycle < 10; cycle++) { + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); + for (auto *item : items) + pool.release(item); + } +} + +TEST(EventPool, ReleaseNullptrIsSafe) { + esphome::EventPool pool; + pool.release(nullptr); + EXPECT_NE(pool.allocate(), nullptr); +} + +TEST(EventPool, WarmFullyPopulatesThePool) { + // warm()'s guarantee is invisible at runtime: no later allocate() may touch + // malloc(). Fully populated means SIZE allocations succeed from the free + // list and the SIZE + 1-th refuses. + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, WarmIsIdempotent) { + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + ASSERT_TRUE(pool.warm()); + // Still exactly SIZE objects: no growth past capacity. + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + +TEST(EventPool, AllocateAfterWarmRecyclesTheWarmedObjects) { + // The objects handed out after warm() are the ones warm() created, + // recycled rather than re-created. + esphome::EventPool pool; + ASSERT_TRUE(pool.warm()); + std::set first_round; + PoolItem *items[4]; + for (auto *&item : items) { + item = pool.allocate(); + first_round.insert(item); + } + for (auto *item : items) + pool.release(item); + for (int i = 0; i < 4; i++) { + PoolItem *item = pool.allocate(); + ASSERT_NE(item, nullptr); + EXPECT_TRUE(first_round.count(item) == 1); + } +} + +TEST(EventPool, WarmTopsUpWithEntriesOutstanding) { + // warm() counts existing entries (free or checked out) instead of failing + // when some are outstanding: it tops the pool up from any state. + esphome::EventPool pool; + PoolItem *held = pool.allocate(); + ASSERT_NE(held, nullptr); + ASSERT_TRUE(pool.warm()); + // The held object plus three more accounts for all SIZE entries. + PoolItem *items[3]; + for (auto *&item : items) { + item = pool.allocate(); + ASSERT_NE(item, nullptr); + } + EXPECT_EQ(pool.allocate(), nullptr); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_lock_free_queue.cpp b/tests/components/core/test_lock_free_queue.cpp new file mode 100644 index 0000000000..2f74278129 --- /dev/null +++ b/tests/components/core/test_lock_free_queue.cpp @@ -0,0 +1,96 @@ +// Exercises the no-atomics LockFreeQueue implementation (PlainAtomic indices — +// the path used on cores without atomic RMW instructions, currently BK72xx). +// The define is forced before the include so this TU deterministically compiles +// that path regardless of the host's default thread model; no other test TU +// instantiates this template, so the differing definition is confined here. +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#include "esphome/core/lock_free_queue.h" + +#include + +namespace esphome::core::testing { + +TEST(LockFreeQueueNoAtomics, EmptyPopReturnsNull) { + esphome::LockFreeQueue q; + EXPECT_EQ(q.pop(), nullptr); + EXPECT_TRUE(q.empty()); + EXPECT_FALSE(q.full()); + EXPECT_EQ(q.size(), 0u); +} + +TEST(LockFreeQueueNoAtomics, FifoOrder) { + esphome::LockFreeQueue q; + int a = 1, b = 2, c = 3; + EXPECT_TRUE(q.push(&a)); + EXPECT_TRUE(q.push(&b)); + EXPECT_TRUE(q.push(&c)); + EXPECT_EQ(q.size(), 3u); + EXPECT_EQ(q.pop(), &a); + EXPECT_EQ(q.pop(), &b); + EXPECT_EQ(q.pop(), &c); + EXPECT_EQ(q.pop(), nullptr); +} + +TEST(LockFreeQueueNoAtomics, CapacityIsSizeMinusOne) { + esphome::LockFreeQueue q; + int v[4] = {0, 1, 2, 3}; + EXPECT_TRUE(q.push(&v[0])); + EXPECT_TRUE(q.push(&v[1])); + EXPECT_TRUE(q.push(&v[2])); + EXPECT_TRUE(q.full()); + // Ring reserves one slot: the SIZEth push fails and is counted as dropped. + EXPECT_FALSE(q.push(&v[3])); + EXPECT_EQ(q.get_and_reset_dropped_count(), 1u); + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky +} + +TEST(LockFreeQueueNoAtomics, NullPushRejected) { + esphome::LockFreeQueue q; + EXPECT_FALSE(q.push(nullptr)); + EXPECT_TRUE(q.empty()); +} + +TEST(LockFreeQueueNoAtomics, WrapAround) { + esphome::LockFreeQueue q; + int v[3] = {10, 20, 30}; + // Cycle several times the ring size to cross the wrap boundary repeatedly. + for (int cycle = 0; cycle < 10; cycle++) { + for (auto &value : v) + ASSERT_TRUE(q.push(&value)); + EXPECT_TRUE(q.full()); + for (auto &value : v) + ASSERT_EQ(q.pop(), &value); + EXPECT_TRUE(q.empty()); + } + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); +} + +TEST(LockFreeQueueNoAtomics, IncrementDroppedCount) { + esphome::LockFreeQueue q; + // Producer-side external drop accounting (pool exhausted before push). + q.increment_dropped_count(); + q.increment_dropped_count(); + EXPECT_EQ(q.get_and_reset_dropped_count(), 2u); +} + +TEST(LockFreeQueueNoAtomics, InterleavedPushPop) { + esphome::LockFreeQueue q; + int v[64]; + int popped = 0; + for (int i = 0; i < 64; i++) { + v[i] = i; + ASSERT_TRUE(q.push(&v[i])); + if (i % 2 == 1) { + int *first = q.pop(); + ASSERT_NE(first, nullptr); + EXPECT_EQ(*first, popped++); + int *second = q.pop(); + ASSERT_NE(second, nullptr); + EXPECT_EQ(*second, popped++); + } + } + EXPECT_TRUE(q.empty()); + EXPECT_EQ(popped, 64); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_lock_free_queue_single.cpp b/tests/components/core/test_lock_free_queue_single.cpp new file mode 100644 index 0000000000..911674da65 --- /dev/null +++ b/tests/components/core/test_lock_free_queue_single.cpp @@ -0,0 +1,115 @@ +// Exercises the LockFreeQueue PlainAtomic path under ESPHOME_THREAD_SINGLE — +// the gate added for single-threaded platforms whose only concurrency is +// same-core interrupt preemption (RP2: BTstack packet handler in the CYW43 +// async-context IRQ). The define is forced before the include so this TU +// deterministically compiles that path regardless of the host's default +// thread model. The instantiations here deliberately differ from +// test_lock_free_queue.cpp's (uint32_t elements, non-power-of-2 sizes): no +// template instantiation is shared between the two TUs, so the differing +// AtomicIndex definitions can never collide under the one-definition rule, +// and the non-power-of-2 sizes cover next_index()'s comparison branch, which +// the other TU's power-of-2 sizes never reach. +#define ESPHOME_THREAD_SINGLE +#include "esphome/core/lock_free_queue.h" + +#include + +#include +#include + +namespace esphome::core::testing { + +// Pin the gate itself: under ESPHOME_THREAD_SINGLE the index type must be the +// PlainAtomic fallback, not std::atomic — otherwise the RP2040 build silently +// pulls __atomic_* library calls back in. +static_assert(!std::is_same_v, std::atomic>, + "ESPHOME_THREAD_SINGLE must select the PlainAtomic index path"); + +TEST(LockFreeQueueThreadSingle, EmptyPopReturnsNull) { + esphome::LockFreeQueue q; + EXPECT_EQ(q.pop(), nullptr); + EXPECT_TRUE(q.empty()); + EXPECT_FALSE(q.full()); + EXPECT_EQ(q.size(), 0u); +} + +TEST(LockFreeQueueThreadSingle, FifoOrder) { + esphome::LockFreeQueue q; + uint32_t a = 1, b = 2, c = 3, d = 4; + EXPECT_TRUE(q.push(&a)); + EXPECT_TRUE(q.push(&b)); + EXPECT_TRUE(q.push(&c)); + EXPECT_TRUE(q.push(&d)); + EXPECT_EQ(q.size(), 4u); + EXPECT_EQ(q.pop(), &a); + EXPECT_EQ(q.pop(), &b); + EXPECT_EQ(q.pop(), &c); + EXPECT_EQ(q.pop(), &d); + EXPECT_EQ(q.pop(), nullptr); +} + +TEST(LockFreeQueueThreadSingle, CapacityIsSizeMinusOne) { + esphome::LockFreeQueue q; + uint32_t v[5] = {0, 1, 2, 3, 4}; + EXPECT_TRUE(q.push(&v[0])); + EXPECT_TRUE(q.push(&v[1])); + EXPECT_TRUE(q.push(&v[2])); + EXPECT_TRUE(q.push(&v[3])); + EXPECT_TRUE(q.full()); + // Ring reserves one slot: the SIZEth push fails and is counted as dropped. + EXPECT_FALSE(q.push(&v[4])); + EXPECT_EQ(q.get_and_reset_dropped_count(), 1u); + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky +} + +TEST(LockFreeQueueThreadSingle, NullPushRejected) { + esphome::LockFreeQueue q; + EXPECT_FALSE(q.push(nullptr)); + EXPECT_TRUE(q.empty()); +} + +TEST(LockFreeQueueThreadSingle, WrapAround) { + // Non-power-of-2 SIZE: next_index() wraps via the comparison branch here. + esphome::LockFreeQueue q; + uint32_t v[4] = {10, 20, 30, 40}; + // Cycle several times the ring size to cross the wrap boundary repeatedly. + for (int cycle = 0; cycle < 10; cycle++) { + for (auto &value : v) + ASSERT_TRUE(q.push(&value)); + EXPECT_TRUE(q.full()); + for (auto &value : v) + ASSERT_EQ(q.pop(), &value); + EXPECT_TRUE(q.empty()); + } + EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); +} + +TEST(LockFreeQueueThreadSingle, IncrementDroppedCount) { + esphome::LockFreeQueue q; + // Producer-side external drop accounting (pool exhausted before push). + q.increment_dropped_count(); + q.increment_dropped_count(); + EXPECT_EQ(q.get_and_reset_dropped_count(), 2u); +} + +TEST(LockFreeQueueThreadSingle, InterleavedPushPop) { + esphome::LockFreeQueue q; + uint32_t v[64]; + uint32_t popped = 0; + for (uint32_t i = 0; i < 64; i++) { + v[i] = i; + ASSERT_TRUE(q.push(&v[i])); + if (i % 2 == 1) { + uint32_t *first = q.pop(); + ASSERT_NE(first, nullptr); + EXPECT_EQ(*first, popped++); + uint32_t *second = q.pop(); + ASSERT_NE(second, nullptr); + EXPECT_EQ(*second, popped++); + } + } + EXPECT_TRUE(q.empty()); + EXPECT_EQ(popped, 64u); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_preference_contract.cpp b/tests/components/core/test_preference_contract.cpp new file mode 100644 index 0000000000..f0833a5929 --- /dev/null +++ b/tests/components/core/test_preference_contract.cpp @@ -0,0 +1,91 @@ +// Pins the preferences contract concepts so the surface they enforce cannot +// drift unnoticed: a minimal conforming type must satisfy each concept, and a +// type missing a method or returning the wrong type must not. + +#include + +#include "esphome/core/preference_backend.h" + +namespace esphome::core::testing { + +struct MinimalBackend { + bool save(const uint8_t *, size_t) { return true; } + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(PreferenceBackendContract); + +struct BackendMissingLoad { + bool save(const uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct BackendWrongReturn { + void save(const uint8_t *, size_t) {} + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct MinimalPreferences : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(PreferencesContract); + +struct PreferencesMissingTwoArgForm : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesMissingReset : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesWrongSyncReturn : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + void sync() {} + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +// Forgot `using PreferencesMixin::make_preference;`, so the derived +// overloads hide the template forms (see the PreferencesContract note in +// preference_backend.h); the concept must reject the class. +struct PreferencesForgotUsingDeclaration : public PreferencesMixin { + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct MinimalKeyLookup { + bool load_from_key(uint32_t, uint8_t *, size_t) { return true; } +}; +static_assert(PreferencesKeyLookupContract); + +struct KeyLookupMissingMethod {}; +static_assert(!PreferencesKeyLookupContract); + +TEST(PreferenceContract, NullBackendRefusesBothOperations) { + // ESPPreferenceObject forwards to whichever backend the platform binds; a + // default-constructed object has no backend and must refuse both operations + // instead of crashing. + ESPPreferenceObject without_backend; + uint32_t value = 42; + EXPECT_FALSE(without_backend.save(&value)); + EXPECT_FALSE(without_backend.load(&value)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_string_ref.cpp b/tests/components/core/test_string_ref.cpp new file mode 100644 index 0000000000..bcbd0aa0d4 --- /dev/null +++ b/tests/components/core/test_string_ref.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/core/string_ref.h" + +namespace esphome::core::testing { + +TEST(StringRefStartsWith, ProperPrefixMatches) { + StringRef ref("FR:R20:12345", 12); + EXPECT_TRUE(ref.starts_with("FR:")); +} + +TEST(StringRefStartsWith, WholeStringIsAPrefixOfItself) { + StringRef ref("TP96", 4); + EXPECT_TRUE(ref.starts_with("TP96")); +} + +TEST(StringRefStartsWith, PrefixLongerThanViewFails) { + StringRef ref("TP", 2); + EXPECT_FALSE(ref.starts_with("TP96")); +} + +TEST(StringRefStartsWith, DifferentContentFails) { + StringRef ref("TP96", 4); + EXPECT_FALSE(ref.starts_with("FR:")); +} + +TEST(StringRefStartsWith, EmptyPrefixAlwaysMatches) { + StringRef ref("abc", 3); + EXPECT_TRUE(ref.starts_with("")); + StringRef empty; + EXPECT_TRUE(empty.starts_with("")); +} + +TEST(StringRefStartsWith, EmptyViewOnlyMatchesEmptyPrefix) { + StringRef empty; + EXPECT_FALSE(empty.starts_with("a")); +} + +TEST(StringRefStartsWith, WorksOnANonTerminatedBuffer) { + // The reason the helper exists: a bounded view over a buffer with no + // terminator anywhere near the viewed bytes. + const char raw[] = {'R', 'a', 'd', 'o', 'n', 'X'}; + StringRef ref(raw, 5); + EXPECT_TRUE(ref.starts_with("Radon")); + EXPECT_FALSE(ref.starts_with("RadonEye")); + EXPECT_FALSE(ref.starts_with("adon")); +} + +TEST(StringRefStartsWith, StdStringOverload) { + StringRef ref("TP96", 4); + EXPECT_TRUE(ref.starts_with(std::string("TP"))); + EXPECT_FALSE(ref.starts_with(std::string("96"))); +} + +TEST(StringRefStartsWith, RefOverloadComparesOnlyTheViewedLength) { + // The prefix is a bounded view: bytes past its length must not be compared. + StringRef ref("FR:123", 6); + StringRef prefix("FR:xyz", 3); + EXPECT_TRUE(ref.starts_with(prefix)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/dallas_temp/common.yaml b/tests/components/dallas_temp/common.yaml index abd8e0cfa3..7f03ffa326 100644 --- a/tests/components/dallas_temp/common.yaml +++ b/tests/components/dallas_temp/common.yaml @@ -1,14 +1,18 @@ one_wire: - platform: gpio + id: ow_dallas_temp pin: ${one_wire_pin} sensor: - platform: dallas_temp + one_wire_id: ow_dallas_temp address: 0x1C0000031EDD2A28 name: Dallas Temperature 1 resolution: 9 - platform: dallas_temp + one_wire_id: ow_dallas_temp name: Dallas Temperature 2 - platform: dallas_temp + one_wire_id: ow_dallas_temp name: Dallas Temperature 3 index: 2 diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index b97eec76b9..9dc2f87258 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -6,9 +6,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO13 mode: ANY_HIGH touch_wakeup: true diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index 9ed4279a33..c531d44743 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -5,8 +5,15 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] esp32_ext1_wakeup: pins: - - number: GPIO2 + - pin: + number: GPIO2 + on_wake: + - logger.log: Woken by ext1 pin GPIO2 - number: GPIO5 mode: ANY_HIGH diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index c20e1a902e..e670787cc0 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -5,3 +5,7 @@ deep_sleep: sleep_duration: 50s wakeup_pin: ${wakeup_pin} wakeup_pin_mode: INVERT_WAKEUP + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] diff --git a/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml b/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml new file mode 100644 index 0000000000..bb24377675 --- /dev/null +++ b/tests/components/deep_sleep/test-ota-rollback.esp32-idf.yaml @@ -0,0 +1,19 @@ +# Deep sleep combined with OTA while bootloader rollback support is enabled +# (the default on ESP-IDF). Entering deep sleep runs the safe shutdown hooks, +# where safe_mode confirms the running app image so the bootloader does not +# roll back a fresh OTA update when the device goes to sleep before +# boot_is_good_after has elapsed. +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + deep_sleep_esp32: !include common-esp32.yaml + +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" diff --git a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..485490576d --- /dev/null +++ b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml @@ -0,0 +1,16 @@ +# Deep sleep combined with mcumgr OTA while MCUboot image rollback is enabled +# (the default on nRF52). Entering system-off deep sleep runs the safe +# shutdown hooks, where safe_mode confirms the running image so MCUboot does +# not revert a fresh OTA update on the next wake. +packages: + deep_sleep: !include common.yaml + +deep_sleep: + run_duration: 10s + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index 2385fbb4db..bdbd27c902 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,6 +1,10 @@ deep_sleep: run_duration: 30s sleep_duration: 12h + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] wakeup_pin: - pin: number: P6 diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index df08ec8a14..e4c592c095 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,5 +1,9 @@ deep_sleep: run_duration: 10s sleep_duration: 50s + on_wake: + - logger.log: + format: "Woke up, cause %d" + args: ["static_cast(cause)"] <<: !include common.yaml diff --git a/tests/components/ds2484/common.yaml b/tests/components/ds2484/common.yaml index 1e5dcd7dba..b6abc5bd76 100644 --- a/tests/components/ds2484/common.yaml +++ b/tests/components/ds2484/common.yaml @@ -1,6 +1,7 @@ one_wire: - platform: ds2484 - i2c_id: i2c_bus - address: 0x18 - active_pullup: true - strong_pullup: false + - platform: ds2484 + id: ow_ds2484 + i2c_id: i2c_bus + address: 0x18 + active_pullup: true + strong_pullup: false diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml new file mode 100644 index 0000000000..53ae56f20a --- /dev/null +++ b/tests/components/ds248x/common.yaml @@ -0,0 +1,115 @@ +# Combined DS248x test covering all chip variants and options: +# - DS2482-100: active pullup, multiple sensors + index access +# - DS2482-101: sleep pin, bus_sleep / hub_sleep +# - DS2482-800: all 8 channels +# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection +ds248x: + - id: ds2482_100 + address: 0x18 + type: ds2482-100 + active_pullup: true + - id: ds2482_101 + address: 0x19 + type: ds2482-101 + active_pullup: true + sleep_pin: + number: GPIO12 + inverted: false + bus_sleep: true + hub_sleep: true + - id: ds2482_800 + address: 0x1a + type: ds2482-800 + active_pullup: true + - id: ds2484_hub + address: 0x1b + type: ds2484 + active_pullup: true + # DS2484-specific 1-Wire timing parameters (optional fine-tuning) + reset_low_time: 8 # tRSTL: Reset low time + master_sample_time: 8 # tMSP: Master sample point + write_0_low_time: 8 # tW0L: Write-0 low time + recovery_time: 8 # tREC0: Recovery time + active_pullup_resistance: 1000ohm # RWPU: weak pullup resistor selection + +one_wire: + - platform: ds248x + ds248x_id: ds2482_100 + channel: 0 + id: ow_100 + - platform: ds248x + ds248x_id: ds2482_101 + channel: 0 + id: ow_101 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 0 + id: ow_800_0 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 1 + id: ow_800_1 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 2 + id: ow_800_2 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 3 + id: ow_800_3 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 4 + id: ow_800_4 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 5 + id: ow_800_5 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 6 + id: ow_800_6 + - platform: ds248x + ds248x_id: ds2482_800 + channel: 7 + id: ow_800_7 + - platform: ds248x + ds248x_id: ds2484_hub + channel: 0 + id: ow_2484 + +sensor: + # DS2482-100: explicit address + index-based access on the same bus + - platform: dallas_temp + one_wire_id: ow_100 + address: 0x1c0000031edd2a28 + name: Temp 100 by address + resolution: 12 + - platform: dallas_temp + one_wire_id: ow_100 + index: 0 + name: Temp 100 by index + # DS2482-101 (sleep variant) + - platform: dallas_temp + one_wire_id: ow_101 + address: 0x578295491f64ff28 + name: Temp 101 + # DS2482-800: sensors on a few of the eight channels + - platform: dallas_temp + one_wire_id: ow_800_0 + address: 0x1c0000031edd2a28 + name: Temp 800 CH0 + - platform: dallas_temp + one_wire_id: ow_800_3 + index: 0 + name: Temp 800 CH3 by index + - platform: dallas_temp + one_wire_id: ow_800_7 + address: 0x2800000123456789 + name: Temp 800 CH7 + # DS2484 (adjustable timing) + - platform: dallas_temp + one_wire_id: ow_2484 + address: 0x1c0000031edd2a28 + name: Temp 2484 + resolution: 12 diff --git a/tests/components/ds248x/test.esp32-ard.yaml b/tests/components/ds248x/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/ds248x/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp32-idf.yaml b/tests/components/ds248x/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/ds248x/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.esp8266-ard.yaml b/tests/components/ds248x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/ds248x/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ds248x/test.rp2040-ard.yaml b/tests/components/ds248x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/ds248x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h new file mode 100644 index 0000000000..5ac12afa6a --- /dev/null +++ b/tests/components/epaper_spi/common.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include "esphome/components/spi/spi.h" +#include "esphome/core/hal.h" + +namespace esphome::epaper_spi::testing { + +/// SPI delegate that records transaction boundaries and burns wall-clock time on each +/// row write, so a transfer can be driven past its MAX_TRANSFER_TIME yield deadline. +class TimedSPIDelegate : public spi::SPIDelegate { + public: + explicit TimedSPIDelegate(uint32_t row_transfer_ms) : row_transfer_ms_(row_transfer_ms) {} + + uint8_t transfer(uint8_t data) override { return 0; } + + void write_array(const uint8_t *ptr, size_t length) override { + // A row of pixel data is one "slow" write; single-byte writes are commands. + if (length > 1) { + const uint32_t until = millis() + this->row_transfer_ms_; + while (millis() < until) { + } + } + } + + void begin_transaction() override { this->begin_count++; } + void end_transaction() override { this->end_count++; } + + int begin_count{0}; + int end_count{0}; + + protected: + uint32_t row_transfer_ms_; +}; + +/// GPIO pin that just remembers the last level written to it. +class RecordingPin : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool value) override { this->level = value; } + size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); } + + bool level{true}; +}; + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_t133a01_transfer.cpp b/tests/components/epaper_spi/display/test_t133a01_transfer.cpp new file mode 100644 index 0000000000..5c7abdc022 --- /dev/null +++ b/tests/components/epaper_spi/display/test_t133a01_transfer.cpp @@ -0,0 +1,77 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the protected transfer machinery so the yield behaviour can be driven directly. +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01(uint16_t width, uint16_t height) : EPaperT133A01("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + using EPaperT133A01::transfer_data; + + RecordingPin dc, cs, cs1; +}; + +/// Regression test for the T133A01 transfer deadlock (issue #17668). +/// +/// `transfer_data()` evaluates its yield deadline *after* incrementing the row counter, so the +/// deadline can expire on a phase's final row. The phase is then complete but the function +/// reports "not done"; on the next call the phase guard is false, so the `disable()` / +/// CS-deassert epilogue is skipped permanently. The SPI transaction is never closed and the +/// next `enable()` blocks forever, tripping the task watchdog. +/// +/// Here the CS phase is two rows and every row write overruns the deadline, so the second call +/// completes the phase exactly as the deadline expires, which is the failing alignment. The completed +/// phase must still run its epilogue: end the transaction and deassert CS. +TEST(EPaperT133A01, CompletedPhaseRunsEpilogueWhenDeadlineExpiresOnFinalRow) { + // width 8 -> 4 bytes per row, 2 per half-row; height 2 -> a two-row CS phase + TestableT133A01 display(8, 2); + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + // First call performs the one-off CCSET setup (which opens and closes a transaction of its + // own) and then writes row 0 of the CS phase before yielding on the deadline. + ASSERT_FALSE(display.transfer_data()) << "transfer should have yielded after the first row"; + ASSERT_FALSE(display.cs.level) << "CS must stay asserted across a yield mid-phase"; + const int closed_after_setup = delegate.end_count; + + // Second call writes the final row of the CS phase; the deadline expires as it lands. + display.transfer_data(); + + EXPECT_EQ(delegate.end_count, closed_after_setup + 1) + << "completed CS phase skipped disable() -- SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "completed CS phase left CS asserted"; +} + +/// The CS1 phase has the same off-by-one, but fails worse: after the skipped epilogue the +/// function falls through to `return true`, reporting the transfer complete while the SPI +/// transaction is still open and CS1 is still asserted. The next command's `enable()` then +/// blocks forever. A transfer that reports done must have released the bus. +TEST(EPaperT133A01, TransferReportsDoneOnlyAfterReleasingTheBus) { + TestableT133A01 display(8, 2); + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + // Both phases are two rows each and every row overruns the deadline, so the transfer needs + // one call per row plus the setup call. Bound the loop so a regression fails rather than hangs. + int calls = 0; + while (!display.transfer_data()) { + ASSERT_LT(++calls, 10) << "transfer never reported completion"; + } + + EXPECT_TRUE(display.cs1.level) << "transfer reported done with CS1 still asserted"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) + << "transfer reported done with an SPI transaction still open -- the next enable() would deadlock"; +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index fb43b06567..9cca528744 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -212,6 +212,29 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Soldered Inkplate 6COLOR 7-color e-paper (600x448, UC8159-family) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate6color + 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 + inverted: true + lambda: |- + 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)); + it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 165, 0)); + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..fab1d922fc --- /dev/null +++ b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,23 @@ +# External RSA signing mode with a declared trusted-key list enables ESPHome's +# own multi-key OTA signature verifier (USE_OTA_SIGNED_VERIFICATION_MULTI_KEY), +# which accepts an image whose signature block matches one of the compiled-in +# trusted keys. wifi + ota pull in the ota component so CI actually compiles that +# verifier; allow_partition_access exercises the bootloader-update path too. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + verification_keys: + - ../../components/esp32/dummy_signing_key.pem + +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + allow_partition_access: true + +<<: !include common.yaml diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index fd42fac5a3..c16869d06b 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -21,7 +21,7 @@ esp32: disable_fatfs: true ota: - platform: esphome + - platform: esphome wifi: ssid: MySSID diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml deleted file mode 100644 index 5b57993e87..0000000000 --- a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Secure Boot V2 schemes carry the public key inside each image's signature -# block, so verifying externally-signed binaries needs no key in the config: -# a bare block enables verification with the default rsa3072 scheme. -esp32: - variant: esp32s3 - framework: - type: esp-idf - advanced: - signed_ota_verification: - -<<: !include common.yaml diff --git a/tests/components/esp32_ble_tracker/common.yaml b/tests/components/esp32_ble_tracker/common.yaml index 564cf1f6ea..9c880dbf1a 100644 --- a/tests/components/esp32_ble_tracker/common.yaml +++ b/tests/components/esp32_ble_tracker/common.yaml @@ -12,18 +12,21 @@ esp32_ble_tracker: then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address (%s) exists in list", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address (%s) exists in list", x.address_str_to(addr)); # yamllint enable rule:line-length - mac_address: AC:37:43:77:5F:4C then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); # yamllint enable rule:line-length - then: # yamllint disable rule:line-length - lambda: !lambda |- - ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); # yamllint enable rule:line-length on_ble_service_data_advertise: - service_uuid: ABCD diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index f3ee86bcce..701e513ebd 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -9,7 +9,7 @@ light: id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + rgbw_order: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/espnow/common-wifi.yaml b/tests/components/espnow/common-wifi.yaml new file mode 100644 index 0000000000..5ffa9dd44b --- /dev/null +++ b/tests/components/espnow/common-wifi.yaml @@ -0,0 +1,9 @@ +wifi: + ssid: MySSID + password: password1 + +espnow: + id: espnow_component + auto_add_peer: true + peers: + - 11:22:33:44:55:66 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index ae43baa41a..2f82e794c4 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -74,6 +74,7 @@ sensor: id: espnow_temp_sensor - platform: packet_transport + transport_id: transport1 provider: test-provider remote_id: espnow_temp_sensor id: remote_temp diff --git a/tests/components/espnow/test-wifi.esp32-idf.yaml b/tests/components/espnow/test-wifi.esp32-idf.yaml new file mode 100644 index 0000000000..c45547cd53 --- /dev/null +++ b/tests/components/espnow/test-wifi.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common-wifi.yaml diff --git a/tests/components/ethernet/common-ch390.yaml b/tests/components/ethernet/common-ch390.yaml new file mode 100644 index 0000000000..b27bc6ab4f --- /dev/null +++ b/tests/components/ethernet/common-ch390.yaml @@ -0,0 +1,19 @@ +ethernet: + type: CH390 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + 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-ch390.esp32-idf.yaml b/tests/components/ethernet/test-ch390.esp32-idf.yaml new file mode 100644 index 0000000000..50165d458f --- /dev/null +++ b/tests/components/ethernet/test-ch390.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ch390.yaml diff --git a/tests/components/exposure_notifications/common-ln.yaml b/tests/components/exposure_notifications/common-ln.yaml new file mode 100644 index 0000000000..f3f9b93464 --- /dev/null +++ b/tests/components/exposure_notifications/common-ln.yaml @@ -0,0 +1,5 @@ +exposure_notifications: + on_exposure_notification: + then: + - lambda: | + ESP_LOGD("main", "RSSI: %d", x.rssi); diff --git a/tests/components/exposure_notifications/common.yaml b/tests/components/exposure_notifications/common.yaml index faba5bb2d1..8cc209ff4e 100644 --- a/tests/components/exposure_notifications/common.yaml +++ b/tests/components/exposure_notifications/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub exposure_notifications: on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub then: - lambda: | ESP_LOGD("main", "Got notification:"); diff --git a/tests/components/exposure_notifications/test.ln882x-ard.yaml b/tests/components/exposure_notifications/test.ln882x-ard.yaml new file mode 100644 index 0000000000..964f5b68b0 --- /dev/null +++ b/tests/components/exposure_notifications/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + exposure_notifications: !include common-ln.yaml diff --git a/tests/components/exposure_notifications/validate.bk72xx-ard.yaml b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b487591d95 --- /dev/null +++ b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml @@ -0,0 +1,15 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +exposure_notifications: + on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub + then: + - lambda: | + ESP_LOGD("main", "Got notification:"); + ESP_LOGD("main", " RPI: %s", format_hex(x.rolling_proximity_identifier).c_str()); + ESP_LOGD("main", " RSSI: %d", x.rssi); diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 5c3f4b931c..384e12eaba 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -1,32 +1,20 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml xl9535: id: expander -display: - - platform: mipi_spi - id: gsl3670_display - spi_id: spi_bus - model: t-display-s3-pro - # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL - # pin, so override it onto a free pin for this test. - dc_pin: GPIO5 - -psram: - mode: quad - touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 - display: gsl3670_display + display: test_display_screen reset_pin: 10 interrupt_pin: 11 firmware: diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 0fc40737f0..24a67e2e45 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: gt911_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${display_reset_pin} - pages: - - id: gt911_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: gt911_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/gt911/test.esp32-idf.yaml b/tests/components/gt911/test.esp32-idf.yaml index 3bce86d9a3..9c2de1a425 100644 --- a/tests/components/gt911/test.esp32-idf.yaml +++ b/tests/components/gt911/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.esp8266-ard.yaml b/tests/components/gt911/test.esp8266-ard.yaml index c3bc159b5b..59af399be8 100644 --- a/tests/components/gt911/test.esp8266-ard.yaml +++ b/tests/components/gt911/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "12" reset_pin: "13" packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/gt911/test.rp2040-ard.yaml b/tests/components/gt911/test.rp2040-ard.yaml index 0c7f0bc504..efd5d9c2b1 100644 --- a/tests/components/gt911/test.rp2040-ard.yaml +++ b/tests/components/gt911/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - display_reset_pin: "10" interrupt_pin: "20" reset_pin: "21" packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + gt911: !include common.yaml diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp new file mode 100644 index 0000000000..6e9b567080 --- /dev/null +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. +TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +// The connection flag follows the bus controller in both directions. +TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { + TestableHoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + ASSERT_FALSE(sensor.state); + + connect_controller(door); + door.update(); + EXPECT_TRUE(sensor.state); + + door.set_valid_(false); + door.update(); + EXPECT_FALSE(sensor.state); +} + +// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice. +TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + connect_controller(door); + door.update(); + ASSERT_EQ(publishes, 1); + + // A status broadcast changes the door state without touching the connection. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + EXPECT_EQ(publishes, 1); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..552b1cb0fd --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -0,0 +1,17 @@ +hoermann_hcp: + id: hoermann_hcp_hub + modbus_id: modbus_server_bus + +cover: + - platform: hoermann_hcp + name: Garage Door + device_class: garage + +binary_sensor: + - platform: hoermann_hcp + is_connected: + name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..43ca47edb2 --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,147 @@ +#include + +#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. +TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN); + + // Any request marks the device connected, which is itself a state change. + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED); +} + +// Venting and half-open moves report no direction, so one is only derived once the position has moved. +TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // Position 100/200 = 0.5, state 0x80 -> resting half open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000})); + door.update(); + ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // State 0x05 -> moving to half-open, but the position has not moved yet. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // Position 120/200 = 0.6 is higher than before, so the door is opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING); + EXPECT_FLOAT_EQ(cover.position, 0.6f); +} + +// Booting while the door is already mid-move gives no baseline to compare against, so no direction +// may be inferred from the first update. +TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // The very first thing seen is a half-open move already at 100/200 = 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); +} + +// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather +// than as a target the door would be stopped at. +TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_open().perform(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed +} + +// The same for cover.close, which arrives as a position of 0.0. +TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_close().perform(); + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed +} + +TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_toggle().perform(); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + // The door is opening, so it takes an impulse to stop it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + + cover.make_call().set_command_stop().perform(); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// A position between the end stops starts the door in the right direction; it is stopped there later. +TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { + HoermannHcp door; // starts out fully closed + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_position(0.5f).perform(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed +} + +// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has +// to be published back over that assumption. +TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + + cover.make_call().set_command_close().perform(); + + EXPECT_EQ(poll_command(door).first, 0x0000); + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); +} + +// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would +// otherwise sit at its fully open default and look healthy. +TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + EXPECT_TRUE(cover.status_has_warning()); + + connect_controller(door); + door.update(); + EXPECT_FALSE(cover.status_has_warning()); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..1cc5301b4a --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,388 @@ +#include + +#include +#include + +#include "common.h" + +namespace esphome::hoermann_hcp::testing { + +// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. +TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 2, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 2u); + EXPECT_EQ(response[0], 0x0004); + EXPECT_EQ(response[1], 0x0000); +} + +// A bus scan (write 3 / read 5) answers with the fixed device identification block. +TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 5, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 5u); + EXPECT_EQ(response[1], 0x0005); + EXPECT_EQ(response[2], 0x0430); + EXPECT_EQ(response[3], 0x10ff); + EXPECT_EQ(response[4], 0xa845); +} + +// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press. +TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0001); + EXPECT_EQ(response[2], 0x0000); + EXPECT_EQ(response[3], 0x0000); +} + +// A queued control command is injected into the next command poll as a simulated key press. +TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { + HoermannHcp door; + connect_controller(door); + door.open_door(); + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value + EXPECT_EQ(response[3], 0x0000); +} + +// A read of any other block is an addressing error rather than a successful all-zero reply. +TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { + HoermannHcp door; + RegisterValues response; + EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A command is held for the key-press duration, then released, and only then can the next one be queued. +TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { + TestableHoermannHcp door; + connect_controller(door); + door.open_door(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. + door.close_door(); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + // With the command gone, the next one is accepted again. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed +} + +// Commands issued while the bus controller is absent are dropped instead of firing when it returns. +TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { + HoermannHcp door; + door.open_door(); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// Losing the controller must drop a command it never fetched, otherwise it blocks every later command +// and fires unasked once the bus comes back. +TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { + TestableHoermannHcp door; + connect_controller(door); + door.open_door(); + ASSERT_TRUE(door.is_valid()); + + door.set_valid_(false); + EXPECT_FALSE(door.is_valid()); + + // The reconnecting poll must not replay the dropped command. + EXPECT_EQ(poll_command(door).first, 0x0000); + // And the slot is free, so a new command is accepted. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); +} + +// The connection is dropped by update() once the controller stops polling, which is what releases a +// command it never fetched in the field. +TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { + TestableHoermannHcp door; + // Wide enough that a stall cannot expire the connection before the check below runs. + door.connection_timeout_ms_ = 10000; + connect_controller(door); + door.open_door(); + + // Still inside the window: the controller counts as present. + door.update(); + ASSERT_TRUE(door.is_valid()); + + // Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer. + door.connection_timeout_ms_ = 20; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + EXPECT_FALSE(door.is_valid()); + // The pending command went with the connection instead of firing on the reconnecting poll. + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to +// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely. +TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.open_door(); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // A status broadcast refreshes the connection without ever fetching the command. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + ASSERT_TRUE(door.is_valid()); + + // With the stale command gone, the door accepts commands again. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); +} + +// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed +// differently per block length. +TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) { + HoermannHcp door; + // Counter 0x34 in the high byte, command 0x07 in the low byte. + door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000})); + + RegisterValues command_poll; + door.on_read_holding_registers(STATE_REG, 8, command_poll); + ASSERT_EQ(command_poll.size(), 8u); + EXPECT_EQ(command_poll[0], 0x3400); // counter alone + EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low + + RegisterValues empty_poll; + door.on_read_holding_registers(STATE_REG, 2, empty_poll); + ASSERT_EQ(empty_poll.size(), 2u); + EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here + EXPECT_EQ(empty_poll[1], 0x0700); // command alone + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + ASSERT_EQ(scan.size(), 5u); + EXPECT_EQ(scan[0], 0x3400); + EXPECT_EQ(scan[1], 0x0705); +} + +// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position. +TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) { + HoermannHcp door; + // registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening). + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a +// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed. +TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) { + HoermannHcp door; + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::STOPPED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of +// the state register still has to be decoded. +TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) { + HoermannHcp door; + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061})); + EXPECT_EQ(door.get_door_state(), DoorState::VENT); +} + +// A door parking a count short of its end stop must still report exactly closed or open, because +// Cover::is_fully_closed() compares against 0.0 exactly. +TEST(HoermannHcpWrite, EndStopsReportExactPositions) { + HoermannHcp door; + // Position register 1 of 200 while the door reports itself closed. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f); + + // Position register 199 of 200 while the door reports itself open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f); + + // Away from the end stops the raw count is reported as-is. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// A position request below the lower snap threshold becomes a plain close command. +TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { + HoermannHcp door; + connect_controller(door); + door.set_position(0.02f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value +} + +// A half-open target starts the door moving towards the requested position. +TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { + HoermannHcp door; // starts out fully closed + connect_controller(door); + door.set_position(0.5f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value +} + +// The door has no notion of a target, so it is stopped with an impulse once it travels past the request. +TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + + // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Position 120/200 = 0.6 is past the target, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once +// must be read as "already stopped" rather than "still opening". +TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + // The door is stopped at 0.3 by a wall button, short of the requested 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A target armed while the door is still travelling the other way must not be judged by that old direction, +// otherwise the very next position it reports counts as reached and stops the door where it stands. +TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { + TestableHoermannHcp door; + connect_controller(door); + // The door is closing, passing 60/200 = 0.3. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + + // Still closing at 58/200 = 0.29: below the target, but not on the way to it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Now opening at 62/200 = 0.31, still short of the target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Past the target at 110/200 = 0.55, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// A motor turning around can report a momentary stop; dropping the target there would let the door run on +// to the end stop that the reversing command asked for. +TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + // The stop reported on the way from closing to opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // The door then opens and still has to be stopped at the requested position. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); +} + +// A door that never turns around has to lose the target as well, otherwise it would cut a later move short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the + // target is the only thing that may expire here. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000})); + door.update(); + ASSERT_TRUE(door.is_valid()); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/inkbird_ibsth1_mini/common-ln.yaml b/tests/components/inkbird_ibsth1_mini/common-ln.yaml new file mode 100644 index 0000000000..618b4ff879 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity diff --git a/tests/components/inkbird_ibsth1_mini/common.yaml b/tests/components/inkbird_ibsth1_mini/common.yaml index ba46b7dbf6..50c977cf8d 100644 --- a/tests/components/inkbird_ibsth1_mini/common.yaml +++ b/tests/components/inkbird_ibsth1_mini/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub mac_address: 38:81:D7:0A:9C:11 temperature: name: Inkbird IBS-TH1 Temperature diff --git a/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml new file mode 100644 index 0000000000..2d37c8d318 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + inkbird_ibsth1_mini: !include common-ln.yaml diff --git a/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..93ea63c2a8 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity + battery_level: + name: Inkbird IBS-TH1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:12 + temperature: + name: BK Inkbird Implicit Temperature diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml new file mode 100644 index 0000000000..ee881bd787 --- /dev/null +++ b/tests/components/ld6002b/common.yaml @@ -0,0 +1,167 @@ +ld6002b: + id: ld6002b_radar + wakeup_pin: GPIO14 + +sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + target_count: + name: Target Count + point_count: + name: Point Count + target_1: + x: + name: Target-1 X + y: + name: Target-1 Y + z: + name: Target-1 Z + doppler_index: + name: Target-1 Dop + cluster_id: + name: Target-1 Cluster + target_2: + x: + name: Target-2 X + y: + name: Target-2 Y + z: + name: Target-2 Z + doppler_index: + name: Target-2 Dop + cluster_id: + name: Target-2 Cluster + target_3: + x: + name: Target-3 X + y: + name: Target-3 Y + z: + name: Target-3 Z + doppler_index: + name: Target-3 Dop + cluster_id: + name: Target-3 Cluster + interference_area_0: + x_min: + name: Interference-0 X Min + x_max: + name: Interference-0 X Max + y_min: + name: Interference-0 Y Min + y_max: + name: Interference-0 Y Max + z_min: + name: Interference-0 Z Min + z_max: + name: Interference-0 Z Max + detection_area_0: + x_min: + name: Detection-0 X Min + x_max: + name: Detection-0 X Max + y_min: + name: Detection-0 Y Min + y_max: + name: Detection-0 Y Max + z_min: + name: Detection-0 Z Min + z_max: + name: Detection-0 Z Max + +binary_sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + target: + name: Presence + target_1: + name: Target-1 Presence + detection_area_0: + name: Detection Area-0 Presence + +text_sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + work_mode: + name: Work Mode + ota_version: + name: OTA Version + +number: + - platform: ld6002b + ld6002b_id: ld6002b_radar + hold_delay: + name: Hold Delay + z_min: + name: Z Min + z_max: + name: Z Max + low_power_sleep_time: + name: Low Power Sleep + area_config: + x_min: + name: Area X Min + x_max: + name: Area X Max + y_min: + name: Area Y Min + y_max: + name: Area Y Max + z_min: + name: Area Z Min + z_max: + name: Area Z Max + +select: + - platform: ld6002b + ld6002b_id: ld6002b_radar + sensitivity: + name: Sensitivity + trigger_speed: + name: Trigger Speed + installation_mode: + name: Installation + area_id: + name: Area ID + +switch: + - platform: ld6002b + ld6002b_id: ld6002b_radar + low_power: + name: Low Power + point_cloud: + name: Point Cloud + target_display: + name: Target Display + +button: + - platform: ld6002b + ld6002b_id: ld6002b_radar + apply_area: + name: Apply Area + auto_interference: + name: Auto Interference + get_areas: + name: Get Areas + clear_interference: + name: Clear Interference + reset_detection_area: + name: Reset Detection + get_delay: + name: Get Delay + get_sensitivity: + name: Get Sensitivity + get_trigger_speed: + name: Get Trigger Speed + get_z_range: + name: Get Z Range + get_installation: + name: Get Installation + get_low_power_mode: + name: Get Low Power Mode + get_low_power_sleep_time: + name: Get Low Power Sleep + reset_unattended: + name: Reset Unattended + wake: + name: Wake diff --git a/tests/components/ld6002b/test.esp32-idf.yaml b/tests/components/ld6002b/test.esp32-idf.yaml new file mode 100644 index 0000000000..d26ef5c348 --- /dev/null +++ b/tests/components/ld6002b/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld6002b: !include common.yaml diff --git a/tests/components/ld6002b/test.esp8266-ard.yaml b/tests/components/ld6002b/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8846b4ab50 --- /dev/null +++ b/tests/components/ld6002b/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld6002b: !include common.yaml diff --git a/tests/components/ld6002b/test.rp2040-ard.yaml b/tests/components/ld6002b/test.rp2040-ard.yaml new file mode 100644 index 0000000000..4edcb6965e --- /dev/null +++ b/tests/components/ld6002b/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld6002b: !include common.yaml diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/ln882h_ble/common.yaml b/tests/components/ln882h_ble/common.yaml new file mode 100644 index 0000000000..c466c2a4de --- /dev/null +++ b/tests/components/ln882h_ble/common.yaml @@ -0,0 +1,2 @@ +ln882h_ble: + enable_on_boot: true diff --git a/tests/components/ln882h_ble/test.ln882x-ard.yaml b/tests/components/ln882h_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..08cb35b3d8 --- /dev/null +++ b/tests/components/ln882h_ble/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble: !include common.yaml diff --git a/tests/components/ln882h_ble_tracker/common-boundary.yaml b/tests/components/ln882h_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..b6df6f6f39 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common-boundary.yaml @@ -0,0 +1,12 @@ +ln882h_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + active: false + continuous: false diff --git a/tests/components/ln882h_ble_tracker/common.yaml b/tests/components/ln882h_ble_tracker/common.yaml new file mode 100644 index 0000000000..aba02147c8 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common.yaml @@ -0,0 +1,16 @@ +ln882h_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 50ms + duration: 5min + continuous: true + +# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI +# (same coverage arrangement as the rp2_ble_tracker tests). +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml new file mode 100644 index 0000000000..3cd3ce28b2 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/test-automations.ln882x-ard.yaml @@ -0,0 +1,34 @@ +packages: + ln882h_ble_tracker: !include common.yaml + +esphome: + on_boot: + then: + - ln882h_ble_tracker.start_scan + - ln882h_ble_tracker.start_scan: + continuous: true + - ln882h_ble_tracker.start_scan: + continuous: !lambda return false; + - ln882h_ble_tracker.stop_scan + +ln882h_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); + on_ble_service_data_advertise: + - service_uuid: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended"); diff --git a/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6a9efad314 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common.yaml diff --git a/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml new file mode 100644 index 0000000000..fc5790e3b2 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common-boundary.yaml diff --git a/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml b/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml new file mode 100644 index 0000000000..76444a2e89 --- /dev/null +++ b/tests/components/logger/test-uart0_no_logging.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common-uart0_no_logging.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index f085b62cb6..46c1fd362a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -174,7 +174,8 @@ lvgl: - id: anim_color duration: 2s timing: - - round_trip + - type: round_trip + pause: 0.5 - type: gravity bounce: 0.3 acceleration: 0.8 @@ -295,6 +296,22 @@ lvgl: id: style_test bg_color: blue bg_opa: !lambda return 0.5; + # `obj` is already themed above -- exercises updating an existing hidden style. + - lvgl.theme.update: + obj: + border_width: 2 + # `label` is never mentioned under `theme:` -- exercises lazily creating the + # hidden style and getting it attached to already-built label widgets. + - lvgl.theme.update: + label: + text_color: red + # `button` is never mentioned under `theme:`, and only a non-default state is + # targeted here -- exercises that no spurious, empty main/default style is + # created (and attached to every button) alongside the requested one. + - lvgl.theme.update: + button: + pressed: + bg_color: red - lvgl.image.update: id: lv_image src: @@ -740,7 +757,25 @@ lvgl: on_defocus: lvgl.widget.hide: hello_label on_focus: - logger.log: Button clicked + - logger.log: Button clicked + - lvgl.widget.set_z_index: + id: hello_label + position: top + - lvgl.widget.set_z_index: + id: hello_label + position: bottom + - lvgl.widget.set_z_index: + id: hello_label + position: up + - lvgl.widget.set_z_index: + id: hello_label + position: down + - lvgl.widget.set_z_index: + id: hello_label + position: 1 + - lvgl.widget.set_z_index: + id: hello_label + position: -1 on_scroll: logger.log: Button clicked on_scroll_end: @@ -1523,17 +1558,20 @@ font: image: - id: cat_image + platform: file resize: 256x48 file: $component_dir/logo-text.svg type: RGB565 transparency: alpha_channel - id: dog_image + platform: file file: $component_dir/logo-text.svg resize: 256x48 type: BINARY transparency: chroma_key - id: alert + platform: file file: $component_dir/logo-text.svg type: grayscale resize: 100x100 diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 90cbb3c0a5..3fa54fa3d6 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -39,7 +39,7 @@ lvgl: timing: - round_trip - type: ease_in_out - weight: 3 + weight: 0.5 on_start: - logger.log: anim started on_stop: diff --git a/tests/components/mcp4461/common.yaml b/tests/components/mcp4461/common.yaml index 71e2528aa4..8accc2ea54 100644 --- a/tests/components/mcp4461/common.yaml +++ b/tests/components/mcp4461/common.yaml @@ -3,30 +3,61 @@ mcp4461: i2c_id: i2c_bus output: + # All-terminals-off coverage lives here (folded from a former second channel-A + # output — one output per channel keeps the reg_ state deterministic). - platform: mcp4461 id: digipot_wiper_1 mcp4461_id: mcp4461_digipot_01 channel: A - - - platform: mcp4461 - id: digipot_wiper_2 - mcp4461_id: mcp4461_digipot_01 - channel: B - - - platform: mcp4461 - id: digipot_wiper_3 - mcp4461_id: mcp4461_digipot_01 - channel: C - - - platform: mcp4461 - id: digipot_wiper_4 - mcp4461_id: mcp4461_digipot_01 - channel: D - - - platform: mcp4461 - id: digipot_wiper_5 - mcp4461_id: mcp4461_digipot_01 - channel: A terminal_a: false terminal_b: false terminal_w: false + + - platform: mcp4461 + id: digipot_wiper_2 + mcp4461_id: mcp4461_digipot_01 + channel: B + nonvolatile: false + + - platform: mcp4461 + id: digipot_wiper_3 + mcp4461_id: mcp4461_digipot_01 + channel: C + nonvolatile_write_delay: 5s + initial_value: 0.5 + + # TCON1 coverage: terminal flags on a channel D output exercise the + # calc_terminal_connector_byte_() write path for wipers 2/3. + - platform: mcp4461 + id: digipot_wiper_4 + mcp4461_id: mcp4461_digipot_01 + channel: D + terminal_a: false + terminal_w: false + + # Bare NV-channel output — the pre-existing persistence workaround; must + # keep validating without any nonvolatile key (regression: schema default + # used to materialize the key on every channel and fail final validation). + - platform: mcp4461 + id: digipot_nv_wiper_1 + mcp4461_id: mcp4461_digipot_01 + channel: E + + # Explicit opt-out on an NV channel is a harmless no-op and stays valid. + - platform: mcp4461 + id: digipot_nv_wiper_2 + mcp4461_id: mcp4461_digipot_01 + channel: F + nonvolatile: false + +button: + - platform: template + name: "Digipot test actions" + on_press: + - mcp4461.wiper.increase: digipot_wiper_1 + - mcp4461.wiper.decrease: digipot_wiper_1 + - mcp4461.wiper.store_nonvolatile: digipot_wiper_2 + - mcp4461.wiper.set_terminal: + id: digipot_wiper_1 + terminal: a + enable: false diff --git a/tests/components/micro_wake_word/validate.esp32-idf.yaml b/tests/components/micro_wake_word/validate.esp32-idf.yaml new file mode 100644 index 0000000000..d87b19bdcf --- /dev/null +++ b/tests/components/micro_wake_word/validate.esp32-idf.yaml @@ -0,0 +1,21 @@ +# Config-only test: micro_wake_word without any compiled-in models. Covers the optional models +# schema, which validates without a model list. Wake word models are added at runtime instead, +# which voice_assistant wires up. +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + +microphone: + - platform: i2s_audio + id: echo_microphone + i2s_audio_id: i2s_audio_bus + i2s_din_pin: ${mic_din_pin} + adc_type: external + pdm: true + bits_per_sample: 16bit + +micro_wake_word: + microphone: echo_microphone + # models is omitted entirely, so the default empty list applies diff --git a/tests/components/microphone/common-pdm.yaml b/tests/components/microphone/common-pdm.yaml new file mode 100644 index 0000000000..093dcb24f8 --- /dev/null +++ b/tests/components/microphone/common-pdm.yaml @@ -0,0 +1,25 @@ +microphone: + - platform: i2s_audio + id: mic_id_external + i2s_din_pin: ${i2s_din_pin1} + adc_type: external + pdm: false + mclk_multiple: 384 + correct_dc_offset: true + on_data: + - if: + condition: + - microphone.is_muted: + id: mic_id_external + then: + - microphone.unmute: + id: mic_id_external + else: + - microphone.mute: + id: mic_id_external + - platform: i2s_audio + id: mic_id_pdm + i2s_din_pin: ${i2s_din_pin2} + adc_type: external + pdm: true + pdm_dsr: 16 diff --git a/tests/components/microphone/common.yaml b/tests/components/microphone/common.yaml index 39ab06da61..281bda1ce0 100644 --- a/tests/components/microphone/common.yaml +++ b/tests/components/microphone/common.yaml @@ -1,8 +1,3 @@ -i2s_audio: - i2s_bclk_pin: ${i2s_bclk_pin} - i2s_lrclk_pin: ${i2s_lrclk_pin} - i2s_mclk_pin: ${i2s_mclk_pin} - microphone: - platform: i2s_audio id: mic_id_external @@ -22,8 +17,3 @@ microphone: else: - microphone.mute: id: mic_id_external - - platform: i2s_audio - id: mic_id_pdm - i2s_din_pin: ${i2s_din_pin2} - adc_type: external - pdm: true diff --git a/tests/components/microphone/test.esp32-idf.yaml b/tests/components/microphone/test.esp32-idf.yaml index 2f39263a43..65d7081bcd 100644 --- a/tests/components/microphone/test.esp32-idf.yaml +++ b/tests/components/microphone/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: - i2s_bclk_pin: GPIO15 - i2s_lrclk_pin: GPIO4 - i2s_mclk_pin: GPIO5 i2s_din_pin1: GPIO33 i2s_din_pin2: GPIO34 -<<: !include common.yaml +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + +<<: !include common-pdm.yaml diff --git a/tests/components/microphone/test.esp32-s2-idf.yaml b/tests/components/microphone/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..47b9283720 --- /dev/null +++ b/tests/components/microphone/test.esp32-s2-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s2-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index c7b18a6701..25fc2debcd 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -1,7 +1,3 @@ -wifi: - ssid: MySSID - password: password1 - climate: - platform: midea id: midea_unit diff --git a/tests/components/midea/test.esp32-ard.yaml b/tests/components/midea/test.esp32-ard.yaml index 1e3fe0ff51..17ced80477 100644 --- a/tests/components/midea/test.esp32-ard.yaml +++ b/tests/components/midea/test.esp32-ard.yaml @@ -1,5 +1,8 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml uart: !include ../../test_build_components/common/uart/esp32-ard.yaml + midea: !include common.yaml -<<: !include common.yaml +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/midea/test.esp32-h2-idf.yaml b/tests/components/midea/test.esp32-h2-idf.yaml new file mode 100644 index 0000000000..45b73dc6c7 --- /dev/null +++ b/tests/components/midea/test.esp32-h2-idf.yaml @@ -0,0 +1,5 @@ +# ESP32-H2 has no WiFi PHY; this verifies the component builds without wifi +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-h2-idf.yaml + midea: !include common.yaml diff --git a/tests/components/midea/test.esp32-idf.yaml b/tests/components/midea/test.esp32-idf.yaml new file mode 100644 index 0000000000..5ad22b5b93 --- /dev/null +++ b/tests/components/midea/test.esp32-idf.yaml @@ -0,0 +1,15 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + +# Regression test for https://github.com/esphome/esphome/issues/18054: the +# MideaUART ESP-IDF shims must not make unqualified millis() ambiguous +interval: + - interval: 10s + then: + - lambda: ESP_LOGD("test", "%u", millis()); diff --git a/tests/components/midea/test.esp8266-ard.yaml b/tests/components/midea/test.esp8266-ard.yaml index 9825ff85a1..70a0b00105 100644 --- a/tests/components/midea/test.esp8266-ard.yaml +++ b/tests/components/midea/test.esp8266-ard.yaml @@ -1,5 +1,8 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea: !include common.yaml -<<: !include common.yaml +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index ef3cdd0fff..3bc6d5b2b8 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -2,16 +2,16 @@ namespace esphome::mitsubishi_cn105::testing { -struct TestContext { +struct MitsubishiCN105TestsContext { MockUARTComponent uart; uart::UARTDevice device{&uart}; TestableMitsubishiCN105 sut{device}; - TestContext() { this->sut.set_current_time(0); } + MitsubishiCN105TestsContext() { this->sut.set_current_time(0); } }; TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); @@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -75,7 +75,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING); - // Now fetch room temperature (0x03) + // Now fetch telemetry (0x03) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); @@ -84,11 +84,11 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Clear TX bytes. ctx.uart.tx.clear(); - // Room temperature response + // Telemetry response ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5}); - // Room temperature should still have initial value + // Room temperature from telemetry should still have initial value EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan()); ctx.sut.set_current_time(400); @@ -97,7 +97,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_TRUE(ctx.uart.rx.empty()); EXPECT_TRUE(ctx.sut.is_status_initialized()); - // Check room temperature we just read from received package + // Check room temperature we just read from telemetry package EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f); EXPECT_TRUE(ctx.uart.tx.empty()); @@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { } TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { } TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); @@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); @@ -266,14 +266,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); - EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); @@ -281,14 +281,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); - EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); @@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); @@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); @@ -316,11 +316,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); @@ -328,11 +328,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_power(true); ctx.sut.apply_settings(); @@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_target_temperature(23.0f); ctx.sut.apply_settings(); @@ -352,9 +352,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.0f); ctx.sut.apply_settings(); @@ -363,9 +363,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.5f); ctx.sut.apply_settings(); @@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplyModeCool) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); ctx.sut.apply_settings(); @@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) { } TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); ctx.sut.apply_settings(); @@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { } TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); ctx.sut.apply_settings(); @@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -414,9 +414,9 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.property_context_.set_wide_vane_high_bit = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { } TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { } TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Set remote temperature ctx.sut.set_remote_temperature(28.5f); @@ -505,10 +505,10 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { } TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN)); // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); @@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); ASSERT_FALSE(ctx.sut.update()); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); // The next apply sends the remote-temperature packet and clears the last pending flag. ctx.uart.tx.clear(); @@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { } TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -578,28 +578,28 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(40.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(NAN); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 45f7b65289..f542880eef 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/automation.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -46,12 +48,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; - using MitsubishiCN105::UpdateFlag; + using MitsubishiCN105::PropertyId; using MitsubishiCN105::state_; using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; - using MitsubishiCN105::use_temperature_encoding_b_; - using MitsubishiCN105::set_wide_vane_high_bit_; + using MitsubishiCN105::property_context_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; @@ -65,11 +66,23 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { public: + TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); } + using MitsubishiCN105Climate::apply_values_; using MitsubishiCN105Climate::last_non_swing_vane_mode_; using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; - MitsubishiCN105::Status &status() { return static_cast(this->hp_).status_; } + MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + + protected: + MitsubishiCN105Component component_; +}; + +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } }; } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5b9c3aaaf6..fc14724786 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -1,17 +1,39 @@ +mitsubishi_cn105: + id: ac + uart_id: uart_bus + update_interval: 30s + telemetry_request_min_interval: 120s + vane: + on_state: + - logger.log: + format: "TRIGGER: vane on_state is auto: %s" + args: ['x.vertical.direction == VERTICAL_VANE_MODE_AUTO ? "yes" : "no"'] + climate: - platform: mitsubishi_cn105 - id: ac + mitsubishi_cn105_id: ac name: "AC Test" - uart_id: uart_bus - update_interval: 30s - current_temperature_min_interval: 120s supported_swing_modes: BOTH +select: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac + vertical_vane_direction: + name: "Vertical Vane" + esphome: on_boot: then: - - climate.mitsubishi_cn105.set_remote_temperature: + - mitsubishi_cn105.set_remote_temperature: id: ac temperature: 22.0 - - climate.mitsubishi_cn105.clear_remote_temperature: + - mitsubishi_cn105.clear_remote_temperature: id: ac + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: SWING + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp new file mode 100644 index 0000000000..c957759223 --- /dev/null +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -0,0 +1,73 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { + TestableMitsubishiCN105Component hub; + size_t callback_count = 0; + std::optional callback_direction; + hub.add_on_vane_state_callback([&](const VaneState &state) { + callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); + + hub.publish_status(); + + EXPECT_EQ(callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) { + TestableMitsubishiCN105Component hub; + size_t status_callback_count = 0; + size_t vane_callback_count = 0; + std::optional callback_direction; + hub.add_on_status_callback([&]() { status_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &state) { + vane_callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 1); + EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN}); + + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 2); + EXPECT_EQ(vane_callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { + TestableMitsubishiCN105Component hub; + + auto call = hub.make_vane_call(); + call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5); + call.perform(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); +} + +TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { + TestableMitsubishiCN105Component hub; + VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); + + action.play(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp new file mode 100644 index 0000000000..1f928e3bf4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -0,0 +1,104 @@ +#include "../common.h" +#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h" + +namespace esphome::mitsubishi_cn105::testing { + +class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { + public: + using MitsubishiCN105VerticalVaneDirectionSelect::control; +}; + +struct VerticalVaneDirectionSelectTestContext { + TestableMitsubishiCN105Component hub; + TestableMitsubishiCN105VerticalVaneDirectionSelect select; + + VerticalVaneDirectionSelectTestContext() { + this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"}); + this->select.set_parent(&this->hub); + this->select.setup(); + } +}; + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array expected_modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < expected_modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.select.control(i); + EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]); + } +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.hub.mutable_status().vane_mode = modes[i]; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{i}); + } + + ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + ctx.select.control(6); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + ctx.select.control(3); + EXPECT_EQ(ctx.select.active_index(), std::optional{3}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{0}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) { + VerticalVaneDirectionSelectTestContext ctx; + + ctx.select.control(3); + + EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3); + EXPECT_FALSE(ctx.select.has_state()); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml new file mode 100644 index 0000000000..247568cfc3 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml @@ -0,0 +1,16 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + id: ac + name: "AC Test" + +esphome: + on_boot: + then: + - climate.mitsubishi_cn105.set_remote_temperature: + id: ac + temperature: 22.0 + - climate.mitsubishi_cn105.clear_remote_temperature: + id: ac diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml new file mode 100644 index 0000000000..a2abaf8b9b --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + current_temperature_min_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..0ef70b6535 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml new file mode 100644 index 0000000000..065d2b5495 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + uart_id: uart_bus diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml new file mode 100644 index 0000000000..2e8f714f52 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + update_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..03f05da5f4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +mitsubishi_cn105: + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h new file mode 100644 index 0000000000..d03ccf8ec3 --- /dev/null +++ b/tests/components/modbus/common.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::modbus::testing { + +// A UART that discards all writes, for tests that never inspect the wire. +class NullUART : public uart::UARTComponent { + public: + NullUART() { this->set_baud_rate(115200); } + void write_array(const uint8_t *data, size_t len) override {} + bool peek_byte(uint8_t *data) override { return false; } + bool read_array(uint8_t *data, size_t len) override { return false; } + size_t available() override { return 0; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + void check_logger_conflict() override {} +}; + +// A UART that records every byte written so tests can assert on the exact wire response. +class RecordingUART : public NullUART { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + + std::vector written; +}; + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index af43c6e5e3..869b280b0d 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -39,6 +39,50 @@ namespace esphome::modbus::testing { namespace { +// A UART the test can inject received bytes into; sent bytes are discarded. +class InjectableUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override {} + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } + void check_logger_conflict() override {} + + void inject_frame(uint8_t address, std::span pdu) { + // Wire frame: address + PDU + CRC16(low, high) + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + +class NullDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_response(std::span request_pdu, std::span response_pdu) override { + this->responses++; + } + int responses{0}; +}; + struct Sample { size_t count; size_t bytes; @@ -73,9 +117,10 @@ TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { EXPECT_EQ(large.count, 1u); } -// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx -// deque's first block is already allocated when the hub is constructed. (A queue deeper than one -// deque block - roughly a dozen commands - would allocate further blocks.) +// Queueing typical commands is allocation-free within the deque's first block: the frame fits the +// inline buffer, every entry is a plain append (ordering lives in selection, not storage), and the +// first block is already allocated when the hub is constructed. A 512-byte deque block holds +// 512 / sizeof(ModbusDeviceCommand) entries (16 on the 64-bit host); a deeper queue allocates more. TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { ModbusClientHub hub; ModbusClientDevice device(&hub, 0x02); @@ -85,22 +130,94 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); constexpr int n = 12; + static_assert(n * sizeof(ModbusDeviceCommand) < 512, "keep n within one deque block so the probe stays meaningful"); size_t total = 0; for (int i = 0; i != n; i++) { - total += sample([&] { device.send_pdu(req); }).count; + req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue + total += sample([&] { device.queue_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); } +// A WRITE arriving behind queued reads is a plain append too - the old priority front-insert (and +// its possible front-block allocation) is gone; the write wins transmit SELECTION instead. +TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + for (int i = 0; i != 3; i++) { + req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue + device.queue_pdu(req); + } + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + Sample append = sample([&] { device.queue_pdu(write_pdu); }); + printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); + EXPECT_EQ(append.count, 0u); +} + +// End to end: bytes injected at the UART travel through receive, frame parsing, response matching and +// device dispatch. The first response may grow the hub's rx buffer once; after that warm-up, handling a +// response performs zero heap allocations all the way to the device callback. +TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { + InjectableUART uart; + uart.set_baud_rate(115200); // tx timing math divides by the baud rate + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + NullDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + // Largest possible read response first, so the rx buffer warm-up covers every later size. + uint8_t large_resp[252] = {0x03, 250}; + const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + + auto round_trip = [&](std::span response_pdu) { + device.queue_pdu(req); + hub.loop(); // transmit; the tx queue is empty during the measured receive below + uart.inject_frame(0x02, response_pdu); + return sample([&] { hub.loop(); }); // receive + parse + match + dispatch + }; + + Sample warmup = round_trip(std::span(large_resp, sizeof(large_resp))); + Sample steady_large = round_trip(std::span(large_resp, sizeof(large_resp))); + Sample steady_small = round_trip(small_resp); + + printf("HEAPPROBE warmup count=%zu bytes=%zu\n", warmup.count, warmup.bytes); + printf("HEAPPROBE steady_large count=%zu bytes=%zu\n", steady_large.count, steady_large.bytes); + printf("HEAPPROBE steady_small count=%zu bytes=%zu\n", steady_small.count, steady_small.bytes); + + EXPECT_EQ(device.responses, 3); + EXPECT_LE(warmup.count, 1u); // at most the one-time rx buffer growth + EXPECT_EQ(steady_large.count, 0u); + EXPECT_EQ(steady_small.count, 0u); +} + } // namespace esphome::modbus::testing #else // !HEAP_PROBE_HAS_ASAN +// Stub every ASan-gated test name, so the suite's test list is identical in every build configuration. namespace esphome::modbus::testing { TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; } +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} } // namespace esphome::modbus::testing #endif // HEAP_PROBE_HAS_ASAN diff --git a/tests/components/modbus/modbus_broadcast_test.cpp b/tests/components/modbus/modbus_broadcast_test.cpp new file mode 100644 index 0000000000..6f088f4888 --- /dev/null +++ b/tests/components/modbus/modbus_broadcast_test.cpp @@ -0,0 +1,369 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus { + +namespace { + +// A server device that records the writes the hub routes to it. +class RecordingDevice : public ModbusServerDevice { + public: + explicit RecordingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + this->last_start_address = start_address; + this->last_values.assign(registers.begin(), registers.end()); + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_values; +}; + +// A server device that records the coil writes the hub routes to it. Coils arrive as a PackedBits view +// over the hub's buffers, so the bits are copied out here rather than the view retained. +class RecordingCoilDevice : public ModbusServerDevice { + public: + explicit RecordingCoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_start_address = start_address; + this->last_bits.clear(); + for (uint16_t i = 0; i != bits.size(); i++) { + this->last_bits.push_back(bits[i]); + } + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_bits; +}; + +// A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch. +class RejectingDevice : public ModbusServerDevice { + public: + explicit RejectingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int write_count{0}; +}; + +// Drives full frames through the server hub's receive path in tests. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + pdu + CRC) and runs the full receive-side parser + // (parse_modbus_frames), so the expecting-peer-response routing is exercised, not just the frame parser + // below it. Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +using testing::RecordingUART; + +// A broadcast (address 0) single-register write reaches every registered device and is not answered. +// Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length, +// CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it. +TEST(ModbusBroadcast, SingleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A single-register broadcast (FC 0x06) must still reach every device when the hub is mid-way through +// waiting for a peer's response. Its frame length matches a response frame, so without the address-0 guard +// in parse_modbus_frames() it would be swallowed by the response parser instead of being dispatched. +TEST(ModbusBroadcast, SingleRegisterBroadcastDispatchedWhileExpectingPeerResponse) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // A unicast write addressed to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t peer_pdu[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), peer_pdu, + sizeof(peer_pdu))); + ASSERT_EQ(device_a.write_count, 0); // the peer request is not for our devices + ASSERT_EQ(device_b.write_count, 0); + + // The broadcast that follows must still be delivered to every device, and still without a reply. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// After dispatching a broadcast, the hub must not still expect a peer response: a following unicast FC 0x06 +// to one of our own devices must be handled, not misparsed as that peer's response and dropped. +TEST(ModbusBroadcast, BroadcastClearsStalePeerExpectation) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // A unicast write to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t pdu_data[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + + // The broadcast that follows clears that expectation as it is dispatched. + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + ASSERT_EQ(device.write_count, 1); + + // The next unicast FC 0x06 to our own device is handled, not swallowed by the stale expectation. + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.write_count, 2); +} + +// A broadcast multi-register write is decoded and delivered to every device, still without a reply. +TEST(ModbusBroadcast, MultipleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: start 0x9D31, quantity 2, byte count 4, values 0x0102 and 0x0304. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 2u); + EXPECT_EQ(device->last_values[0], 0x0102); + EXPECT_EQ(device->last_values[1], 0x0304); + } + EXPECT_TRUE(uart.written.empty()); +} + +// A read broadcast is meaningless (it would need a reply), so nothing is dispatched and nothing is sent. +TEST(ModbusBroadcast, ReadFunctionCodeIsIgnoredAndProducesNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x03 payload: start 0x0000, quantity 2. Reads cannot be broadcast. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::READ_HOLDING_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); // no device was written + EXPECT_TRUE(uart.written.empty()); // and the broadcast address is never answered +} + +// An invalid broadcast write is silently dropped: no writes dispatched and no exception reply sent. +TEST(ModbusBroadcast, InvalidMultipleWriteBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: quantity 2 but byte count 2 (should be 4), so parsing fails. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x02, 0x01, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device_a.write_count, 0); + EXPECT_EQ(device_b.write_count, 0); + EXPECT_TRUE(uart.written.empty()); +} + +// A device that rejects a broadcast write must not stop dispatch to devices registered after it, and the +// broadcast is still never answered. +TEST(ModbusBroadcast, RejectingDeviceDoesNotStopBroadcastDispatch) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RejectingDevice rejecter(0x02); + RecordingDevice device(0x03); + hub.register_device(&rejecter); // registered first, so a rejection happens before the normal device + hub.register_device(&device); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(rejecter.write_count, 1); // the rejecting device was still invoked + EXPECT_EQ(device.write_count, 1); // and dispatch continued to the device registered after it + EXPECT_EQ(device.last_start_address, 0x9D31); + ASSERT_EQ(device.last_values.size(), 1u); + EXPECT_EQ(device.last_values[0], 0x00A5); + EXPECT_TRUE(uart.written.empty()); // a broadcast is never answered, even when a device rejects +} + +// A unicast out-of-range write sends exactly one exception frame on the wire. +TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x10 payload: start 0xFFFF, quantity 2, byte count 4, values valid but address range overflows. + const uint8_t pdu_data[] = {0xFF, 0xFF, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[0], 0x02); // server address + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); +} + +// A broadcast single-coil write (FC 0x05) reaches every device and is not answered. The 2-byte ON value +// is normalized to a one-bit view, so the handler sees the same shape as a multiple-coil write of one. +TEST(ModbusBroadcast, SingleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x05 payload: coil 0x00AC, value 0xFF00 (ON). + const uint8_t pdu_data[] = {0x00, 0xAC, 0xFF, 0x00}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x00AC); + ASSERT_EQ(device->last_bits.size(), 1u); + EXPECT_TRUE(device->last_bits[0]); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A broadcast multiple-coil write (FC 0x0F) delivers the packed bits to every device, LSB first. +TEST(ModbusBroadcast, MultipleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x0F payload: start 0x0013, 10 coils, 2 bytes, 0xCD 0x01 -> bit 0 set, bit 8 set. + const uint8_t pdu_data[] = {0x00, 0x13, 0x00, 0x0A, 0x02, 0xCD, 0x01}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x0013); + ASSERT_EQ(device->last_bits.size(), 10u); + EXPECT_TRUE(device->last_bits[0]); // 0xCD bit 0 + EXPECT_FALSE(device->last_bits[1]); // 0xCD bit 1 + EXPECT_TRUE(device->last_bits[8]); // 0x01 bit 0 + EXPECT_FALSE(device->last_bits[9]); // padding bit + } + EXPECT_TRUE(uart.written.empty()); +} + +// A coil broadcast that fails validation is dropped exactly like a bad register broadcast: no handler +// call and, because broadcasts are never answered, no exception frame either. +TEST(ModbusBroadcast, InvalidCoilBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device(0x02); + hub.register_device(&device); + + // Byte count disagrees with the coil quantity: 10 coils need 2 bytes, not 1. + const uint8_t bad_count[] = {0x00, 0x13, 0x00, 0x0A, 0x01, 0xCD}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), bad_count, sizeof(bad_count))); + EXPECT_EQ(device.write_count, 0); + + // A single-coil value must be 0x0000 or 0xFF00; anything else is out of spec. + const uint8_t bad_value[] = {0x00, 0xAC, 0x12, 0x34}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + bad_value, sizeof(bad_value))); + EXPECT_EQ(device.write_count, 0); + + EXPECT_TRUE(uart.written.empty()); +} + +} // namespace esphome::modbus diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp new file mode 100644 index 0000000000..333da5b228 --- /dev/null +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -0,0 +1,414 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records every typed callback so tests can assert on the dispatch performed by the default +// on_response()/on_error() implementations. +class RecordingDevice : public ModbusClientDevice { + public: + struct ReadRegistersCall { + uint16_t start_address; + std::vector registers; + ResponseStatus status; + }; + struct ReadBitsCall { + uint16_t start_address; + uint16_t count; + std::vector packed; + ResponseStatus status; + }; + struct WriteCall { + uint16_t address; + uint16_t value; + ResponseStatus status; + }; + + void on_read_holding_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->holding_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_read_input_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->input_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->coil_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->discrete_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_register_calls.push_back({address, value, status}); + } + void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) override { + this->write_single_coil_calls.push_back({address, static_cast(value), status}); + } + void on_write_multiple_registers(uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->write_multiple_registers_calls.push_back({start_address, {registers.begin(), registers.end()}, status}); + } + void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->write_multiple_coils_calls.push_back( + {start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status}); + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->custom_responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->custom_statuses.push_back(status); + } + + std::vector holding_calls; + std::vector input_calls; + std::vector coil_calls; + std::vector discrete_calls; + std::vector write_single_register_calls; + std::vector write_single_coil_calls; + std::vector write_multiple_registers_calls; + std::vector write_multiple_coils_calls; + std::vector> custom_requests; + std::vector> custom_responses; + std::vector custom_statuses; +}; + +// Overrides only the generic callbacks to verify the typed defaults delegate to them. +class GenericDevice : public ModbusClientDevice { + public: + void on_read_registers(EntityType register_type, uint16_t start_address, std::span registers, + ResponseStatus status) override { + this->register_type = register_type; + this->start_address = start_address; + this->registers.assign(registers.begin(), registers.end()); + this->calls++; + } + void on_read_bits(EntityType register_type, uint16_t start_address, PackedBits bits, ResponseStatus status) override { + this->register_type = register_type; + this->start_address = start_address; + this->bit_count = bits.size(); + this->calls++; + } + EntityType register_type{EntityType::CUSTOM}; + uint16_t start_address{0}; + uint16_t bit_count{0}; + std::vector registers; + int calls{0}; +}; + +} // namespace + +TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 regs at 0x100 + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; // 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x100); + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); +} + +// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read +// start/count. The write half has no client-side ack callback - it is confirmed by a successful response. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) { + RecordingDevice device; + // read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020 + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x0010); // the READ start address, not the write + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side +} + +// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted +// to on_custom_response(), never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) { + RecordingDevice device; + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + +TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) { + GenericDevice device; + const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01}; + const uint8_t response[] = {0x04, 0x02, 0x12, 0x34}; + device.on_response(request, response); + + EXPECT_EQ(device.calls, 1); + EXPECT_EQ(device.register_type, EntityType::INPUT_REGISTER); + EXPECT_EQ(device.start_address, 0x10); + EXPECT_EQ(device.registers, (std::vector{0x1234})); +} + +TEST(ModbusClientDeviceFanOut, ReadDiscreteInputsDelegateToGenericBits) { + GenericDevice device; + const uint8_t request[] = {0x02, 0x00, 0x20, 0x00, 0x05}; // 5 inputs at 0x20 + const uint8_t response[] = {0x02, 0x01, 0x15}; + device.on_response(request, response); + + EXPECT_EQ(device.calls, 1); + EXPECT_EQ(device.register_type, EntityType::DISCRETE_INPUT); + EXPECT_EQ(device.start_address, 0x20); + EXPECT_EQ(device.bit_count, 5); +} + +// A CRC-valid response whose length does not match its request cannot be decoded per the +// function-code contract: it goes to the catch-all with the raw PDUs, not to the typed callback. +TEST(ModbusClientDeviceFanOut, ReadRegistersMismatchedLengthGoesToCatchAll) { + RecordingDevice device; + // Request asks for 4 registers but the response only carries 1. + const uint8_t request[] = {0x03, 0x00, 0x00, 0x00, 0x04}; + const uint8_t response[] = {0x03, 0x02, 0xBE, 0xEF}; + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + ASSERT_EQ(device.custom_responses.size(), 1u); + EXPECT_EQ(device.custom_responses.front(), (std::vector(response, response + sizeof(response)))); +} + +// Coil responses are validated the same way: byte count must be ceil(count / 8). +TEST(ModbusClientDeviceFanOut, ReadCoilsMismatchedLengthGoesToCatchAll) { + RecordingDevice device; + const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils -> 3 packed bytes + const uint8_t response[] = {0x01, 0x02, 0xCD, 0x6B}; // only 2 + device.on_response(request, response); + + EXPECT_TRUE(device.coil_calls.empty()); + EXPECT_EQ(device.custom_responses.size(), 1u); +} + +TEST(ModbusClientDeviceFanOut, ReadCoilsSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils at 0x13 + const uint8_t response[] = {0x01, 0x03, 0xCD, 0x6B, 0x05}; + device.on_response(request, response); + + ASSERT_EQ(device.coil_calls.size(), 1u); + const auto &call = device.coil_calls.front(); + EXPECT_EQ(call.start_address, 0x13); + EXPECT_EQ(call.count, 19); + EXPECT_EQ(call.packed, (std::vector{0xCD, 0x6B, 0x05})); + EXPECT_FALSE(call.status.has_value()); + // first coil = bit 0 of byte 0 + EXPECT_TRUE(helpers::bit_from_packed(0, call.packed)); + EXPECT_FALSE(helpers::bit_from_packed(1, call.packed)); +} + +TEST(ModbusClientDeviceFanOut, WriteSingleRegisterSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03}; + device.on_response(request, request); // echo + + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + const auto &call = device.write_single_register_calls.front(); + EXPECT_EQ(call.address, 1); + EXPECT_EQ(call.value, 3); + EXPECT_FALSE(call.status.has_value()); +} + +TEST(ModbusClientDeviceFanOut, WriteSingleCoilSuccess) { + RecordingDevice device; + const uint8_t request[] = {0x05, 0x00, 0xAC, 0xFF, 0x00}; + device.on_response(request, request); + + ASSERT_EQ(device.write_single_coil_calls.size(), 1u); + EXPECT_EQ(device.write_single_coil_calls.front().address, 0xAC); + EXPECT_EQ(device.write_single_coil_calls.front().value, 1u); +} + +TEST(ModbusClientDeviceFanOut, WriteErrorReportsRequestArgumentsAndStatus) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03}; + const uint8_t exception[] = {0x86, 0x02}; // ILLEGAL_DATA_ADDRESS + device.on_error(request, static_cast(exception[1])); + + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + const auto &call = device.write_single_register_calls.front(); + EXPECT_EQ(call.address, 1); + EXPECT_EQ(call.value, 3); + EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +TEST(ModbusClientDeviceFanOut, ReadErrorReportsEmptyDataAndStatus) { + RecordingDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t exception[] = {0x83, 0x02}; + device.on_error(request, static_cast(exception[1])); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x100); + EXPECT_TRUE(call.registers.empty()); + EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +TEST(ModbusClientDeviceFanOut, CustomFunctionCodeGoesToCatchAll) { + RecordingDevice device; + const uint8_t request[] = {0x47, 0x01, 0x02, 0x03, 0x04}; + const uint8_t response[] = {0x47, 0xAA, 0xBB}; + device.on_response(request, response); + + ASSERT_EQ(device.custom_requests.size(), 1u); + EXPECT_EQ(device.custom_requests.front(), (std::vector{0x47, 0x01, 0x02, 0x03, 0x04})); + EXPECT_EQ(device.custom_responses.front(), (std::vector{0x47, 0xAA, 0xBB})); + EXPECT_FALSE(device.custom_statuses.front().has_value()); + EXPECT_TRUE(device.holding_calls.empty()); + + // On failure the catch-all receives an empty response and the status (the exception code). + const uint8_t exception[] = {0xC7, 0x02}; + device.on_error(request, static_cast(exception[1])); + ASSERT_EQ(device.custom_statuses.size(), 2u); + EXPECT_EQ(device.custom_statuses.back(), ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_TRUE(device.custom_responses.back().empty()); +} + +// A write ack only echoes the start address and count, so the data that was written is decoded from the +// request PDU: [0] function code, [1..2] start address, [3..4] count, [5] byte count, [6..] data. +TEST(ModbusClientDeviceFanOut, WriteMultipleAcksReportStartAndData) { + RecordingDevice device; + // Write 2 registers (0x0001, 0x0002) at 0x0020: byte count 4, data from offset 6. + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + // Write 10 coils at 0x0030: byte count 2, packed bits 0xFF 0x03 from offset 6. + const uint8_t coil_request[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03}; + const uint8_t coil_ack[] = {0x0F, 0x00, 0x30, 0x00, 0x0A}; + device.on_response(coil_request, coil_ack); + + ASSERT_EQ(device.write_multiple_registers_calls.size(), 1u); + EXPECT_EQ(device.write_multiple_registers_calls.front().start_address, 0x20); + EXPECT_EQ(device.write_multiple_registers_calls.front().registers, (std::vector{0x0001, 0x0002})); + ASSERT_EQ(device.write_multiple_coils_calls.size(), 1u); + EXPECT_EQ(device.write_multiple_coils_calls.front().start_address, 0x30); + EXPECT_EQ(device.write_multiple_coils_calls.front().count, 10); + EXPECT_EQ(device.write_multiple_coils_calls.front().packed, (std::vector{0xFF, 0x03})); +} + +// A truncated request (byte-count header promises more data than the PDU carries) is not a standard +// write-multiple, so it is diverted to on_custom_response() - never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, WriteMultipleTruncatedRequestDispatchesAsCustom) { + RecordingDevice device; + // Header claims 2 registers / 4 data bytes, but only one register's worth is present. + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); + ASSERT_EQ(device.custom_requests.size(), 1u); + EXPECT_EQ(device.custom_requests.front(), (std::vector{0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01})); +} + +// A request whose byte-count header disagrees with its own quantity field (here: 2 registers but a +// byte count of 2 instead of 4, with matching data) is non-standard and diverted to the catch-all. +TEST(ModbusClientDeviceFanOut, WriteMultipleInconsistentByteCountDispatchesAsCustom) { + RecordingDevice device; + const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01}; + const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02}; + device.on_response(reg_request, reg_ack); + + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + +// An exception on a read still dispatches to the typed callback (empty data, status set): the gate must +// not require a standard response on the failure path, because on_error() delivers an empty response by +// design. +TEST(ModbusClientDeviceFanOut, ReadErrorWithEmptyResponseStillDispatchesTyped) { + RecordingDevice device; + const uint8_t read_request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + device.on_error(read_request, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + ASSERT_EQ(device.holding_calls.size(), 1u); + EXPECT_TRUE(device.holding_calls.front().registers.empty()); + EXPECT_EQ(device.holding_calls.front().status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_TRUE(device.custom_requests.empty()); +} + +// An error on a coil read must deliver a PackedBits view whose size() is zero - the count must never +// promise bits that have no bytes behind them (operator[] is unchecked). +TEST(ModbusClientDeviceFanOut, ReadCoilsErrorDeliversZeroCountBits) { + RecordingDevice device; + const uint8_t read_request[] = {0x01, 0x01, 0x00, 0x00, 0x0A}; + device.on_error(read_request, ExceptionCode::SERVICE_DEVICE_FAILURE); + + ASSERT_EQ(device.coil_calls.size(), 1u); + EXPECT_EQ(device.coil_calls.front().count, 0); + EXPECT_TRUE(device.coil_calls.front().packed.empty()); +} + +// Single-write acks: on success the delivered value is the device's echo (real read-back); +// on an exception it falls back to the request copy. +TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) { + RecordingDevice device; + const uint8_t request[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; + const uint8_t echo_clamped[] = {0x06, 0x00, 0x10, 0x00, 0x28}; // device clamped 42 -> 40 + device.on_response(request, echo_clamped); + ASSERT_EQ(device.write_single_register_calls.size(), 1u); + EXPECT_EQ(device.write_single_register_calls.front().value, 0x0028); // the echo, not the request + + device.on_error(request, ExceptionCode::ILLEGAL_DATA_VALUE); + ASSERT_EQ(device.write_single_register_calls.size(), 2u); + EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy +} + +// Deprecated on_modbus_data() compatibility shim (pre-2026.8 API). Records the vectors delivered to +// the old callback so we can pin its payload framing against the pre-2026.7 behavior. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +namespace { +class LegacyDevice : public ModbusDevice { + public: + void on_modbus_data(const std::vector &data) override { this->data_calls.push_back(data); } + void on_modbus_error(uint8_t function_code, uint8_t exception_code) override { + this->error_calls.emplace_back(function_code, exception_code); + } + std::vector> data_calls; + std::vector> error_calls; +}; +} // namespace + +// Custom (user-defined) function codes historically delivered the payload INCLUDING the function code +// byte (frame data_offset 1). External components such as the Century VS pump match that first byte +// against the code they sent, so dropping it (issue #17994) makes every response get ignored. +TEST(ModbusLegacyShim, CustomFunctionCodeKeepsFunctionCodeByte) { + LegacyDevice device; + const uint8_t request[] = {0x45, 0x01, 0x02}; // custom function 0x45 + const uint8_t response[] = {0x45, 0xAA, 0xBB, 0xCC}; // echo of the custom code + data + device.on_response(request, response); + + ASSERT_EQ(device.data_calls.size(), 1u); + EXPECT_EQ(device.data_calls.front(), (std::vector{0x45, 0xAA, 0xBB, 0xCC})); +} + +// Standard reads still strip the function code and byte-count header, matching the pre-2026.7 shim. +TEST(ModbusLegacyShim, StandardReadStripsHeader) { + LegacyDevice device; + const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + device.on_response(request, response); + + ASSERT_EQ(device.data_calls.size(), 1u); + EXPECT_EQ(device.data_calls.front(), (std::vector{0x00, 0x2A, 0x01, 0x00})); +} +#pragma GCC diagnostic pop + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index d04c4fe10c..43c81bbf34 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -1,47 +1,84 @@ #include #include +#include #include +#include +#include "common.h" #include "esphome/components/modbus/modbus.h" +#include "esphome/core/hal.h" namespace esphome::modbus::testing { namespace { -// Exposes the protected tx queue and waiting-for-response slot so tests can drive the -// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the -// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +// Exposes the frame state machine so tests can drive it without a UART (force_send_next(), +// timeout_waiting(), sweep_for_test() stand in for the loop() transmit/watchdog/sweep steps). class NoResponseProbeHub : public ModbusClientHub { public: - size_t queued_frames() const { return this->tx_buffer_.size(); } - const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } - bool waiting() const { return this->waiting_for_response_.has_value(); } - const ModbusDeviceCommand &waiting_command() const { - EXPECT_TRUE(this->waiting_for_response_.has_value()); - return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + // The old "queue" view: entries awaiting transmission, in STORAGE order (selection order is + // what the engine transmits by; use next_ready() for that). + size_t queued_frames() const { + size_t count = 0; + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY) + count++; + } + return count; + } + // A never-null placeholder to return when a lookup fails, so a tripped EXPECT/ADD_FAILURE reports + // the assertion instead of dereferencing null / an empty deque and segfaulting the whole suite. + static const ModbusDeviceCommand &dummy_command() { + static const uint8_t DUMMY_PDU[1] = {0x00}; + static ModbusDeviceCommand cmd(nullptr, 0, std::span(DUMMY_PDU, 1)); + return cmd; + } + const ModbusDeviceCommand &queued(size_t i) const { + for (const auto &cmd : this->tx_buffer_) { + if (cmd.state == FrameState::READY && i-- == 0) + return cmd; + } + ADD_FAILURE() << "no READY entry at that index"; + return dummy_command(); + } + size_t entries() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand *next_ready() { return this->select_next_ready_(); } + bool waiting() const { return this->waiting_for_response_; } + const ModbusDeviceCommand &waiting_command() { + ModbusDeviceCommand *cmd = this->find_waiting_(); + EXPECT_NE(cmd, nullptr); + return cmd != nullptr ? *cmd : dummy_command(); } - void force_send_front() { - this->waiting_for_response_ = std::move(this->tx_buffer_.front()); - this->tx_buffer_.pop_front(); + void sweep_for_test() { this->sweep_(); } + void send_next_for_test() { + this->send_next_frame_(); + this->sweep_(); // a transmit failure's on_not_sent() is delivered by the loop's sweep } - // Drives the real unexpected-frame branch in process_modbus_server_frame(). - void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { - this->process_modbus_server_frame(address, function_code, data, len); + void force_send_next() { + ModbusDeviceCommand *cmd = this->select_next_ready_(); + ASSERT_NE(cmd, nullptr) << "no READY entry to send"; + cmd->state = FrameState::WAITING; + this->waiting_for_response_ = true; + } + // Drives the real response/interruption branches, followed by the loop's sweep. + void receive_frame_for_test(uint8_t address, std::span pdu) { + this->process_modbus_server_frame(address, pdu); + this->sweep_(); } void timeout_waiting() { - if (this->waiting_for_response_.has_value()) - this->notify_no_response_(*this->waiting_for_response_); - this->waiting_for_response_.reset(); + this->sweep_(); // deliver anything already owed (e.g. an interruption's on_no_response) + this->expire_waiting_(); + this->sweep_(); } }; -// A device with a scripted answer to on_modbus_no_response(). +// A device with a scripted answer to on_no_response(). class RetryingDevice : public ModbusClientDevice { public: RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} - bool on_modbus_no_response() override { + bool on_no_response(std::span request_pdu) override { this->no_response_count_++; return this->retry_; } @@ -55,7 +92,7 @@ class RetryingDevice : public ModbusClientDevice { class ClearingRetryDevice : public ModbusClientDevice { public: ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} - bool on_modbus_no_response() override { + bool on_no_response(std::span request_pdu) override { this->no_response_count_++; this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback return true; // and still requests a retry @@ -79,9 +116,9 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); - hub.force_send_front(); + hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); ASSERT_TRUE(hub.waiting()); @@ -90,12 +127,13 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { EXPECT_EQ(device.no_response_count_, 1); EXPECT_FALSE(hub.waiting()); ASSERT_EQ(hub.queued_frames(), 1u); - const ModbusDeviceCommand &requeued = hub.front(); + const ModbusDeviceCommand &requeued = hub.queued(0); EXPECT_EQ(requeued.device, &device); // address + PDU + CRC ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); - EXPECT_EQ(requeued.frame.data.data()[0], 0x02); - EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); + EXPECT_EQ(requeued.frame.address(), 0x02); + ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU)); + EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU))); } // A device that declines the retry has the frame dropped. @@ -103,8 +141,8 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); - hub.force_send_front(); + device.queue_pdu(read_pdu()); + hub.force_send_next(); hub.timeout_waiting(); @@ -119,8 +157,8 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { NoResponseProbeHub hub; { RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_front(); + device.queue_pdu(read_pdu()); + hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } ASSERT_TRUE(hub.waiting()); @@ -132,32 +170,57 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { EXPECT_EQ(hub.queued_frames(), 0u); } -// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the -// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the -// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +// An unexpected frame interrupts the transaction: the entry becomes an INTERRUPTED shell that +// ignores this transaction and blocks tx until the send-wait timeout, where it gets its single +// on_no_response() - a granted retry is requeued there, like any other timeout. TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_front(); + device.queue_pdu(read_pdu()); + hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. - const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; - hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); + hub.sweep_for_test(); - EXPECT_EQ(device.no_response_count_, 1); - ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... - EXPECT_EQ(hub.front().device, &device); - ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot - EXPECT_TRUE(hub.waiting_command().interrupted); - EXPECT_EQ(hub.waiting_command().device, nullptr); + EXPECT_EQ(device.no_response_count_, 0); // not notified early: it waits out the timeout + ASSERT_TRUE(hub.waiting()); // and keeps blocking the bus + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + EXPECT_EQ(hub.waiting_command().pending, 1u); - // The send-wait timeout clears the shell without a second callback or another requeue. + // The send-wait timeout delivers on_no_response and requeues the granted retry. hub.timeout_waiting(); EXPECT_FALSE(hub.waiting()); EXPECT_EQ(device.no_response_count_, 1); - EXPECT_EQ(hub.queued_frames(), 1u); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).device, &device); +} + +// The declined-retry interrupted shell blocks until the send-wait timeout, then gets its single +// on_no_response() there and retires with nothing left to send. +TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction + hub.sweep_for_test(); + + EXPECT_EQ(device.no_response_count_, 0); // not notified early + ASSERT_TRUE(hub.waiting()); // the shell still blocks the wire + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + EXPECT_EQ(hub.waiting_command().pending, 1u); + + hub.timeout_waiting(); // on_no_response (declined), then the shell retires + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(device.no_response_count_, 1); } // A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: @@ -166,8 +229,8 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { NoResponseProbeHub hub; ClearingRetryDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - hub.force_send_front(); + device.queue_pdu(read_pdu()); + hub.force_send_next(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -175,4 +238,1914 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { EXPECT_FALSE(hub.waiting()); } +// Writes jump ahead of queued reads; reads keep FIFO order among themselves. +TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(write_pdu); + + ASSERT_EQ(hub.queued_frames(), 3u); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write transmits first + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x01); // reads follow in FIFO order + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); +} + +// Re-requesting a queued frame is absorbed into the existing entry instead of queueing a duplicate. +TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests +} + +// Re-requesting the frame currently waiting is absorbed into the waiting entry; after a +// no-response timeout the absorbed request still gets its run even though the device declines a +// retry, and a second timeout does not run it again. +TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + device.queue_pdu(read_pdu()); // duplicate of the waiting frame + + EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice + EXPECT_EQ(hub.waiting_command().pending, 2u); + + hub.timeout_waiting(); + ASSERT_EQ(hub.queued_frames(), 1u); // the timeout resolved one request; the absorbed one runs + EXPECT_EQ(hub.queued(0).pending, 1u); + + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(hub.queued_frames(), 0u); // the last request resolved; nothing left to run +} + +// An entry with an absorbed extra request that times out while the device asks to retry: the +// retry is not a resolution, so BOTH requests remain pending rather than one being dropped - +// which would leave that caller without a resolution. +TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + ASSERT_EQ(hub.waiting_command().pending, 2u); + + hub.timeout_waiting(); // no response; the device requests a retry + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); // preserved: the retry resolved nothing +} + +// A continuous read re-queues itself (at the lowest priority) after each successful response, +// but not after an exception response. +TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + hub.force_send_next(); + + // A matching successful response cycles the continuous entry back to READY. + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + + // An exception response ends the poll. + hub.force_send_next(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A continuous read that gets no response and is retried stays continuous: an explicit retry of a +// continuous poll is assumed to still want continuous polling (the entry stays continuous). +TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + hub.force_send_next(); + + hub.timeout_waiting(); // no response -> device requests retry + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous +} + +// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous +// duplicate upgrading a one-shot): the entry runs one more cycle to serve the request, then stops. +TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + + device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).continuous); + EXPECT_EQ(hub.queued(0).pending, 1u); + + // It runs one more cycle to serve the request, then stops - not re-queued as a poll. + hub.force_send_next(); + EXPECT_FALSE(hub.waiting_command().continuous); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Re-sends its frame once as a one-shot from inside on_error(), to exercise the downgrade branch +// when the poll it duplicates has already reached a terminal (pending drained to 0). +class ResendOnErrorDevice : public ModbusClientDevice { + public: + ResendOnErrorDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_error(std::span request_pdu, ExceptionCode exception_code) override { + this->error_count_++; + if (this->resend_) { + this->resend_ = false; + this->read_holding_registers(0x100, 2); // one-shot re-send from inside the failure callback + } + } + int error_count_{0}; + bool resend_{true}; +}; +} // namespace + +// A one-shot re-send issued from inside a continuous poll's failure callback must still run. The +// poll's exception terminal has already drained pending to 0, so the re-send absorbs into that entry +// via the downgrade branch - which must restore the debt, or the sweep erases the entry with the +// request never sent and no callback delivered. +TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) { + NoResponseProbeHub hub; + ResendOnErrorDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_TRUE(hub.queued(0).continuous); + + hub.force_send_next(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends + + EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far + ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased + EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot + EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs + + // And it runs to its own terminal - a good response this time - then the entry is gone. + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); +} + +// Requesting continuous polling for a frame that is already queued as a one-shot turns that entry +// into the continuous poll instead of leaving a promotion that never polls. +TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.read_holding_registers(0x100, 2); + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_FALSE(hub.queued(0).continuous); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); + + // And it behaves as a poll from here: success cycles it back to READY. + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_TRUE(hub.queued(0).continuous); +} + +// The transmit order is one key with three levels: writes, then one-shot reads, then continuous +// polls - a poll only gets the bus when nothing else wants it. +TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + // Queued oldest-first in the opposite order to the one they must transmit in, so age cannot be + // what produces the expected sequence. + device.read_holding_registers(0x100, 2, {.continuous = true}); + const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.queue_pdu(one_shot); + device.queue_pdu(write_pdu); + ASSERT_EQ(hub.queued_frames(), 3u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); + EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); + EXPECT_EQ(hub.queued(2).priority(), CommandPriority::WRITE); + + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write goes first + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left +} + +// continuous is ignored for writes: the frame still sends at WRITE priority, once. +TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.queue_pdu(write_pdu, {.continuous = true}); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + EXPECT_FALSE(hub.queued(0).continuous); +} + +// A queued continuous poll does not count against immediate-send readiness: it ranks below every +// one-shot, so a new one-shot goes out ahead of it. A queued one-shot does count. +TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued + device.read_holding_registers(0x100, 2, {.continuous = true}); + ASSERT_TRUE(hub.queued(0).continuous); + EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now + + device.read_holding_registers(0x200, 2); // a one-shot does count + EXPECT_FALSE(hub.tx_buffer_empty()); +} + +// A device whose sent/not-sent callbacks are counted. +namespace { +class SentCountingDevice : public ModbusClientDevice { + public: + SentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { + this->sent_count_++; + this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + int sent_count_{0}; + int not_sent_count_{0}; + std::vector last_sent_pdu_; + std::vector last_not_sent_pdu_; +}; +} // namespace + +// A write is never requeueable, so its entry can serve exactly one request: a duplicate of a +// queued write is refused at the door rather than earning the write an extra transmission. +TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + EXPECT_EQ(hub.queued(0).pending, 1u); // a write's cap + EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered +} + +// Requeueability is an allow-list of the standard reads: a custom function code's idempotency is +// unknown, so its duplicate is refused like a write's instead of earning a silent re-send. +TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code + EXPECT_TRUE(device.queue_pdu(custom_pdu)); + EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); // the non-requeueable cap of one run + EXPECT_EQ(device.not_sent_count_, 0); +} + +// An anonymous duplicate (no device - the YAML-lambda path) is always dropped, never promoted: +// with no callback there is no lifecycle to absorb into and no owner to route a re-run to. +TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { + NoResponseProbeHub hub; + + const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + hub.queue_pdu(0x02, read); + hub.queue_pdu(0x02, read); // anonymous duplicate: dropped + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner +} + +// A retried entry is re-stamped to the queue tail: reads that arrived while it was waiting get +// their turn before the retry, so a frame that keeps timing out cannot starve the rest of the bus. +TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + device.queue_pdu(fresh_a); + device.queue_pdu(fresh_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads + + ASSERT_EQ(hub.queued_frames(), 3u); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[2], 0x10); // fresh reads keep FIFO order ahead of the retry + hub.force_send_next(); + hub.timeout_waiting(); + hub.force_send_next(); + EXPECT_EQ(hub.waiting_command().frame.pdu()[2], 0x20); + hub.timeout_waiting(); + hub.force_send_next(); // the retry gets its turn last, both requests still on the entry + EXPECT_TRUE(std::equal(hub.waiting_command().frame.pdu().begin(), hub.waiting_command().frame.pdu().end(), READ_PDU)); + EXPECT_EQ(hub.waiting_command().pending, 2u); +} + +// An absorbed duplicate does not move the entry back in line: seq belongs to the entry, and only +// re-entering the line (retry, resolved request, continuous cycle) re-stamps it. +TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + ASSERT_EQ(hub.queued_frames(), 2u); + + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[2], 0x10); // read_a still transmits first + EXPECT_EQ(next->pending, 2u); +} + +// A write that is retried after a no-response keeps the WRITE class, so it stays ahead of reads, +// and a later duplicate still resolves against it instead of queueing twice. +TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueable) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + device.queue_pdu(write_pdu); + hub.force_send_next(); + hub.timeout_waiting(); // no response -> device requests retry -> back to READY + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class + + device.queue_pdu(write_pdu); // duplicate of the retried write + ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); + hub.sweep_for_test(); + EXPECT_EQ(hub.queued(0).pending, 1u); // ...the duplicate was refused at the door (write cap is 1) +} + +namespace { +// A hub that is never free to transmit. +class AlwaysBlockedHub : public NoResponseProbeHub { + public: + bool tx_blocked() override { return true; } +}; +} // namespace + +// Transmitting cannot fail, so a hub that is busy simply does not transmit: the frame keeps its +// place in the queue and goes out on a later loop, with no callback and no lifecycle change. (The +// caller owns the tx_blocked() check; send_frame_() has no gate of its own to refuse at.) +TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { + AlwaysBlockedHub hub; + SentCountingDevice device(&hub, 0x02); + + EXPECT_TRUE(device.queue_pdu(read_pdu())); + hub.send_next_for_test(); + + EXPECT_EQ(device.sent_count_, 0); + EXPECT_EQ(device.not_sent_count_, 0); // nothing failed - it has not been attempted + ASSERT_EQ(hub.queued_frames(), 1u); // still queued, still owed exactly one terminal + EXPECT_EQ(hub.queued(0).pending, 1u); + EXPECT_FALSE(hub.waiting()); +} + +// on_sent() fires when the frame goes onto the wire, not when it is queued. +TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // frame timing derives from the baud rate + SentCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + // The callback identifies which command transmitted: it carries the request PDU. + EXPECT_EQ(device.last_sent_pdu_, (std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU)))); + EXPECT_TRUE(hub.waiting()); +} + +namespace { +// Records on_sent / on_response / on_no_response so a broadcast's fire-and-forget completion +// (on_sent, and no terminal) can be asserted. +class BroadcastProbeDevice : public ModbusClientDevice { + public: + BroadcastProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_count_++; + this->last_response_size_ = response_pdu.size(); + } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + return false; + } + int sent_count_{0}; + int response_count_{0}; + int no_response_count_{0}; + size_t last_response_size_{0}; +}; +} // namespace + +// A broadcast (address 0) is never answered (Modbus 4.1), so the client treats it as fire-and-forget: +// on_sent fires as the frame goes out, NO terminal (on_response/on_error/on_no_response) is delivered, +// the hub is left NOT waiting - no timeout is burned - and the sweep erases the entry. +TEST(ModbusClientHubBroadcast, CompletesAtTransmissionWithoutWaiting) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); // no waiting slot occupied + EXPECT_EQ(hub.queued_frames(), 0u); // and the entry is gone + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Keeps the DEFAULT on_response() (so the base typed dispatcher runs) and records the typed write +// callback and the catch-all, to prove a broadcast reaches neither - only on_sent. +class BroadcastTypedProbeDevice : public ModbusClientDevice { + public: + BroadcastTypedProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_count_++; + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_count_++; + } + int sent_count_{0}; + int write_single_count_{0}; + int custom_count_{0}; +}; +} // namespace + +// Completing a broadcast with an empty response({}) used to fall, for a device on the default +// on_response(), through the typed dispatcher to on_custom_response() - firing the wrong callback and +// logging a spurious "non-standard" warning. Fire-and-forget delivers no terminal at all, so a broadcast +// write reaches neither the typed write callback nor the catch-all: only on_sent. +TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastTypedProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // on_sent still reports the transmission + EXPECT_EQ(device.write_single_count_, 0); // no terminal: the typed write callback never fires + EXPECT_EQ(device.custom_count_, 0); // and it is NOT diverted to the catch-all (no false warning) + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be +// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently +// retiring it. Writes, 0x17, and custom codes still go through (covered above). +TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + EXPECT_FALSE(device.queue_pdu(read)); // refused: a broadcast read is never answered + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + +// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the +// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. +TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; // FC 0x41: first user-defined function code space + ASSERT_TRUE(device.queue_pdu(custom)); // accepted: a custom code is not a read + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); // the entry is gone +} + +// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks +// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling +// of an exception-flagged write. +TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t exception_custom[] = {0xC1, 0x01, 0x02}; // 0x41 | 0x80: custom code with the exception bit + EXPECT_FALSE(device.queue_pdu(exception_custom)); // refused: exception-flagged, never a real broadcast + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + +namespace { +// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. +class RejectPostDelayHub : public NoResponseProbeHub { + public: + bool tx_blocked() override { return this->tx_blocked_calls_++ > 0; } + int tx_blocked_calls_{0}; +}; +} // namespace + +// A byte arriving during send_frame_'s pre-send delay blocks transmission after the caller's gate +// already passed. send_frame_ rejects, and send_next_frame_ leaves the frame READY to retry - it is +// not marked WAITING and the bus is not claimed. +TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { + NullUART uart; + RejectPostDelayHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + SentCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check + + EXPECT_EQ(device.sent_count_, 0); // nothing transmitted + EXPECT_FALSE(hub.waiting()); // the frame was left untouched, bus not claimed + ASSERT_EQ(hub.entries(), 1u); + EXPECT_EQ(hub.queued(0).state, FrameState::READY); // still selectable next loop +} + +// Counts response deliveries so requeue semantics can be pinned end to end. +namespace { +class DataCountingDevice : public ModbusClientDevice { + public: + DataCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->data_count_++; + } + void on_error(std::span request_pdu, ExceptionCode exception_code) override { this->error_count_++; } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + this->last_no_response_pdu_.assign(request_pdu.begin(), request_pdu.end()); + if (this->retries_ == 0) + return false; + this->retries_--; + return true; + } + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); + } + void on_sent(std::span request_pdu) override { this->sent_count_++; } + int terminals() const { + return this->data_count_ + this->error_count_ + this->no_response_count_ + this->not_sent_count_; + } + int data_count_{0}; + int error_count_{0}; + int no_response_count_{0}; + int not_sent_count_{0}; + int sent_count_{0}; + int retries_{0}; + std::vector last_not_sent_pdu_; + std::vector last_no_response_pdu_; +}; + +// Runs full send/respond cycles until the queue drains; returns the number of cycles executed. +int drain_with_responses(NoResponseProbeHub &hub, std::span response_pdu, int max_cycles = 10) { + int cycles = 0; + while (hub.queued_frames() != 0 && cycles < max_cycles) { + hub.force_send_next(); + hub.receive_frame_for_test(0x02, response_pdu); + cycles++; + } + return cycles; +} +} // namespace + +constexpr uint8_t OK_RESPONSE[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + +// One request produces exactly one data callback. +TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_FALSE(hub.waiting()); +} + +// Requesting the same read twice while queued yields exactly two callbacks: +// the promoted entry completes, re-queues once (demoted), completes again, and stops. +TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + int cycles = drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(cycles, 2); + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.not_sent_count_, 0); // both requests were served + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// Clears the address queue from inside its first response callback, so a duplicate still owed on the +// same entry has to be resolved (or, as things stand, is dropped) by that clear. +class ClearOnFirstResponseDevice : public DataCountingDevice { + public: + ClearOnFirstResponseDevice(ModbusClientHub *hub, uint8_t address) : DataCountingDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->data_count_++; + if (this->data_count_ == 1) + this->clear_tx_queue_for_address(); // clear mid-completion, from inside the first response + } +}; +} // namespace + +// A duplicate read absorbs into one entry (pending 2). The first response resolves one request, and +// its callback clears the address queue mid-completion. The still-owed duplicate is a second accepted +// request, so it must get its own terminal - on_not_sent() - not be dropped silently. +TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent) { + NoResponseProbeHub hub; + ClearOnFirstResponseDevice device(&hub, 0x02); + + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2 + hub.force_send_next(); + hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep + + EXPECT_EQ(device.data_count_, 1); // exactly one response delivered + EXPECT_EQ(device.not_sent_count_, 1); // the duplicate resolved with a terminal, not dropped + EXPECT_EQ(device.terminals(), 2); // one terminal per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A read entry serves two requests (this run plus one re-run), so the third identical request is +// refused at the door: two data callbacks, and no terminal for the request that was never taken. +TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed + int cycles = drain_with_responses(hub, OK_RESPONSE); + + EXPECT_EQ(cycles, 2); + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.terminals(), 2); // exactly one per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A duplicate write is refused at the door and the original write sends once - the caller learns +// immediately, and no lifecycle is created for the request that was never taken. +TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); + hub.sweep_for_test(); + + EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); +} + +// An exception response is a terminal on its own: exactly one on_error(), no others, +// preceded by exactly one on_sent(). +TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.send_next_for_test(); + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); + + EXPECT_EQ(device.error_count_, 1); + EXPECT_EQ(device.terminals(), 1); + EXPECT_EQ(device.sent_count_, 1); +} + +// A timeout is a terminal on its own: exactly one on_no_response(), preceded by one +// on_sent(); a refused duplicate ends in on_not_sent() with NO on_sent(). +TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.send_next_for_test(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 1); + EXPECT_EQ(device.sent_count_, 1); + + // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no + // terminal, nothing sent. + const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(device.terminals(), 1); // still just the read's timeout + EXPECT_EQ(device.sent_count_, 1); + + // Drain the accepted write: its echo response is the data terminal, and the books balance. + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, write_pdu); + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.terminals(), 2); // 2 accepted lifecycles, 2 terminals + EXPECT_EQ(device.sent_count_, 2); // 2 transmissions; the refused duplicate never sent +} + +// A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal. +TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + device.retries_ = 1; // ask for exactly one retry + + device.queue_pdu(read_pdu()); + hub.send_next_for_test(); + hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) + ASSERT_EQ(hub.queued_frames(), 1u); + hub.send_next_for_test(); + hub.timeout_waiting(); // lifecycle 2: sent + no_response (retry declined -> done) + + EXPECT_EQ(device.no_response_count_, 2); + EXPECT_EQ(device.terminals(), 2); + EXPECT_EQ(device.sent_count_, 2); + EXPECT_EQ(hub.queued_frames(), 0u); + // The retried lifecycle's timeout carries the SAME request PDU as the first attempt. + EXPECT_EQ(device.last_no_response_pdu_, std::vector(READ_PDU, READ_PDU + sizeof(READ_PDU))); +} + +// A retry is a state flip on an existing entry, never a new insertion, so a full queue can't refuse +// it: fill the queue, time out the waiting frame with a retry, and it survives as READY. +TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + device.retries_ = 1; + SentCountingDevice filler(&hub, 0x05); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); // waiting + device.queue_pdu(read_pdu()); // absorbed: two requests pending + // Fill the remaining live capacity with distinct frames. + for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.queue_pdu(fill); + } + + hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); // nothing was refused + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->device, &filler); // round-robin: the retry re-stamped behind the fillers + // The retried entry survives as READY with both absorbed requests intact. + bool found = false; + for (size_t i = 0; i < hub.queued_frames(); i++) { + const ModbusDeviceCommand &cmd = hub.queued(i); + if (cmd.device == &device) { + EXPECT_EQ(cmd.pending, 2u); + found = true; + } + } + EXPECT_TRUE(found); +} + +// The deprecated device-side send_raw() reports an unusable payload the same way every other +// refused send does: false at the call site, with no queue entry and no callback. +namespace { +class NotSentCountingRawDevice : public ModbusClientDevice { + public: + NotSentCountingRawDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; } + int not_sent_count_{0}; +}; +} // namespace + +TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { + NoResponseProbeHub hub; + NotSentCountingRawDevice device(&hub, 0x02); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it +#pragma GCC diagnostic pop + EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered + EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued +} + +// A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. +TEST(ModbusClientHubCallbackCount, ContinuousLifecyclesBalance) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + const uint8_t exception_response[] = {0x83, 0x02}; + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, ok_response); // lifecycle 1 -> requeued + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, ok_response); // lifecycle 2 -> requeued + hub.send_next_for_test(); + hub.receive_frame_for_test(0x02, exception_response); // lifecycle 3 -> stops + + EXPECT_EQ(device.data_count_, 2); + EXPECT_EQ(device.error_count_, 1); + EXPECT_EQ(device.terminals(), 3); + EXPECT_EQ(device.sent_count_, 3); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// A device that stops itself (clears its own queue) from inside on_response(). +class ClearOnDataDevice : public ModbusClientDevice { + public: + ClearOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->clear_tx_queue_for_device(); + } +}; + +// A device that chains a follow-up send from inside on_sent(). +class ChainOnSentDevice : public ModbusClientDevice { + public: + ChainOnSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { + if (!this->chained_) { + this->chained_ = true; + const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 + this->queue_pdu(follow); + } + } + bool chained_{false}; +}; +} // namespace + +// clear_tx_queue_for_address() resolves every dropped frame via its owner's on_not_sent(), so a device +// sharing the address with the clearer (e.g. a modbus_client action alongside an offline controller) +// observes the drop; frames for other addresses are untouched. +TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { + NoResponseProbeHub hub; + SentCountingDevice controller_like(&hub, 0x02); + SentCountingDevice bystander_same(&hub, 0x02); + SentCountingDevice bystander_other(&hub, 0x03); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; + controller_like.queue_pdu(read_a); + bystander_same.queue_pdu(read_b); + bystander_other.queue_pdu(read_c); + ASSERT_EQ(hub.queued_frames(), 3u); + + controller_like.clear_tx_queue_for_address(); + hub.sweep_for_test(); // the loop's sweep delivers the owed terminals and erases the entries + + ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains + EXPECT_EQ(hub.queued(0).frame.address(), 0x03); + EXPECT_EQ(controller_like.not_sent_count_, 1); + EXPECT_EQ(bystander_same.not_sent_count_, 1); + EXPECT_EQ(bystander_other.not_sent_count_, 0); + // each owner saw its own request PDU + EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector(std::begin(read_b), std::end(read_b))); +} + +// A cleared entry resolves with one on_not_sent() per accepted request it stood for, so the +// books balance for owners counting outstanding requests - all within the one sweep. +TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + device.queue_pdu(read); + device.queue_pdu(read); // duplicate: absorbed into the queued entry + ASSERT_EQ(hub.queued_frames(), 1u); + ASSERT_EQ(hub.queued(0).pending, 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 2); // one terminal per accepted request + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); // fully drained and erased +} + +// A duplicate read absorbs into one waiting entry (pending 2 once the first is sent). A clear with +// clear_sent detaches the in-flight frame as a silent shell, but the duplicate - a second accepted +// request that would have re-run - was never transmitted, so it must still get its on_not_sent(). +TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 + ASSERT_EQ(hub.queued(0).pending, 2u); + hub.force_send_next(); // the frame is sent (WAITING); pending still 2 + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 1); // the un-transmitted duplicate is resolved, not dropped +} + +// A clear does not abandon the in-flight frame: it becomes a WAITING_RETIRED shell that keeps the +// bus and still delivers the in-flight request's usual callback (here on_response) when the reply +// arrives. Only un-run duplicates are turned into on_not_sent(); a lone in-flight frame has none. +TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); // sent, now WAITING + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate to resolve + ASSERT_TRUE(hub.waiting()); // still waiting for a response, holding the bus + ASSERT_EQ(hub.entries(), 1u); // entry preserved as a cleared shell + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + // the in-flight request still gets its usual callback when the response finally arrives + hub.receive_frame_for_test(0x02, OK_RESPONSE); + EXPECT_EQ(device.data_count_, 1); + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep. +class ResendOnNotSentDevice : public ModbusClientDevice { + public: + ResendOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->not_sent_count_ == 1) { + const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position + this->queue_pdu(again); + } + } + int not_sent_count_{0}; +}; +} // namespace + +// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep +// nor loops it: the fresh entry starts within its cap, so the sweep never touches it. +TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { + NoResponseProbeHub hub; + ResendOnNotSentDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.queue_pdu(read); + ASSERT_EQ(hub.queued_frames(), 1u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + // The original frame resolved via on_not_sent; the re-send from inside that callback remains queued. + EXPECT_EQ(device.not_sent_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).frame.address(), 0x02); +} + +// The hard case for the sweep: the notified handler re-queues a WRITE to the cleared address. The +// fresh entry must be neither dropped nor re-notified - and the bystander's frame at the other +// address survives untouched, while the write still wins transmit selection. +TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { + NoResponseProbeHub hub; + ResendOnNotSentDevice resender(&hub, 0x02); + SentCountingDevice bystander_other(&hub, 0x03); + + const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + resender.queue_pdu(read_victim); + bystander_other.queue_pdu(read_other); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(resender.not_sent_count_, 1); // notified once, never re-notified for the re-send + ASSERT_EQ(hub.queued_frames(), 2u); // the re-queued write AND the other-address read survive + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.address(), 0x02); // the WRITE class wins selection over the older read + EXPECT_EQ(next->frame.pdu()[0], 0x06); +} + +namespace { +// Re-sends its own frame from EVERY on_not_sent. There is no serve/absorb treadmill: a duplicate at +// the servable cap is refused at the door, and a re-send issued while the entry is retiring queues a +// fresh entry beyond the sweep's captured work_set (served next sweep), never re-absorbing the one draining. +class AlwaysResendDevice : public ModbusClientDevice { + public: + AlwaysResendDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; + this->queue_pdu(again); + } + int not_sent_count_{0}; +}; + +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing +// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired +// entry, and each entry still owes one notification per un-run request until pending reaches zero. +class ClearOtherOnNotSentDevice : public ModbusClientDevice { + public: + ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->parent_->clear_tx_queue_for_address(0x03); + } + int not_sent_count_{0}; +}; +} // namespace + +// pending can never exceed what the entry can serve, so the old serve/absorb treadmill is +// impossible by construction: the surplus request is refused at the door instead of being absorbed +// and resolved later, and a handler that re-sends gets false rather than another lifecycle. +TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { + NoResponseProbeHub hub; + AlwaysResendDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 2u); + + hub.sweep_for_test(); // nothing is owed, so the handler never runs + + EXPECT_EQ(device.not_sent_count_, 0); + EXPECT_EQ(hub.queued(0).pending, 2u); +} + +// A full queue refuses at the door: false at the call site, no entry, no callback - so the +// refusal cannot re-enter the hub at all and needs no recursion bound of its own. +TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { + NoResponseProbeHub hub; + SentCountingDevice filler(&hub, 0x05); + SentCountingDevice device(&hub, 0x02); + + // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). + for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; + filler.queue_pdu(fill); + } + ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed + EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); + EXPECT_EQ(hub.entries(), MODBUS_TX_BUFFER_SIZE); // and no refusal bookkeeping was stored +} + +namespace { +// From inside on_not_sent, clears its OWN address - its remaining queued frames resolve silently +// (the guard suppresses self-deliveries), while other owners on the address are still notified. +class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice { + public: + ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + this->clear_tx_queue_for_address(); + } + int not_sent_count_{0}; +}; +} // namespace + +// An address clear issued from inside on_not_sent() resolves EVERY dropped request with its own +// terminal at the sweep - including the clearer's (the sweep delivers from a quiescent hub, so the +// old stack-nesting silence no longer applies; use clear_tx_queue_for_device() for silent teardown). +TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { + NoResponseProbeHub hub; + ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02); + SentCountingDevice bystander(&hub, 0x02); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; + clearer.queue_pdu(read_a); + clearer.queue_pdu(read_b); + bystander.queue_pdu(read_c); + ASSERT_EQ(hub.queued_frames(), 3u); + + EXPECT_FALSE(clearer.queue_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make + + hub.sweep_for_test(); + + EXPECT_EQ(clearer.not_sent_count_, 2); // one per cleared request of its own + EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's cleared frame is notified too + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(hub.entries(), 0u); +} + +// A clear issued from inside on_not_sent() still delivers its victims' notifications in the same sweep: +// the newly-retired entries set sweep_needed_ and the sweep's restart loop drains them before it ends. +TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { + NoResponseProbeHub hub; + ClearOtherOnNotSentDevice clearer(&hub, 0x02); + SentCountingDevice victim(&hub, 0x03); + + const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + clearer.queue_pdu(read_a); + victim.queue_pdu(read_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn + hub.sweep_for_test(); + + EXPECT_EQ(clearer.not_sent_count_, 1); + EXPECT_EQ(victim.not_sent_count_, 1); // the nested clear's victim resolves in the same sweep + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// From on_not_sent (delivered by the sweep), re-sends a frame identical to ANOTHER doomed queued +// frame; the dedup must not absorb into the doomed entry. +class ResendSecondFrameDevice : public ModbusClientDevice { + public: + ResendSecondFrameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->not_sent_count_ == 1) { + const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; + this->queue_pdu(same_as_r2); + } + } + int not_sent_count_{0}; +}; +} // namespace + +// A send during a sweep that matches a DELETED (doomed) frame must queue fresh, not absorb into +// the doomed entry - absorption would tie the new request to a frame the sweep is draining. +TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { + NoResponseProbeHub hub; + ResendSecondFrameDevice device(&hub, 0x02); + + const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; + const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; + device.queue_pdu(r1); + device.queue_pdu(r2); + ASSERT_EQ(hub.queued_frames(), 2u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); // r1 and r2 both resolve; r1's handler re-sends a frame identical to r2 + // Without the dedup's dead-state skip the re-send would be absorbed into r2 and drained with it; + // with the skip it queues fresh and survives. + + EXPECT_EQ(device.not_sent_count_, 2); // r1 and r2 both resolved + ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survives + EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x22); +} + +namespace { +// The worst-case handler: from every on_not_sent() it both re-sends and clears its own address, so +// each delivery manufactures a fresh entry AND a fresh terminal debt. +class ResendAndClearOnNotSentDevice : public ModbusClientDevice { + public: + ResendAndClearOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; + this->queue_pdu(again); + this->clear_tx_queue_for_address(); + } + int not_sent_count_{0}; +}; +} // namespace + +// Sweep-termination worst case: a handler re-sending AND clearing from every on_not_sent() still +// can't extend the sweep, since it serves only the entries it started with (new debt waits). +TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { + NoResponseProbeHub hub; + ResendAndClearOnNotSentDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; + device.queue_pdu(read); + hub.clear_tx_queue_for_address(0x02); + + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 1); // exactly the one terminal that was owed on entry + EXPECT_EQ(hub.entries(), 1u); // the frame the handler queued (and then cleared itself) + + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 2); // its terminal comes on the next loop, not this sweep + EXPECT_EQ(hub.entries(), 1u); // and the container is still not growing + + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_count_, 3); + EXPECT_EQ(hub.entries(), 1u); +} + +// clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented +// exception to the exactly-one-terminal contract; used during teardown/offline handling). +TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; + device.queue_pdu(read_a); + device.queue_pdu(read_b); + ASSERT_EQ(hub.queued_frames(), 2u); + + device.clear_tx_queue_for_device(); + + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback +} + +// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// immediately or corrupting the waiting transaction. +TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + ChainOnSentDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up + + EXPECT_TRUE(hub.waiting()); // first frame is waiting + ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent + EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009) +} + +// "Stop polling now" from inside on_response() works: the completing command is exposed to the +// clear routines, which detach it, cancelling the pending continuous re-queue. +TEST(ModbusClientHubPriority, ClearDeviceDuringDataCancelsContinuousRequeue) { + NoResponseProbeHub hub; + ClearOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + + // "Stop polling now" from inside on_response() works: the completing command is detached, so the + // continuous re-queue is cancelled. + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// A device that stops polling for its address (clear by address) from inside on_response(). +class ClearAddressOnDataDevice : public ModbusClientDevice { + public: + ClearAddressOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->clear_tx_queue_for_address(); + } +}; +} // namespace + +// The address-scoped clear cancels the mid-completion re-queue the same way the device-scoped one does. +TEST(ModbusClientHubPriority, ClearAddressDuringDataCancelsContinuousRequeue) { + NoResponseProbeHub hub; + ClearAddressOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so +// external devices written against the old names keep working through the deprecation window. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class LegacyNameDevice : public ModbusClientDevice { + public: + LegacyNameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_modbus_not_sent() override { this->legacy_not_sent_++; } + bool on_modbus_no_response() override { + this->legacy_no_response_++; + return false; + } + int legacy_not_sent_{0}; + int legacy_no_response_{0}; +}; +#pragma GCC diagnostic pop +} // namespace + +// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one. +// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no +// CommandOptions - so a component built against a real release still compiles and still queues. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it + EXPECT_EQ(hub.queued_frames(), 1u); + + // A refusal is invisible to this spelling - no return value and no callback - so the only evidence + // is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys. + device.send_pdu(std::span()); + EXPECT_EQ(hub.queued_frames(), 1u); + + // The deprecated hub spelling queues the same way, addressed explicitly. + const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + hub.send_pdu(0x03, other, &device); + EXPECT_EQ(hub.queued_frames(), 2u); + + // Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO), + // then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's + // on_no_response proves the request routes by owner pointer, not by address. + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02) + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing) +} +#pragma GCC diagnostic pop + +TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { + NoResponseProbeHub hub; + LegacyNameDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.queue_pdu(read); + hub.force_send_next(); + hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response + EXPECT_EQ(device.legacy_no_response_, 1); + + // A refused send returns false with no callback, so exercise the forward through an accepted + // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. + EXPECT_FALSE(device.queue_pdu(std::span())); // empty PDU: refused at the door + EXPECT_EQ(device.legacy_not_sent_, 0); + const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; + EXPECT_TRUE(device.queue_pdu(queued)); + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + EXPECT_EQ(device.legacy_not_sent_, 1); +} + +// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. +TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { + NoResponseProbeHub hub; + LegacyNameDevice device(&hub, 0x02); + std::vector big(MAX_PDU_SIZE + 1, 0x41); + EXPECT_FALSE(device.queue_pdu(big)); + EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered + EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(hub.entries(), 0u); +} + +// --- ModbusDevice compatibility shim ------------------------------------------------------------ +// External components written against the pre-2026.8 API subclass ModbusDevice and override the +// old callbacks; the shim adapts the span-based hooks back to those signatures. +namespace { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class LegacyApiDevice : public ModbusDevice { + public: + LegacyApiDevice(ModbusClientHub *hub, uint8_t address) : ModbusDevice(hub, address) {} + void on_modbus_data(const std::vector &data) override { this->last_data_ = data; } + void on_modbus_error(uint8_t function_code, uint8_t exception_code) override { + this->last_error_fc_ = function_code; + this->last_error_code_ = exception_code; + } + std::vector last_data_; + int last_error_fc_{-1}; + int last_error_code_{-1}; +}; +#pragma GCC diagnostic pop +} // namespace + +TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { + NoResponseProbeHub hub; + LegacyApiDevice device(&hub, 0x02); + + // Read response: on_modbus_data() historically received the payload after the function code and + // the byte-count byte, as an owning vector. + const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + device.queue_pdu(read_req); + hub.force_send_next(); + const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, response); + const std::vector expected{0x00, 0x2A, 0x01, 0x00}; + EXPECT_EQ(device.last_data_, expected); + + // Write echo: no byte-count byte, so the payload is everything after the function code. + const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; + device.queue_pdu(write_req); + hub.force_send_next(); + hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request + const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; + EXPECT_EQ(device.last_data_, expected_echo); + + // Exception response: on_modbus_error() received the masked function code and the exception code. + device.queue_pdu(read_req); + hub.force_send_next(); + const uint8_t error[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, error); + EXPECT_EQ(device.last_error_fc_, 0x03); + EXPECT_EQ(device.last_error_code_, 0x02); +} + +// --- typed send helpers -------------------------------------------------------------------------- +// Each helper is a one-line forward onto a merged builder; these pin the function code and wire +// bytes each one queues, so a swapped code or transposed field cannot survive review silently. +TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) { + NoResponseProbeHub hub; + ModbusClientDevice device(&hub, 0x02); + auto check = [&](const std::vector &expected) { + ASSERT_EQ(hub.queued_frames(), 1u); + auto pdu = hub.queued(0).frame.pdu(); + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + hub.force_send_next(); + hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame + }; + + device.read_holding_registers(0x0102, 3); + check({0x03, 0x01, 0x02, 0x00, 0x03}); + device.read_input_registers(0x0010, 2); + check({0x04, 0x00, 0x10, 0x00, 0x02}); + device.read_coils(0x0020, 10); + check({0x01, 0x00, 0x20, 0x00, 0x0A}); + device.read_discrete_inputs(0x0030, 1); + check({0x02, 0x00, 0x30, 0x00, 0x01}); + device.write_single_register(0x0040, 0xABCD); + check({0x06, 0x00, 0x40, 0xAB, 0xCD}); + device.write_single_coil(0x0041, true); + check({0x05, 0x00, 0x41, 0xFF, 0x00}); + device.write_single_coil(0x0041, false); + check({0x05, 0x00, 0x41, 0x00, 0x00}); + const uint16_t regs[] = {0x000B, 0x0016}; + device.write_multiple_registers(0x0050, regs); + check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}); + const bool coils[] = {true, false, true}; + device.write_multiple_coils(0x0060, coils); + check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05}); + const uint8_t packed[] = {0x05}; + device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes + check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05}); +} + +TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) { + NoResponseProbeHub hub; + ModbusClientDevice device(&hub, 0x02); + + device.read_entities(EntityType::HOLDING, 0x0001, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x03); + hub.force_send_next(); + hub.timeout_waiting(); + + device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1); + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x02); + hub.force_send_next(); + hub.timeout_waiting(); + + device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// A rejected read_entities() returns false like every other refused send. +namespace { +class NotSentCountingDevice : public ModbusClientDevice { + public: + NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { this->not_sent_++; } + int not_sent_{0}; +}; +} // namespace + +TEST(ModbusTypedSendHelpers, InvalidReadEntitiesIsRefusedAtTheDoor) { + NoResponseProbeHub hub; + NotSentCountingDevice device(&hub, 0x02); + EXPECT_FALSE(device.read_entities(EntityType::CUSTOM, 0x0001, 1)); + EXPECT_EQ(device.not_sent_, 0); // refused sends report through the return value + EXPECT_EQ(hub.queued_frames(), 0u); +} + +namespace { +// Re-sends its own frame from inside on_response() - matching the command mid-completion. +class ResendOnDataDevice : public ModbusClientDevice { + public: + ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_response(std::span request_pdu, std::span response_pdu) override { + this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + } + void queue_pdu(const std::vector &pdu) { ModbusClientDevice::queue_pdu(pdu); } +}; +} // namespace + +// A send from inside on_response() that matches the RECEIVED (completing) entry is absorbed into it, +// never a fresh twin. Here it is a one-shot re-send of a continuous poll, so it also downgrades the +// poll to a one-shot: one entry on the queue afterwards, now non-continuous. +TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand) { + NoResponseProbeHub hub; + ResendOnDataDevice device(&hub, 0x02); + + device.read_holding_registers(0x100, 2, {.continuous = true}); + hub.force_send_next(); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion + + ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin + EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll +} + +// An exception-flagged function code is never silently re-sendable, even though the read check +// masks the exception bit: its duplicate takes the drop path like any other non-read. +TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged + EXPECT_TRUE(device.queue_pdu(weird)); + EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + hub.sweep_for_test(); + + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_EQ(hub.queued(0).pending, 1u); + EXPECT_EQ(device.not_sent_count_, 0); + + // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class + // ordering either: exception-flagged codes are excluded from the mutates classification. + const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; + device.queue_pdu(weird_write); + ASSERT_EQ(hub.queued_frames(), 2u); + EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry +} + +namespace { +// From inside the sweep's on_not_sent, re-sends the frame that is currently WAITING. +class ResendInFlightOnNotSentDevice : public ModbusClientDevice { + public: + ResendInFlightOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_not_sent(std::span request_pdu) override { + this->not_sent_count_++; + if (this->not_sent_count_ == 1) { + const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU + this->queue_pdu(same_as_waiting); + } + } + int not_sent_count_{0}; +}; +} // namespace + +// A clear turns the waiting entry into a WAITING_RETIRED shell. The shell keeps its device (so the +// in-flight request still gets its callback), but the dedup skips it, so a sweep handler re-sending +// that frame queues fresh instead of being absorbed into the cleared shell and drained as on_not_sent. +TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) { + NoResponseProbeHub hub; + ResendInFlightOnNotSentDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); // READ_PDU now waiting + const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.queue_pdu(queued_read); // a queued frame for the sweep to notify + ASSERT_EQ(hub.queued_frames(), 1u); + + hub.clear_tx_queue_for_address(0x02); + hub.sweep_for_test(); + + EXPECT_EQ(device.not_sent_count_, 1); // only the cleared queued frame, not the re-send + ASSERT_EQ(hub.queued_frames(), 1u); // the handler's re-send queued fresh... + EXPECT_EQ(hub.queued(0).pending, 1u); // ...not absorbed into the cleared shell + EXPECT_TRUE(std::equal(hub.queued(0).frame.pdu().begin(), hub.queued(0).frame.pdu().end(), READ_PDU)); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); // in-flight one still awaiting a reply +} + +namespace { +// Gives up after a timeout by clearing its address from inside on_no_response() - the natural +// "device is dead, drop my traffic" pattern, and the reentrant case the address clear must handle. +class ClearAddressOnNoResponseDevice : public ModbusClientDevice { + public: + ClearAddressOnNoResponseDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + this->clear_tx_queue_for_address(); + return false; // gave up + } + void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; } + int terminals() const { return this->no_response_count_ + this->not_sent_count_; } + int no_response_count_{0}; + int not_sent_count_{0}; +}; + +} // namespace + +// A clear issued from inside on_no_response() must not cause the request to be resolved twice: +// that callback already was its terminal, so the entry it hijacks owes nothing more. +TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { + NoResponseProbeHub hub; + ClearAddressOnNoResponseDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 1); // exactly one terminal for the one accepted request + EXPECT_EQ(hub.entries(), 0u); +} + +// The same entry standing for two accepted requests: the timeout resolves one, and the clear that +// cancels the re-run must resolve exactly the other. +TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedRequestOnce) { + NoResponseProbeHub hub; + ClearAddressOnNoResponseDevice device(&hub, 0x02); + + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests + hub.force_send_next(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.terminals(), 2); // one per accepted request, no more + EXPECT_EQ(hub.entries(), 0u); +} + +// A cleared in-flight frame must release the bus by both exits and still deliver the in-flight +// request's usual callback (on_response here, on_no_response on timeout); no on_not_sent, no duplicate. +TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // the late reply for the cleared frame + + EXPECT_FALSE(hub.waiting()); // the bus is free again + EXPECT_EQ(hub.entries(), 0u); // the shell is gone + EXPECT_EQ(device.data_count_, 1); // the in-flight request still got its response callback + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate +} + +TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + hub.timeout_waiting(); // no reply ever arrives; the watchdog releases the shell + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + EXPECT_EQ(device.no_response_count_, 1); // the in-flight request got its on_no_response + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate +} + +// Clearing an interrupted (not-yet-notified) frame keeps its distrust: it becomes an +// INTERRUPTED_RETIRED shell that still ends in on_no_response at the timeout - never delivering a +// late response as on_response. No duplicate here, so no on_not_sent. +TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction + hub.sweep_for_test(); + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); + + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED); + + // A late MATCHING response is ignored (distrust survives the clear), not delivered as on_response. + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(device.data_count_, 0); + ASSERT_TRUE(hub.waiting()); // still held; the ignored response did not free the wire + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); // the interrupted request's usual terminal, at the timeout + EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate + EXPECT_EQ(hub.queued_frames(), 0u); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The other order: clear a WAITING frame, THEN an unexpected frame arrives. The distrust must still +// take hold - the cleared shell becomes INTERRUPTED_RETIRED and a later matching frame is ignored. +TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + hub.clear_tx_queue_for_address(0x02); + ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + + const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, stray_pdu); // unexpected frame interrupts the cleared shell + ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED); + + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x02, ok_response); // now-distrusted late response is ignored + EXPECT_EQ(device.data_count_, 0); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A cleared waiting duplicate (pending 2) that times out: the duplicate drains to on_not_sent and +// the in-flight request gets on_no_response, with nothing re-transmitted (sweep runs before timeout). +TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { + NoResponseProbeHub hub; + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 + ASSERT_EQ(hub.queued(0).pending, 2u); + hub.force_send_next(); // sent, pending still 2 + hub.clear_tx_queue_for_address(0x02); + + hub.timeout_waiting(); + + EXPECT_EQ(device.not_sent_count_, 1); // the un-run duplicate + EXPECT_EQ(device.no_response_count_, 1); // the in-flight request's usual terminal + EXPECT_EQ(hub.queued_frames(), 0u); // nothing re-transmitted + EXPECT_EQ(hub.entries(), 0u); // fully drained and erased + EXPECT_FALSE(hub.waiting()); +} + +// An absorbed extra request also gets its run after an error response - the re-request was +// explicit, so it runs once more whether this attempt succeeded or not. +TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + DataCountingDevice device(&hub, 0x02); + + device.queue_pdu(read_pdu()); + hub.force_send_next(); + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed + const uint8_t exception_response[] = {0x83, 0x02}; + hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 + + EXPECT_EQ(device.error_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // request 2's run still queued + EXPECT_EQ(hub.queued(0).pending, 1u); +} + +// Read-modify-write function codes mutate registers, so they rank as WRITE for transmit ordering. +TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { + NoResponseProbeHub hub; + SentCountingDevice device(&hub, 0x02); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; + device.queue_pdu(read); + device.queue_pdu(mask_write); + + ASSERT_EQ(hub.queued_frames(), 2u); + const ModbusDeviceCommand *next = hub.next_ready(); + ASSERT_NE(next, nullptr); + EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read + EXPECT_EQ(next->frame.pdu()[0], 0x16); +} } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 1c57a81e6f..53f51b016b 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -1,10 +1,12 @@ #include +#include + #include "esphome/components/modbus/modbus_helpers.h" namespace esphome::modbus::helpers { -using FC = ModbusFunctionCode; +using FC = FunctionCode; // --- server_frame_length --------------------------------------------------- // Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from @@ -83,6 +85,25 @@ TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); } +TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) { + // FC 0x17's write byte count caps at the spec 6.17 limit of 121 registers (242 bytes), deliberately + // tighter than FC 0x10's 123, so a corrupt byte count cannot make the parser wait past the real frame. + const uint8_t pdu[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF}; // claims 255 bytes + EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2); +} + +TEST(ModbusClientFrameLength, ReadWriteMultipleUsesByteCount) { + // read start(2) + read qty(2) + write start(2) + write qty(2) + byte count(1) then data + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02, 0x04, 0xAA, 0xBB, 0xCC, 0xDD}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13 + 4); +} + +TEST(ModbusClientFrameLength, ReadWriteMultipleMissingByteCount) { + // header present up to the write quantity but the byte count byte (frame[10]) is absent + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13); +} + TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); @@ -97,6 +118,124 @@ TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); } +// --- file-record length cap -------------------------------------------------- +// FC 0x14/0x15 are parsed only to keep the frame parser in sync; the byte count caps at 251 +// (MAX_PDU_SIZE - 2), reproducing the released frame-relative bound of MAX_FRAME_SIZE - 5. + +TEST(ModbusFileRecordCap, PduLengthCapsByteCountAt251) { + const uint8_t pdu[] = {static_cast(FC::READ_FILE_RECORD), 0xFF}; // claims 255 bytes + EXPECT_EQ(server_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2)); + EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2)); + // Frame wrappers: address(1) + PDU + CRC(2) stays within the RTU 256-byte frame limit. + const uint8_t frame[] = {0x01, static_cast(FC::WRITE_FILE_RECORD), 0xFF}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE); + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE); +} + +TEST(ModbusFileRecordCap, StandardChecksAcceptUpTo251) { + // A full-length PDU at the cap: function(1) + byte count(1) + 251 data bytes = MAX_PDU_SIZE. + std::vector at_cap(MAX_PDU_SIZE, 0x00); + at_cap[0] = static_cast(FC::READ_FILE_RECORD); + at_cap[1] = MAX_PDU_SIZE - 2; + EXPECT_TRUE(is_server_pdu_standard(at_cap.data(), at_cap.size())); + EXPECT_TRUE(is_client_pdu_standard(at_cap.data(), at_cap.size())); + // Byte count 252 in the same 253-byte buffer: the parsed length still matches (capped), so this + // exercises the byte-count bound itself rather than the length identity. + at_cap[1] = MAX_PDU_SIZE - 1; + EXPECT_FALSE(is_server_pdu_standard(at_cap.data(), at_cap.size())); + EXPECT_FALSE(is_client_pdu_standard(at_cap.data(), at_cap.size())); +} + +// --- is_client_pdu_standard / is_server_pdu_standard ------------------------- +// The gatekeepers for the typed client dispatch: a PDU must be exactly its function code's standard +// shape, with byte count, quantity, and address range all consistent. + +TEST(ModbusPduStandard, ClientReadAndWriteConformant) { + const uint8_t read_regs[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + EXPECT_TRUE(is_client_pdu_standard(read_regs, sizeof(read_regs))); + const uint8_t write_regs[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02}; + EXPECT_TRUE(is_client_pdu_standard(write_regs, sizeof(write_regs))); + // 10 coils pack into 2 data bytes - the coil formula, not the register one. + const uint8_t write_coils[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03}; + EXPECT_TRUE(is_client_pdu_standard(write_coils, sizeof(write_coils))); +} + +TEST(ModbusPduStandard, ClientRejectsNonConformant) { + // Truncated: header claims 4 data bytes, only 2 present. + const uint8_t truncated[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(truncated, sizeof(truncated))); + // Byte count disagrees with quantity (2 registers need 4 bytes, header says 2). + const uint8_t inconsistent[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(inconsistent, sizeof(inconsistent))); + // Coil write using the register byte-count formula (10 coils with 20 data bytes). + const uint8_t coil_as_regs[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x14, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + EXPECT_FALSE(is_client_pdu_standard(coil_as_regs, sizeof(coil_as_regs))); + // Quantity zero and quantity beyond the per-function-code maximum. + const uint8_t zero_qty[] = {0x03, 0x01, 0x00, 0x00, 0x00}; + EXPECT_FALSE(is_client_pdu_standard(zero_qty, sizeof(zero_qty))); + const uint8_t too_many[] = {0x03, 0x01, 0x00, 0x00, 0x7E}; // 126 > 125 + EXPECT_FALSE(is_client_pdu_standard(too_many, sizeof(too_many))); + // Address range overflow: 0xFFFF + 2 registers exceeds the 16-bit register space. + const uint8_t wraps[] = {0x03, 0xFF, 0xFF, 0x00, 0x02}; + EXPECT_FALSE(is_client_pdu_standard(wraps, sizeof(wraps))); +} + +TEST(ModbusPduStandard, ServerReadResponses) { + const uint8_t ok[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + EXPECT_TRUE(is_server_pdu_standard(ok, sizeof(ok))); + // Byte-count header disagrees with the actual length. + const uint8_t lying[] = {0x03, 0x06, 0x00, 0x2A, 0x01, 0x00}; + EXPECT_FALSE(is_server_pdu_standard(lying, sizeof(lying))); + // An empty PDU (the on_error path) is not a standard response. + EXPECT_FALSE(is_server_pdu_standard(ok, 0)); +} + +TEST(ModbusPduStandard, ServerResponsesRejectDegenerateShapes) { + // A read response always carries data: byte count zero is non-conformant. + const uint8_t zero_bc[] = {0x03, 0x00}; + EXPECT_FALSE(is_server_pdu_standard(zero_bc, sizeof(zero_bc))); + // Registers are 2 bytes each: an odd byte count would silently truncate a register. + const uint8_t odd_bc[] = {0x03, 0x03, 0x00, 0x01, 0x02}; + EXPECT_FALSE(is_server_pdu_standard(odd_bc, sizeof(odd_bc))); + // Bit reads have no parity requirement: one packed byte is a fine coil response. + const uint8_t coil_one_byte[] = {0x01, 0x01, 0x05}; + EXPECT_TRUE(is_server_pdu_standard(coil_one_byte, sizeof(coil_one_byte))); + // A write-multiple echo claiming 65535 registers written is bounded like the request side. + const uint8_t wild_echo[] = {0x10, 0x00, 0x00, 0xFF, 0xFF}; + EXPECT_FALSE(is_server_pdu_standard(wild_echo, sizeof(wild_echo))); + const uint8_t ok_echo[] = {0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_TRUE(is_server_pdu_standard(ok_echo, sizeof(ok_echo))); +} + +TEST(ModbusPduStandard, SingleCoilValueMustBeCanonical) { + // FC 0x05's value field allows exactly 0xFF00 (ON) and 0x0000 (OFF); anything else is non-standard. + const uint8_t on[] = {0x05, 0x00, 0x10, 0xFF, 0x00}; + const uint8_t off[] = {0x05, 0x00, 0x10, 0x00, 0x00}; + const uint8_t junk[] = {0x05, 0x00, 0x10, 0x12, 0x34}; + EXPECT_TRUE(is_client_pdu_standard(on, sizeof(on))); + EXPECT_TRUE(is_client_pdu_standard(off, sizeof(off))); + EXPECT_FALSE(is_client_pdu_standard(junk, sizeof(junk))); + EXPECT_TRUE(is_server_pdu_standard(on, sizeof(on))); // the response echoes the request + EXPECT_FALSE(is_server_pdu_standard(junk, sizeof(junk))); +} + +TEST(ModbusPduStandard, NonStandardFunctionCodesAcceptedOnLengthAlone) { + // Custom, unimplemented, and exception function codes have no standard shape to check: they are + // accepted whenever the parsed length matches, so a dispatcher can still route them by function + // code instead of having them rejected outright. This is the documented contract - see the header. + const uint8_t custom[] = {0x42}; // user-defined space; 1 byte matches the MIN_PDU_SIZE fallback + EXPECT_TRUE(is_client_pdu_standard(custom, sizeof(custom))); + EXPECT_TRUE(is_server_pdu_standard(custom, sizeof(custom))); + const uint8_t unimplemented[] = {0x07}; // READ_EXCEPTION_STATUS + EXPECT_TRUE(is_server_pdu_standard(unimplemented, sizeof(unimplemented))); + const uint8_t exception[] = {0x83, 0x02}; // exception response; length pinned to 2 bytes + EXPECT_TRUE(is_server_pdu_standard(exception, sizeof(exception))); + // The length identity still gates: extra bytes beyond the parsed fallback are non-conformant. + const uint8_t custom_long[] = {0x42, 0x01}; + EXPECT_FALSE(is_client_pdu_standard(custom_long, sizeof(custom_long))); +} + // --- create_client_pdu ----------------------------------------------------- // PDU = function code + data (no address, no CRC). @@ -179,6 +318,32 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { EXPECT_TRUE(pdu.empty()); } +// The generic write path requires the data length to agree exactly with the entity count +// (registers: 2 bytes each; coils: 8 packed per byte) - the same rule the response dispatch +// enforces via is_client_pdu_standard(), so a frame built here always passes that gate. +TEST(ModbusCreateClientPdu, WriteMultipleRejectsMismatchedDataLength) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + // 2 registers need exactly 4 data bytes. + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 3).empty()); + EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 4).empty()); + // 10 coils pack into exactly 2 data bytes - the coil formula, not the register one. + EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2).empty()); + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 4).empty()); +} + +TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) { + // 200 coils: above the 123-register write limit but well within the 1968-coil limit; 25 data bytes. + std::vector values(25, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 200, values.data(), values.size()); + ASSERT_FALSE(pdu.empty()); + EXPECT_EQ(pdu[5], 25); // byte count uses the coil formula + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); // builder output passes the validator + // Builder and validator agree at the top of the range too: 1969 coils rejected. + std::vector big((1969 + 7) / 8, 0x00); + EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty()); +} + +// --- payload_to_number ----------------------------------------------------- TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); @@ -194,6 +359,28 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } +TEST(ModbusHelpersTest, PayloadToNumberDecodesSwappedUnsignedWord) { + const std::vector data{0x34, 0x12}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD_S, 0, 0xFFFFFFFF), 0x1234); +} + +TEST(ModbusHelpersTest, PayloadToNumberDecodesSwappedSignedWord) { + const std::vector data{0xFE, 0xFF}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::S_WORD_S, 0, 0xFFFFFFFF), -2); +} + +TEST(ModbusHelpersTest, PayloadToNumberAppliesBitmaskAfterSwap) { + // Bytes {0x34,0x12} decode as U_WORD_S to 0x1234; mask 0xFF00 then right-shift by bit 8 -> 0x12 + const std::vector data{0x34, 0x12}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD_S, 0, 0xFF00), 0x12); +} + +TEST(ModbusHelpersTest, PayloadToNumberAppliesBitmaskAfterSwapSigned) { + // Bytes {0x34,0xFE} decode as S_WORD_S to 0xFE34 (negative); mask 0x00F0 then right-shift by bit 4 -> 0x3 + const std::vector data{0x34, 0xFE}; + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::S_WORD_S, 0, 0x00F0), 0x3); +} + // --- registers_to_number --------------------------------------------------- // Register words are host byte order; results must match the byte-based payload_to_number. @@ -202,6 +389,16 @@ TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); } +TEST(ModbusHelpersTest, RegistersToNumberDecodesSwappedUnsignedWord) { + const uint16_t registers[] = {0x3412}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD_S), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesSwappedSignedWord) { + const uint16_t registers[] = {0xFEFF}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::S_WORD_S), -2); +} + TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { const uint16_t registers[] = {0x1234, 0x5678}; EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); @@ -229,4 +426,280 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// --- packed bit helpers ------------------------------------------------------ + +TEST(ModbusHelpersTest, PackBitsAppendsToContainer) { + // Bits are packed LSB first: the first value is bit 0 of the first byte, and the push_back + // overload appends packed bytes onto a growable container preserving existing content. + std::vector bits{true, false, true, true, false, false, false, false, true, true}; + std::vector out{0x55}; // pre-existing content must be preserved + pack_bits(out, bits); + ASSERT_EQ(out.size(), 3u); // leading byte + 2 packed bytes (10 bits) + EXPECT_EQ(out[0], 0x55); + EXPECT_EQ(out[1], 0x0D); // 0b00001101 + EXPECT_EQ(out[2], 0x03); // bits 8 and 9 -> bits 0,1 of second byte +} + +// --- typed builders ---------------------------------------------------------- + +TEST(ModbusTypedBuilders, ReadPduWireBytes) { + auto pdu = create_read_pdu(FC::READ_HOLDING_REGISTERS, 0x0102, 3); + const std::vector expected{0x03, 0x01, 0x02, 0x00, 0x03}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); + // Reads that run past the 16-bit address space are refused. + EXPECT_TRUE(create_read_pdu(FC::READ_HOLDING_REGISTERS, 0xFFFF, 2).empty()); +} + +TEST(ModbusTypedBuilders, WriteSinglePduWireBytes) { + auto reg = create_write_single_register_pdu(0x0010, 0xABCD); + const std::vector expected_reg{0x06, 0x00, 0x10, 0xAB, 0xCD}; + EXPECT_EQ(std::vector(reg.begin(), reg.end()), expected_reg); + EXPECT_TRUE(is_client_pdu_standard(reg.data(), reg.size())); + auto coil_on = create_write_single_coil_pdu(0x0011, true); + auto coil_off = create_write_single_coil_pdu(0x0011, false); + const std::vector expected_on{0x05, 0x00, 0x11, 0xFF, 0x00}; + const std::vector expected_off{0x05, 0x00, 0x11, 0x00, 0x00}; + EXPECT_EQ(std::vector(coil_on.begin(), coil_on.end()), expected_on); + EXPECT_EQ(std::vector(coil_off.begin(), coil_off.end()), expected_off); + EXPECT_TRUE(is_client_pdu_standard(coil_on.data(), coil_on.size())); + EXPECT_TRUE(is_client_pdu_standard(coil_off.data(), coil_off.size())); +} + +TEST(ModbusTypedBuilders, WriteRegistersPduWireBytes) { + const uint16_t values[] = {0x000B, 0x0016}; + auto pdu = create_write_registers_pdu(0x0000, values); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); + // Writes that run past the 16-bit address space are refused. + EXPECT_TRUE(create_write_registers_pdu(0xFFFF, values).empty()); +} + +TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) { + std::vector values(MAX_NUM_OF_REGISTERS_TO_WRITE + 1, 0xAAAA); + EXPECT_TRUE(create_write_registers_pdu(0x0000, values).empty()); + values.pop_back(); + EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); +} + +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { + const uint16_t write_values[] = {0x000B, 0x0016}; + // Read 2 registers at 0x0010, write 2 registers at 0x0020. + auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values); + const std::vector expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, + 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); +} + +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) { + const uint16_t one_value[] = {0x0001}; + const uint16_t two_values[] = {0x0001, 0x0002}; + // Read count out of range (zero and above the read ceiling). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty()); + EXPECT_TRUE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty()); + // Write count out of range (empty, and above the read/write ceiling which is lower than a plain write). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span()).empty()); + std::vector too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty()); + // Both blocks at their respective ceilings are accepted. + std::vector at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA); + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty()); + // A block that runs past the 16-bit address space is refused (read block, then write block). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty()); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty()); + // Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits. + EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000 + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000 +} + +TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) { + const auto rw = static_cast(FC::READ_WRITE_MULTIPLE_REGISTERS); + // 0x17 both reads and writes, but it is not a pure (retry-safe) read. + EXPECT_TRUE(is_function_code_read(rw)); + EXPECT_TRUE(is_function_code_write(rw)); + EXPECT_FALSE(is_function_code_read_only(rw)); + // Pure reads are read and read-only, never write. + const auto rd = static_cast(FC::READ_HOLDING_REGISTERS); + EXPECT_TRUE(is_function_code_read(rd)); + EXPECT_TRUE(is_function_code_read_only(rd)); + EXPECT_FALSE(is_function_code_write(rd)); + // Plain writes are write only. + const auto wr = static_cast(FC::WRITE_MULTIPLE_REGISTERS); + EXPECT_TRUE(is_function_code_write(wr)); + EXPECT_FALSE(is_function_code_read(wr)); + EXPECT_FALSE(is_function_code_read_only(wr)); + // Mask-write register mutates via read-modify-write, so it classes as a write, never a read. + const auto mask = static_cast(FC::MASK_WRITE_REGISTER); + EXPECT_TRUE(is_function_code_write(mask)); + EXPECT_FALSE(is_function_code_read(mask)); + EXPECT_FALSE(is_function_code_read_only(mask)); +} + +TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) { + // The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead. + const uint16_t values[] = {0x0001}; + EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast(values), + sizeof(values)) + .empty()); +} + +TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { + // The container overload appends - the semantic every migrated caller relies on when a lambda + // has already put words into the buffer. + std::vector data{0x1234}; + float_to_payload(data, 1.0f, SensorValueType::U_WORD); + ASSERT_EQ(data.size(), 2u); + EXPECT_EQ(data[0], 0x1234); + EXPECT_EQ(data[1], 0x0001); +} + +// --- number_to_payload ----------------------------------------------------- + +TEST(ModbusHelpersTest, NumberToPayloadRoundTripsSwappedUnsignedWord) { + std::vector regs; + number_to_payload(regs, 0x1234, SensorValueType::U_WORD_S); + ASSERT_EQ(regs.size(), 1u); + EXPECT_EQ(regs[0], 0x3412); + EXPECT_EQ(registers_to_number(regs.data(), regs.size(), SensorValueType::U_WORD_S), 0x1234); +} + +TEST(ModbusHelpersTest, NumberToPayloadRoundTripsSwappedSignedWord) { + std::vector regs; + number_to_payload(regs, -2, SensorValueType::S_WORD_S); + ASSERT_EQ(regs.size(), 1u); + EXPECT_EQ(regs[0], 0xFEFF); + EXPECT_EQ(registers_to_number(regs.data(), regs.size(), SensorValueType::S_WORD_S), -2); +} + +TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) { + // is_function_code_write() masks the exception bit; the builder must not. + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + EXPECT_TRUE(create_client_pdu(FunctionCode(0x90), 0x0000, 2, values, 4).empty()); + EXPECT_TRUE(create_client_pdu(FunctionCode(0x85), 0x0000, 1, values, 2).empty()); +} + +TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) { + // This early guard is what keeps the 246-byte packing buffer from overflowing - the shared core's + // identical check runs after packing, so it cannot protect it. + auto big = std::make_unique(MAX_NUM_OF_COILS_TO_WRITE + 1); + EXPECT_TRUE(create_write_coils_pdu(0, std::span(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty()); +} + +TEST(ModbusCreateClientPdu, GenericCoilWriteMasksTrailingPadBits) { + // 10 coils with junk in the pad bits of the last data byte: the generic path masks them like the + // typed builder, so both produce identical wire bytes. + const uint8_t values[] = {0xFF, 0xFF}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2); + ASSERT_FALSE(pdu.empty()); + EXPECT_EQ(pdu[pdu.size() - 1], 0x03); // bits 8-9 kept, pad bits 10-15 zeroed +} + +TEST(ModbusCreateClientPdu, SingleCoilValueValidated) { + const uint8_t on[] = {0xFF, 0x00}; + const uint8_t junk[] = {0x01, 0x00}; + EXPECT_FALSE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, on, 2).empty()); + EXPECT_TRUE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, junk, 2).empty()); +} + +// --- create_write_coils_pdu (packed) --------------------------------------- + +TEST(ModbusWriteCoilsPacked, MatchesBoolBuilder) { + const bool coils[] = {true, false, true, true, false, false, true, false, true, true}; + uint8_t packed[] = {0b01001101, 0b00000011}; + auto from_bools = create_write_coils_pdu(0x13, coils); + auto from_packed = create_write_coils_pdu(0x13, PackedBits(packed, 10)); + ASSERT_EQ(from_packed.size(), from_bools.size()); + EXPECT_EQ(0, memcmp(from_packed.data(), from_bools.data(), from_bools.size())); +} + +TEST(ModbusWriteCoilsPacked, MasksUnusedTrailingBits) { + uint8_t packed[] = {0xFF}; + auto pdu = create_write_coils_pdu(0, PackedBits(packed, 3)); + ASSERT_EQ(pdu.size(), 7u); + EXPECT_EQ(pdu[6], 0x07); +} + +TEST(ModbusWriteCoilsPacked, RejectsShortBufferAndZeroCount) { + uint8_t packed[] = {0xFF}; + EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 9)).empty()); // needs 2 bytes + EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 0)).empty()); +} + +TEST(ModbusHelpersTest, PackedBitsReadsLsbFirst) { + const uint8_t packed[] = {0x0D, 0x03}; // bits 0,2,3 and 8,9 + PackedBits bits(packed, 11); + EXPECT_EQ(bits.size(), 11u); + EXPECT_TRUE(bits[0]); + EXPECT_FALSE(bits[1]); + EXPECT_TRUE(bits[2]); + EXPECT_TRUE(bits[3]); + EXPECT_FALSE(bits[7]); + EXPECT_TRUE(bits[8]); + EXPECT_TRUE(bits[9]); + EXPECT_FALSE(bits[10]); + EXPECT_EQ(bits.bytes().size(), 2u); +} + +TEST(ModbusHelpersTest, MutablePackedBitsSetsAndClears) { + uint8_t packed[2] = {0x00, 0xFF}; + MutablePackedBits bits(packed, 16); + bits.set(0, true); + bits.set(3, true); + bits.set(9, false); + EXPECT_EQ(packed[0], 0x09); // bits 0 and 3 + EXPECT_EQ(packed[1], 0xFD); // bit 9 (bit 1 of byte 1) cleared +} + +TEST(ModbusHelpersTest, MutablePackedBitsRoundTripAndConversion) { + const bool original[] = {true, true, false, true, false, false, false, false, true, false, true}; + constexpr uint16_t count = sizeof(original); + uint8_t packed[(count + 7) / 8] = {}; + MutablePackedBits out(packed, count); + for (uint16_t i = 0; i != count; i++) + out.set(i, original[i]); + PackedBits view = out; // implicit conversion to the read-only view + ASSERT_EQ(view.size(), count); + for (uint16_t i = 0; i != count; i++) + EXPECT_EQ(view[i], original[i]) << "bit " << i; +} + +TEST(ModbusHelpersTest, PackedBitsViewContractsEnforced) { + uint8_t buf[8] = {}; + PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer + EXPECT_EQ(view.bytes().size(), 2u); + + MutablePackedBits bits(std::span(buf, 2), 10); + bits.set(9, true); // in range: lands in byte 1 + bits.set(10, true); // out of range: dropped + bits.set(300, true); // far out of range: dropped, no write past the span + + MutablePackedBits short_bits(std::span(buf, 1), 10); // contract-violating: 10 bits over 1 byte + short_bits.set(9, false); // within count_ but past the span: dropped (would clear bit 9 set above) + EXPECT_EQ(buf[1], 0x02); + for (size_t i = 2; i < sizeof(buf); i++) + EXPECT_EQ(buf[i], 0) << "byte " << i; +} + +// server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a +// read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU +// is the exception code byte, for reads and writes alike. +TEST(ModbusServerPduPayload, ExceptionOfReadYieldsExceptionCode) { + const uint8_t pdu[] = {0x83, 0x02}; // exception response to READ_HOLDING_REGISTERS + auto payload = server_pdu_payload(pdu); + ASSERT_EQ(payload.size(), 1u); + EXPECT_EQ(payload[0], 0x02); +} + +TEST(ModbusServerPduPayload, ExceptionOfWriteYieldsExceptionCode) { + const uint8_t pdu[] = {0x86, 0x03}; // exception response to WRITE_SINGLE_REGISTER + auto payload = server_pdu_payload(pdu); + ASSERT_EQ(payload.size(), 1u); + EXPECT_EQ(payload[0], 0x03); +} + } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus/modbus_server_coils_test.cpp b/tests/components/modbus/modbus_server_coils_test.cpp new file mode 100644 index 0000000000..e4bf3f6b14 --- /dev/null +++ b/tests/components/modbus/modbus_server_coils_test.cpp @@ -0,0 +1,398 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/hal.h" + +namespace esphome::modbus { + +namespace { + +// A server device backed by a small coil array: reads deliver the stored bits, writes apply them. +class CoilDevice : public ModbusServerDevice { + public: + explicit CoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->read_count++; + for (uint16_t i = 0; i < bits.size(); i++) + bits.set(i, this->coils[start_address + i]); + return std::nullopt; + } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_write_count = bits.size(); + for (uint16_t i = 0; i < bits.size(); i++) + this->coils[start_address + i] = bits[i]; + return std::nullopt; + } + + bool coils[32] = {}; + int read_count{0}; + int write_count{0}; + uint16_t last_write_count{0}; +}; + +// A device with no bit handlers, to exercise the ILLEGAL_FUNCTION defaults. +class NoBitsDevice : public ModbusServerDevice { + public: + explicit NoBitsDevice(uint8_t address) { this->set_address(address); } +}; + +// Distinguishes the two bit-read entry points: each fills a different pattern and counts its calls, so a +// test can prove FC 0x01 vs 0x02 dispatch routes to the right handler (and not merely that bits came back). +class DualReadDevice : public ModbusServerDevice { + public: + explicit DualReadDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->coil_reads++; + bits.set(0, true); // pattern 0x01 + return std::nullopt; + } + ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) override { + this->discrete_reads++; + bits.set(1, true); // pattern 0x02 + return std::nullopt; + } + + int coil_reads{0}; + int discrete_reads{0}; +}; + +// Overrides only on_read_bits() - the shared fallback the header documents that on_read_coils() and +// on_read_discrete_inputs() default to. Both FC 0x01 and FC 0x02 must reach it. +class BitsOnlyDevice : public ModbusServerDevice { + public: + explicit BitsOnlyDevice(uint8_t address) { this->set_address(address); } + ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) override { + this->calls++; + bits.set(0, true); // set bit 0 so the response proves the fallback ran + return std::nullopt; + } + int calls{0}; +}; + +using testing::RecordingUART; + +// Exposes the client-frame parser so a fully CRC-framed request can be pushed through the hub. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + void prime_send_timestamps_for_test() { + uint32_t now = millis(); + this->last_modbus_byte_ = now; + this->last_send_ = now; + } + + bool process_full_client_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + return this->parse_modbus_client_frame_(); + } +}; + +struct CoilFixture { + CoilFixture() { + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + hub.register_device(&device); + } + TestServerHub hub; + RecordingUART uart; + CoilDevice device{0x02}; +}; + +} // namespace + +// A coil read returns byte count + packed bits, set by the handler directly in the response buffer. +TEST(ModbusServerCoils, ReadCoilsReturnsPackedBits) { + CoilFixture f; + f.device.coils[0] = true; + f.device.coils[2] = true; + f.device.coils[3] = true; + f.device.coils[9] = true; + + // FC 0x01: start 0x0000, quantity 10 -> 2 packed bytes + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 1); + // Response: address(1) + fc(1) + byte count(1) + packed(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 7u); + EXPECT_EQ(f.uart.written[0], 0x02); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(f.uart.written[2], 2u); // byte count + EXPECT_EQ(f.uart.written[3], 0x0D); // coils 0,2,3 + EXPECT_EQ(f.uart.written[4], 0x02); // coil 9 -> bit 1 of byte 1 +} + +// A device overriding only on_read_bits() - the documented fallback - still serves both FC 0x01 (coils) +// and FC 0x02 (discrete inputs), since on_read_coils()/on_read_discrete_inputs() default to it. +TEST(ModbusServerCoils, ReadBitsFallbackServesBothCoilsAndDiscreteInputs) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + BitsOnlyDevice device{0x05}; + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x01}; // start 0x0000, quantity 1 + + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.calls, 1); + // address(1) + fc(1) + byte count(1) + packed(1) + CRC(2); bit 0 set -> 0x01 + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(uart.written[3], 0x01); + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + EXPECT_EQ(device.calls, 2); + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x01); +} + +// A multiple-coil write hands the handler the packed wire bytes and echoes the request header. +TEST(ModbusServerCoils, WriteMultipleCoilsAppliesPackedBits) { + CoilFixture f; + + // FC 0x0F: start 0x0000, quantity 10, byte count 2, packed values 0x0D 0x02 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x02, 0x0D, 0x02}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 1); + EXPECT_EQ(f.device.last_write_count, 10u); + EXPECT_TRUE(f.device.coils[0]); + EXPECT_FALSE(f.device.coils[1]); + EXPECT_TRUE(f.device.coils[2]); + EXPECT_TRUE(f.device.coils[3]); + EXPECT_TRUE(f.device.coils[9]); + EXPECT_FALSE(f.device.coils[10]); + // Response echoes start address + quantity: address(1) + fc(1) + start(2) + quantity(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 8u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS)); +} + +// A single-coil write (FC 0x05) is normalized to a one-bit packed buffer. +TEST(ModbusServerCoils, WriteSingleCoilNormalizedToOneBit) { + CoilFixture f; + + const uint8_t pdu_on[] = {0x00, 0x03, 0xFF, 0x00}; // coil 3 ON + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_on, sizeof(pdu_on))); + EXPECT_EQ(f.device.last_write_count, 1u); + EXPECT_TRUE(f.device.coils[3]); + + f.uart.written.clear(); + f.hub.prime_send_timestamps_for_test(); + const uint8_t pdu_off[] = {0x00, 0x03, 0x00, 0x00}; // coil 3 OFF + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_off, sizeof(pdu_off))); + EXPECT_FALSE(f.device.coils[3]); + EXPECT_EQ(f.device.write_count, 2); +} + +// An invalid single-coil value (not 0xFF00/0x0000) is rejected with ILLEGAL_DATA_VALUE, no write. +TEST(ModbusServerCoils, InvalidSingleCoilValueRejected) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x03, 0x12, 0x34}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// Read quantity validation lives in the shared read-request parser, so the register and bit reads cannot +// drift apart. These pin both ends of the range for coils; the register case below pins that the same +// parser is on that path too. +TEST(ModbusServerCoils, ZeroCoilReadQuantityRejected) { + CoilFixture f; + + // FC 0x01: start 0x0000, quantity 0 - a read of nothing is out of spec. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +TEST(ModbusServerCoils, OverLimitCoilReadQuantityRejected) { + CoilFixture f; + + // One past MAX_NUM_OF_COILS_TO_READ (2000 = 0x07D0), which no frame could carry anyway. + const uint8_t pdu_data[] = {0x00, 0x00, 0x07, 0xD1}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// The register read path shares that parser, so a zero quantity is rejected there identically. Lives +// beside the coil cases deliberately: together they are what stops the shared parser being bypassed on +// one side without the other noticing. +TEST(ModbusServerCoils, ZeroRegisterReadQuantityRejectedByTheSameParser) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_HOLDING_REGISTERS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_HOLDING_REGISTERS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// A device without bit handlers rejects coil requests with ILLEGAL_FUNCTION via the defaults. +TEST(ModbusServerCoils, UnhandledCoilReadIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// The view contracts are enforced, not merely documented: bytes() returns exactly ceil(size()/8) bytes +// even over a larger buffer (forwarding it can never leak trailing buffer content), and set() drops +// out-of-range bits instead of writing past the span (on the server read path that span wraps a stack +// response buffer). +TEST(ModbusServerCoils, PackedBitsViewContractsEnforced) { + uint8_t buf[8] = {}; + PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer + EXPECT_EQ(view.bytes().size(), 2u); + + PackedBits short_view(std::span(buf, 1), 10); // contract-violating: 10 bits over 1 byte + EXPECT_EQ(short_view.bytes().size(), 1u); // clamped to the real span, not a fabricated 2-byte span + + MutablePackedBits bits(std::span(buf, 2), 10); + bits.set(9, true); // in range: lands in byte 1 + bits.set(10, true); // out of range: dropped + bits.set(300, true); // far out of range: dropped, no write past the span + EXPECT_EQ(buf[1], 0x02); + for (size_t i = 2; i < sizeof(buf); i++) + EXPECT_EQ(buf[i], 0) << "byte " << i; +} + +// FC 0x02 must dispatch to on_read_discrete_inputs, not on_read_coils: the two handlers fill different +// patterns, so a swapped dispatch would fail on both the counters and the wire bytes. +TEST(ModbusServerCoils, ReadDiscreteInputsDispatchesToItsOwnHandler) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + DualReadDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.discrete_reads, 1); + EXPECT_EQ(device.coil_reads, 0); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x02); // the discrete handler's pattern, not the coil handler's + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.coil_reads, 1); + EXPECT_EQ(device.discrete_reads, 1); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[3], 0x01); +} + +// The write-side ILLEGAL_FUNCTION defaults: a device without bit handlers rejects coil writes too +// (single and multiple), mirroring the read-side default already covered above. +TEST(ModbusServerCoils, UnhandledCoilWriteIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t single[] = {0x00, 0x03, 0xFF, 0x00}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + single, sizeof(single))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); + + uart.written.clear(); + const uint8_t multiple[] = {0x00, 0x00, 0x00, 0x08, 0x01, 0xAA}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + multiple, sizeof(multiple))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// FC 0x0F with a byte count that does not match ceil(quantity / 8) is ILLEGAL_DATA_VALUE and never +// reaches the handler. +TEST(ModbusServerCoils, WriteCoilsByteCountMismatchRejected) { + CoilFixture f; + + // quantity 10 needs 2 bytes; claim 1 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x01, 0xFF}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); + EXPECT_EQ(f.device.write_count, 0); +} + +// A coil range that runs past address 0xFFFF is ILLEGAL_DATA_ADDRESS and never reaches the handler. +TEST(ModbusServerCoils, CoilAddressRangeOverflowRejected) { + CoilFixture f; + + // start 0xFFF8, quantity 16 -> 0x10008 > 0x10000 + const uint8_t pdu_data[] = {0xFF, 0xF8, 0x00, 0x10}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); + EXPECT_EQ(f.device.read_count, 0); +} + +} // namespace esphome::modbus diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml new file mode 100644 index 0000000000..bce2149fbf --- /dev/null +++ b/tests/components/modbus_client/common.yaml @@ -0,0 +1,148 @@ +# The modbus_client actions are self-contained hub devices: each takes the hub (auto-resolved when there +# is a single modbus client hub) and a templatable device address; no component block is needed. The +# address is not passed back to reply handlers - recompute the configured expression if needed. +# The hub does not bound retries, so a retry lambda must (here: a counter capped at 3), or a dead +# device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) +# so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on +# every retry, so the cap would never be reached. + +# The standalone component: a bare hub device with nothing but an address, so a lambda can drive the +# device directly. Two entries cover both hub-binding paths - the auto-resolved single hub and an +# explicit modbus_id - and the button below calls them, so the generated device has to be usable +# rather than merely constructed (an unreferenced one is optimised away entirely). +modbus_client: + - id: bare_client + address: 0x01 + - id: bare_client_explicit_hub + modbus_id: modbus_bus + address: 0x02 + +globals: + - id: read_retries + type: int + initial_value: "0" + - id: combined_retries + type: int + initial_value: "0" + +button: + # The bare modbus_client devices: no handlers, so nothing reports the outcome - see the component + # comment. Calls here only pin that the device is bound to its hub and the helpers are reachable. + - platform: template + name: "Bare Client" + on_press: + - lambda: |- + id(bare_client).write_single_register(0x10, 42); + id(bare_client).write_single_coil(0x01, true); + id(bare_client_explicit_hub).read_holding_registers(0x20, 4); + const uint16_t rw_vals[] = {1, 2}; + id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals); + - platform: template + name: "Send Read" + on_press: + - lambda: "id(read_retries) = 0;" + - modbus_client.send: + address: 0x01 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + # on_no_response lambda form: return true to retry. `request` is the timed-out PDU. + on_no_response: !lambda "return !request.empty() && request[0] == 0x03 && id(read_retries)++ < 3;" + # Per-send inline reply handlers (fire-and-continue): they run when this send's outcome is known; + # the targeted address is not passed back - recompute the configured expression if needed. + # A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result. + - modbus_client.send: + address: 0x01 + pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);" + - modbus_client.send: + address: !lambda "return 1;" + pdu: !lambda "return {0x03, 0x00, 0x10, 0x00, 0x01};" + on_sent: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "sent fc 0x%X", request.empty() ? 0 : request[0]);' + on_response: + then: + - lambda: |- + id(combined_retries) = 0; + ESP_LOGI("modbus_client.test", "got %d bytes", (int) response.size()); + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + # on_no_response combined form: run actions on timeout AND decide the retry via nested retry:. + on_no_response: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "no reply for fc 0x%X", request.empty() ? 0 : request[0]);' + retry: !lambda "return !request.empty() && request[0] == 0x03 && id(combined_retries)++ < 3;" + on_not_sent: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);' + - platform: template + name: "Typed Actions" + on_press: + - modbus_client.write_single_register: + address: 0x01 + start_address: 0x0102 + value: !lambda "return 42;" + on_response: + then: + - logger.log: "write acked" + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "write exception %d", (int) exception_code);' + - modbus_client.read_holding_registers: + address: !lambda "return 1;" + start_address: 0x10 + count: 2 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' + on_no_response: + then: + - logger.log: "typed read timeout" + - modbus_client.read_input_registers: + address: 0x01 + start_address: 0x20 + on_custom_response: + then: + - lambda: |- + ESP_LOGW("modbus_client.test", "non-standard reply: fc 0x%02X, %u byte request", + response.empty() ? 0 : response[0], (unsigned) request.size()); + - modbus_client.write_single_coil: + address: 0x01 + start_address: 0x01 + value: true + - modbus_client.read_coils: + address: 0x01 + start_address: 0x03 + count: 16 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());' + - modbus_client.read_discrete_inputs: + address: 0x01 + start_address: 0x00 + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0200 + values: !lambda "return {1, 2, 3};" + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "multi write acked");' + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0010 + values: [true, false, true] + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0300 + values: !lambda "return {1, 2};" + read_address: 0x0400 + read_count: 2 + on_response: + then: + # `values` here is the READ-BACK block, not the written block above + - lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/test.esp32-idf.yaml b/tests/components/modbus_client/test.esp32-idf.yaml new file mode 100644 index 0000000000..b5882e90d8 --- /dev/null +++ b/tests/components/modbus_client/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.esp8266-ard.yaml b/tests/components/modbus_client/test.esp8266-ard.yaml new file mode 100644 index 0000000000..151922b0d5 --- /dev/null +++ b/tests/components/modbus_client/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/test.rp2040-ard.yaml b/tests/components/modbus_client/test.rp2040-ard.yaml new file mode 100644 index 0000000000..aaf115ae45 --- /dev/null +++ b/tests/components/modbus_client/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + modbus_client: !include common.yaml diff --git a/tests/components/modbus_client/validate-autoload.esp32-idf.yaml b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml new file mode 100644 index 0000000000..318d492717 --- /dev/null +++ b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml @@ -0,0 +1,16 @@ +# The modbus hub auto-loads modbus_client so the modbus_client.* actions are registered. That must not +# create a device on its own, which is what MULTI_CONF_NO_DEFAULT in the component buys: without it the +# auto-load builds a default entry and fails on the required address, breaking every modbus config. +# So this file deliberately declares no modbus_client: block - it is the no-block path, kept as its own +# fixture because common.yaml now declares one. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: "Action without a component block" + on_press: + - modbus_client.read_holding_registers: + address: 0x01 + start_address: 0x10 + count: 1 diff --git a/tests/components/modbus_controller/command_payload_test.cpp b/tests/components/modbus_controller/command_payload_test.cpp new file mode 100644 index 0000000000..c125a44da5 --- /dev/null +++ b/tests/components/modbus_controller/command_payload_test.cpp @@ -0,0 +1,31 @@ +#include + +#include +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum +// because a fixed pack buffer sized for the maximum would silently truncate there while the quantity +// field still claimed every coil - and the truncated frame would fit the RTU limit and go on the wire +// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with +// a log instead. +TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) { + ModbusController controller; + std::vector coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true); + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size())); +} + +// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce. +TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) { + ModbusController controller; + const std::vector coils{true, false, true, true}; + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + ASSERT_EQ(cmd.payload.size(), 1u); + EXPECT_EQ(cmd.payload.data()[0], 0b00001101); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index 51951a4528..67b022cdf5 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -18,7 +18,7 @@ binary_sensor: modbus_controller_id: modbus_controller1 id: modbus_binary_sensor2 name: Test Binary Sensor with Lambda - register_type: read + register_type: input address: 0x3201 lambda: |- return x; @@ -41,6 +41,13 @@ number: return x * 2.0; write_lambda: |- return x / 2.0; + # Covers Python value-type maps + read/write path for byte-swapped words + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_number3 + name: Test Number Swapped Word + address: 0x9003 + value_type: U_WORD_S output: - platform: modbus_controller @@ -118,6 +125,61 @@ sensor: value_type: U_WORD lambda: |- return x / 10.0; + # Non-mergeable sensor sharing the start address of modbus_sensor1 (different register_count): + # must join the same range, never open a second range keyed on the same (address, type). + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_addr + name: Test Sensor Shared Address + register_type: holding + address: 0x9001 + value_type: U_DWORD + # Sensors sharing one start address with distinct byte offsets (mixed register counts, so they take + # the shared-start path: each resolves to exactly its configured offset, no accumulation). + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs0 + name: Test Sensor Shared Offset Base + register_type: holding + address: 0x9020 + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs1 + name: Test Sensor Shared Offset Low Word + register_type: holding + address: 0x9020 + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_shared_offs2 + name: Test Sensor Shared Offset High Word + register_type: holding + address: 0x9020 + value_type: U_WORD + offset: 2 + # Raw-decode lambda kept on the deprecated get_data() helper on purpose: `data` is a span now, so this + # pins that the compatibility overload still accepts one. The deprecation warning it raises is the + # point - it is what a user on the old helper sees. `item->offset` locates this sensor's data. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_raw_lambda + name: Test Sensor Raw Lambda + register_type: holding + address: 0x9050 + value_type: U_WORD + lambda: |- + return modbus_controller::get_data(data, item->offset) * 0.1f; + # force_new_range sensors sort before plain ones, so this high-address forced sensor is grouped + # first and the lower-address plain sensors above must still get their own ranges. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_sensor_forced_high + name: Test Sensor Forced High Address + register_type: holding + address: 0x9040 + value_type: U_WORD + force_new_range: true switch: - platform: modbus_controller @@ -158,3 +220,22 @@ text_sensor: response_size: 4 lambda: |- return "Modified: " + x; + # A register reporting FEWER bytes than 2*register_count (response_size: 3 for 2 registers), followed + # by a contiguous sensor: the follower's byte position must track the actual 3 bytes, not underflow. + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_text_sensor_narrow + name: Test Text Sensor Narrow Response + register_type: holding + address: 0x9030 + register_count: 2 + response_size: 3 + raw_encode: HEXBYTES + - platform: modbus_controller + modbus_controller_id: modbus_controller1 + id: modbus_text_sensor_after_narrow + name: Test Text Sensor After Narrow + register_type: holding + address: 0x9032 + register_count: 1 + raw_encode: HEXBYTES diff --git a/tests/components/modbus_controller/offline_cadence_test.cpp b/tests/components/modbus_controller/offline_cadence_test.cpp new file mode 100644 index 0000000000..1dade5dd98 --- /dev/null +++ b/tests/components/modbus_controller/offline_cadence_test.cpp @@ -0,0 +1,56 @@ +#include + +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// A probe must come due exactly once per offline_skip_updates + 1 cycles from the trip point, +// for every phase between the trip cycle and the update counter. Pins the regression where a +// probe additionally required a range's skip_updates cadence to coincide, which some phase +// combinations never satisfy - the device then never polled again. +TEST(OfflineRetryCadence, DueOncePerWindowForEveryPhase) { + for (uint16_t skip = 0; skip <= 5; skip++) { + const uint16_t period = skip + 1; + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t due_count = 0; + for (uint32_t counter = offline_at; counter < offline_at + 4u * period; counter++) { + if (offline_retry_due(static_cast(counter), offline_at, skip)) + due_count++; + } + EXPECT_EQ(due_count, 4) << "skip=" << skip << " offline_at=" << offline_at; + } + } +} + +// The first probe goes out within one window of going offline: after at most skip skipped cycles. +TEST(OfflineRetryCadence, FirstProbeWithinOneWindow) { + for (uint16_t skip = 0; skip <= 5; skip++) { + for (uint16_t offline_at = 0; offline_at <= 7; offline_at++) { + uint16_t counter = offline_at; + uint16_t skipped = 0; + while (!offline_retry_due(counter, offline_at, skip)) { + counter++; + skipped++; + ASSERT_LE(skipped, skip) << "skip=" << skip << " offline_at=" << offline_at; + } + } + } +} + +// The cadence neither stretches nor collapses when update_counter_ wraps past 65535. +TEST(OfflineRetryCadence, SurvivesCounterWraparound) { + const uint16_t skip = 2; // period 3 + const uint16_t offline_at = 65530; + uint16_t counter = offline_at; + uint16_t due_count = 0; + for (int i = 0; i < 30; i++) { // crosses the wrap mid-run + if (offline_retry_due(counter, offline_at, skip)) + due_count++; + counter++; + } + EXPECT_EQ(due_count, 10); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/components/modbus_controller/sensor_item_position_test.cpp b/tests/components/modbus_controller/sensor_item_position_test.cpp new file mode 100644 index 0000000000..2fb679ee07 --- /dev/null +++ b/tests/components/modbus_controller/sensor_item_position_test.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +namespace { + +// Minimal concrete SensorItem so the position/address accessors can be exercised directly. +class TestSensorItem : public SensorItem { + public: + void parse_and_publish(std::span /*data*/) override {} +}; + +// Builds an item the way a platform constructor does, before ranges are built. +TestSensorItem make_item(modbus::EntityType type, uint16_t address, uint8_t offset) { + TestSensorItem item; + item.register_type = type; + item.set_address(address); + item.set_offset_from_start_address(offset); + return item; +} + +} // namespace + +// A freshly constructed item is already usable: its resolved position is the offset as configured and +// its range base is its own address, which is what an item that never gets polled relies on. +TEST(SensorItemPosition, ConstructionSeedsResolvedPositionAndRangeBase) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9001, 4); + EXPECT_EQ(item.offset_from_start_address, 4); + EXPECT_EQ(item.offset, 4); + EXPECT_EQ(item.range_start_address, 0x9001); +} + +// A write lands on the register the sensor reads from. The resolved position is relative to the range's +// first register, which may be earlier than the sensor's own address, so both are needed to get there. +TEST(SensorItemPosition, WriteAddressForRegisters) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9003, 0); + item.range_start_address = 0x9001; + item.offset = 4; + EXPECT_EQ(item.write_address(), 0x9003); +} + +// Coils index bits, so the resolved offset is a bit count and is added to the range base directly. +TEST(SensorItemPosition, WriteAddressForCoils) { + auto item = make_item(modbus::EntityType::COIL, 0x15, 0); + item.range_start_address = 0x10; + item.offset = 5; + EXPECT_EQ(item.write_address(), 0x15); + EXPECT_TRUE(item.addresses_bits()); +} + +// An item that is never polled keeps the range base its constructor set, so its write address is still +// its own address plus its configured offset - a switch with assumed_state, or an output. +TEST(SensorItemPosition, WriteAddressWithoutAGroupedRange) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9010, 2); + EXPECT_EQ(item.write_address(), 0x9011); +} + +// A sensor re-using a register after one with a non-zero offset resolves past that offset, and its +// write address follows the same position - the behaviour releases before the range rework had. +TEST(SensorItemPosition, ReUseChainWriteAddressFollowsResolvedPosition) { + auto item = make_item(modbus::EntityType::HOLDING, 0x9001, 4); + item.range_start_address = 0x9001; + item.offset = 6; // 4 configured, plus the 2 the previous sensor on this register resolved to + EXPECT_EQ(item.write_address(), 0x9004); +} + +// Registers address 16-bit words; only coils and discrete inputs address bits. +TEST(SensorItemPosition, AddressesBitsOnlyForCoilAndDiscreteInput) { + EXPECT_FALSE(make_item(modbus::EntityType::HOLDING, 0, 0).addresses_bits()); + EXPECT_FALSE(make_item(modbus::EntityType::INPUT_REGISTER, 0, 0).addresses_bits()); + EXPECT_TRUE(make_item(modbus::EntityType::COIL, 0, 0).addresses_bits()); + EXPECT_TRUE(make_item(modbus::EntityType::DISCRETE_INPUT, 0, 0).addresses_bits()); +} + +// A span payload reaches payload_to_number() unqualified from inside this namespace: SensorValueType +// lives in modbus::helpers, so argument-dependent lookup finds the helper. Declaring a same-signature +// forwarder here would make the call ambiguous rather than convenient, which is why none exists. +TEST(SensorItemPosition, UnqualifiedPayloadToNumberResolvesToTheHelper) { + const uint8_t bytes[] = {0x01, 0x02}; + auto value = payload_to_number(std::span(bytes), SensorValueType::U_WORD, 0, 0xFFFFFFFF); + EXPECT_EQ(value, 0x0102); +} + +} // namespace esphome::modbus_controller::testing diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 8b2316b6e3..3f84a3f6da 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -15,6 +15,16 @@ modbus_server: - id: modbus_server3 address: 0x3 modbus_id: mod_bus2 + bits: + - address: 0x0 + read_lambda: |- + return true; + - address: 0x1 + read_lambda: |- + return address == 0x1; + write_lambda: |- + printf("bit address=%d, value=%d\n", (int) address, (int) x); + return true; registers: - address: 0x9 value_type: S_DWORD @@ -40,3 +50,11 @@ modbus_server: value_type: U_WORD read_lambda: |- return (random_uint32() % 100); + # Covers CPP_TYPE_REGISTER_MAP / signed byte-swapped codegen + - address: 0x6 + value_type: S_WORD_S + read_lambda: |- + return -2; + write_lambda: |- + printf("address=%d, value=%d\n", (int) address, (int) x); + return true; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index d95bb473c9..ce39e83736 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_server { -using modbus::ModbusExceptionCode; +using modbus::ExceptionCode; using modbus::RegisterValues; namespace { @@ -34,6 +34,21 @@ TEST(ModbusServerWrite, SingleWordSucceeds) { EXPECT_EQ(written, 0x1234); } +TEST(ModbusServerWrite, SwappedWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD_S, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_write_registers(0x0000, make_registers({0x3412})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x1234); +} + // A multi-register value is decoded high word first and applied as a single number. TEST(ModbusServerWrite, DwordSucceeds) { ModbusServer server; @@ -73,7 +88,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_VALUE); EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied EXPECT_FALSE(dword_written); } @@ -87,16 +102,30 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { auto status = server.on_write_registers(0x0000, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } -// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +// A write to an address not covered by any configured register (on a populated server) yields +// ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [](int64_t) { return true; }; + server.add_server_register(®); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION. +TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); } // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already @@ -117,7 +146,7 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_EQ(status.value(), ExceptionCode::SERVICE_DEVICE_FAILURE); EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } @@ -136,6 +165,19 @@ TEST(ModbusServerRead, SingleWordSucceeds) { EXPECT_EQ(out[0], 0x1234); } +TEST(ModbusServerRead, SwappedWordReturnsByteSwappedRegister) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD_S, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x3412); +} + TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { ModbusServer server; ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); @@ -168,7 +210,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) { auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); EXPECT_FALSE(read_called); } @@ -187,7 +229,7 @@ TEST(ModbusServerRead, ClippedTailRejected) { auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); EXPECT_FALSE(read_called); } @@ -203,7 +245,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) { auto status = server.on_read_registers(0x0000, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } // An unregistered address with courtesy enabled returns the default value for each cell. @@ -220,14 +262,43 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { EXPECT_EQ(out[1], 0xABCD); } -// An unregistered address with courtesy disabled is rejected. +// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS. TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A server with no registers configured (courtesy disabled) does not implement the register-read +// function: ILLEGAL_FUNCTION. +TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) { ModbusServer server; RegisterValues out; auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) - EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + +// A register read lambda returning an empty optional declines the read: the whole request is +// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda so the optional-forwarding wrapper +// (not a hand-assigned read_lambda) is what carries the decline through. +TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.set_read_lambda([](uint16_t address) -> optional { return {}; }); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); } // --- partial reads (opt-in) ---------------------------------------------------- @@ -282,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) { EXPECT_EQ(second[0], 0x1234); } +// --- bits (coils / discrete inputs, one shared address space) ------------------- + +// Bits are read through the shared table regardless of which read function code arrived: +// the hub routes both 0x01 and 0x02 to on_read_bits(). +TEST(ModbusServerBits, ReadSetsRequestedBits) { + ModbusServer server; + ServerBit bit0(0x0000); + bit0.set_read_lambda([](uint16_t) { return true; }); + ServerBit bit1(0x0001); + bit1.set_read_lambda([](uint16_t) { return false; }); + ServerBit bit2(0x0002); + bit2.set_read_lambda([](uint16_t) { return true; }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + server.add_server_bit(&bit2); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0b101); +} + +// The read lambda receives the bit's address, so one lambda can serve several bits. +TEST(ModbusServerBits, ReadLambdaReceivesAddress) { + ModbusServer server; + ServerBit server_bit(0x0007); + server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; }); + server.add_server_bit(&server_bit); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0x01); +} + +// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerBits, UnreadableBitRejectsRead) { + ModbusServer server; + ServerBit readable(0x0000); + readable.set_read_lambda([](uint16_t) { return true; }); + ServerBit write_only(0x0001); + write_only.set_write_lambda([](uint16_t, bool) { return true; }); + server.add_server_bit(&readable); + server.add_server_bit(&write_only); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1)); + EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A read lambda returning an empty optional declines the read: the whole request is answered +// with SERVICE_DEVICE_FAILURE. +TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerBit ok(0x0000); + ok.set_read_lambda([](uint16_t) { return true; }); + ServerBit declining(0x0001); + declining.set_read_lambda([](uint16_t) -> optional { return {}; }); + server.add_server_bit(&ok); + server.add_server_bit(&declining); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + +// A multi-coil write applies every bit and reports success. +TEST(ModbusServerBits, WriteAppliesAllBits) { + ModbusServer server; + bool state[2] = {false, true}; + ServerBit bit0(0x0000); + bit0.set_write_lambda([&state](uint16_t, bool value) { + state[0] = value; + return true; + }); + ServerBit bit1(0x0001); + bit1.set_write_lambda([&state](uint16_t, bool value) { + state[1] = value; + return true; + }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + + const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_FALSE(status.has_value()); + EXPECT_TRUE(state[0]); + EXPECT_FALSE(state[1]); +} + +// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any +// bit is applied. +TEST(ModbusServerBits, UnwritableBitAppliesNothing) { + ModbusServer server; + bool written = false; + ServerBit writable(0x0000); + writable.set_write_lambda([&written](uint16_t, bool) { + written = true; + return true; + }); + ServerBit read_only(0x0001); + read_only.set_read_lambda([](uint16_t) { return false; }); + server.add_server_bit(&writable); + server.add_server_bit(&read_only); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(written); // the writable bit must NOT have been applied +} + +// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and +// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior). +TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerBit first(0x0000); + first.set_write_lambda([&first_written](uint16_t, bool) { + first_written = true; + return true; + }); + ServerBit second(0x0001); + second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime + server.add_server_bit(&first); + server.add_server_bit(&second); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); +} + } // namespace esphome::modbus_server diff --git a/tests/components/mopeka_ble/common-ln.yaml b/tests/components/mopeka_ble/common-ln.yaml new file mode 100644 index 0000000000..14df729405 --- /dev/null +++ b/tests/components/mopeka_ble/common-ln.yaml @@ -0,0 +1 @@ +mopeka_ble: diff --git a/tests/components/mopeka_ble/common.yaml b/tests/components/mopeka_ble/common.yaml index a115404f1c..d511a449f9 100644 --- a/tests/components/mopeka_ble/common.yaml +++ b/tests/components/mopeka_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. mopeka_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/mopeka_ble/test.ln882x-ard.yaml b/tests/components/mopeka_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8026866234 --- /dev/null +++ b/tests/components/mopeka_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_ble: !include common-ln.yaml diff --git a/tests/components/mopeka_ble/validate.bk72xx-ard.yaml b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..41fdf62d03 --- /dev/null +++ b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +mopeka_ble: + ble_hub_id: ble_hub diff --git a/tests/components/mopeka_pro_check/common-ln.yaml b/tests/components/mopeka_pro_check/common-ln.yaml new file mode 100644 index 0000000000..1e28e1e58d --- /dev/null +++ b/tests/components/mopeka_pro_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_pro_check + mac_address: D3:75:F2:DC:16:91 + tank_type: 20LB_V + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_pro_check/common.yaml b/tests/components/mopeka_pro_check/common.yaml index 3533ecf631..15eabe1f25 100644 --- a/tests/components/mopeka_pro_check/common.yaml +++ b/tests/components/mopeka_pro_check/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_pro_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: CUSTOM custom_distance_full: 40cm diff --git a/tests/components/mopeka_pro_check/test.ln882x-ard.yaml b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3484c7b5b3 --- /dev/null +++ b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_pro_check: !include common-ln.yaml diff --git a/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..888b879e07 --- /dev/null +++ b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_pro_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: 20lb_v + level: + name: BK Mopeka Pro Level diff --git a/tests/components/mopeka_std_check/common-ln.yaml b/tests/components/mopeka_std_check/common-ln.yaml new file mode 100644 index 0000000000..0b645dcaf3 --- /dev/null +++ b/tests/components/mopeka_std_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_std_check/common.yaml b/tests/components/mopeka_std_check/common.yaml index 383e2e2a19..e7224ba725 100644 --- a/tests/components/mopeka_std_check/common.yaml +++ b/tests/components/mopeka_std_check/common.yaml @@ -1,8 +1,11 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: # Example using 11kg 100% propane tank. + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_std_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: Europe_11kg temperature: diff --git a/tests/components/mopeka_std_check/test.ln882x-ard.yaml b/tests/components/mopeka_std_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..11a54cb37c --- /dev/null +++ b/tests/components/mopeka_std_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_std_check: !include common-ln.yaml diff --git a/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..86b4425518 --- /dev/null +++ b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml @@ -0,0 +1,19 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_std_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + level: + name: BK Mopeka Std Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:92 + tank_type: Europe_11kg + level: + name: BK Propane implicit level diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py new file mode 100644 index 0000000000..101f63ac82 --- /dev/null +++ b/tests/components/network/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NETWORK_IPV6", True) + + manifest.to_code = to_code_testing diff --git a/tests/components/network/test-priority.esp32-ard.yaml b/tests/components/network/test-priority.esp32-ard.yaml new file mode 100644 index 0000000000..a04246a128 --- /dev/null +++ b/tests/components/network/test-priority.esp32-ard.yaml @@ -0,0 +1,23 @@ +# Arduino dual-stack test: default-route arbitration must also compile under +# the Arduino framework, which builds the same esp_netif/ESP-IDF from source. +# Ethernet is listed first so this build exercises the ethernet-first side of +# the arbitration pivot in NetworkComponent::loop() (the IDF variant of this +# test covers the wifi-first side). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/network/test-priority.esp32-idf.yaml b/tests/components/network/test-priority.esp32-idf.yaml new file mode 100644 index 0000000000..baa821a234 --- /dev/null +++ b/tests/components/network/test-priority.esp32-idf.yaml @@ -0,0 +1,23 @@ +# Compiled dual-stack test: wifi + ethernet coexisting via network: priority:. +# This is the first build path that keeps both radios' stacks compiled in, so +# it must actually compile (not just validate) to guard the reconciler wiring. +# WiFi is listed first so the build also exercises the wifi-primary branch in +# network/util.cpp (the ethernet-primary branch matches the legacy order). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - wifi + - ethernet diff --git a/tests/components/network/test_ip_address_host.cpp b/tests/components/network/test_ip_address_host.cpp new file mode 100644 index 0000000000..4070f422b1 --- /dev/null +++ b/tests/components/network/test_ip_address_host.cpp @@ -0,0 +1,208 @@ +#include + +#include "esphome/components/network/ip_address.h" + +#ifdef USE_HOST +#if USE_NETWORK_IPV6 + +namespace esphome::network::testing { + +// ========================================================================= +// IPv4 +// ========================================================================= + +TEST(IPAddressHost, IPv4DefaultNotSet) { + IPAddress addr; + EXPECT_FALSE(addr.is_set()); +} + +TEST(IPAddressHost, IPv4DefaultIsIPv4) { + IPAddress addr; + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +TEST(IPAddressHost, IPv4ParseAndSerialize) { + IPAddress addr("192.168.1.1"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); +} + +TEST(IPAddressHost, IPv4FromOctets) { + IPAddress addr(192, 168, 1, 1); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); +} + +TEST(IPAddressHost, IPv4IsSet) { + IPAddress addr("192.168.1.1"); + EXPECT_TRUE(addr.is_set()); +} + +TEST(IPAddressHost, IPv4IsIp4) { + IPAddress addr("192.168.1.1"); + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +TEST(IPAddressHost, IPv4MulticastDetected) { + IPAddress addr("239.0.60.53"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4MulticastBoundaryLow) { + IPAddress addr("224.0.0.0"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4MulticastBoundaryHigh) { + IPAddress addr("239.255.255.255"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4UnicastNotMulticast) { + IPAddress addr("192.168.1.1"); + EXPECT_FALSE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv4EqualityMatch) { + IPAddress a("192.168.1.1"); + IPAddress b("192.168.1.1"); + EXPECT_EQ(a, b); +} + +TEST(IPAddressHost, IPv4EqualityMismatch) { + IPAddress a("192.168.1.1"); + IPAddress b("192.168.1.2"); + EXPECT_NE(a, b); +} + +TEST(IPAddressHost, IPv4FromOctetsMatchesParse) { + IPAddress from_octets(192, 168, 1, 1); + IPAddress from_string("192.168.1.1"); + EXPECT_EQ(from_octets, from_string); +} + +TEST(IPAddressHost, IPv4FromIPAddrT) { + ip_addr_t raw; + memset(&raw, 0, sizeof(raw)); + raw.u_addr.ip4.s_addr = htonl((192u << 24) | (168u << 16) | (1u << 8) | 1u); + raw.type = IPADDR_TYPE_V4; + IPAddress addr(&raw); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "192.168.1.1"); + EXPECT_TRUE(addr.is_ip4()); + EXPECT_FALSE(addr.is_ip6()); +} + +// ========================================================================= +// IPv6 +// ========================================================================= + +TEST(IPAddressHost, IPv6ParseAndSerialize) { + IPAddress addr("ff12::cafe"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "ff12::cafe"); +} + +TEST(IPAddressHost, IPv6Loopback) { + IPAddress addr("::1"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + EXPECT_STREQ(addr.str_to(buf), "::1"); +} + +TEST(IPAddressHost, IPv6IsIp6) { + IPAddress addr("ff12::cafe"); + EXPECT_TRUE(addr.is_ip6()); + EXPECT_FALSE(addr.is_ip4()); +} + +TEST(IPAddressHost, IPv6AllZerosNotSet) { + IPAddress addr("::"); + EXPECT_FALSE(addr.is_set()); +} + +TEST(IPAddressHost, IPv6LoopbackIsSet) { + IPAddress addr("::1"); + EXPECT_TRUE(addr.is_set()); +} + +TEST(IPAddressHost, IPv6MulticastDetected) { + IPAddress addr("ff12::cafe"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6MulticastLinkLocal) { + IPAddress addr("ff02::1"); + EXPECT_TRUE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6UnicastNotMulticast) { + IPAddress addr("::1"); + EXPECT_FALSE(addr.is_multicast()); +} + +TEST(IPAddressHost, IPv6EqualityMatch) { + IPAddress a("ff12::cafe"); + IPAddress b("ff12::cafe"); + EXPECT_EQ(a, b); +} + +TEST(IPAddressHost, IPv6EqualityMismatch) { + IPAddress a("ff12::cafe"); + IPAddress b("ff02::1"); + EXPECT_NE(a, b); +} + +TEST(IPAddressHost, IPv6OutputIsLowercase) { + // inet_pton is case-insensitive; str_to must lowercase the output + IPAddress addr("FF12::CAFE"); + char buf[IP_ADDRESS_BUFFER_SIZE]; + addr.str_to(buf); + for (const char *p = buf; *p; ++p) { + EXPECT_FALSE(*p >= 'A' && *p <= 'F') << "uppercase letter in: " << buf; + } +} + +TEST(IPAddressHost, IPv6FullAddressRoundTrip) { + // A full 128-bit address with no compression opportunity + const char *input = "fde0:983a:d0d3:a65e:725a:0fff:fe36:9916"; + IPAddress addr(input); + char buf[IP_ADDRESS_BUFFER_SIZE]; + addr.str_to(buf); + EXPECT_NE(buf[0], '\0'); + EXPECT_NE(std::string(buf).find("fde0"), std::string::npos); +} + +// ========================================================================= +// Malformed input +// ========================================================================= + +TEST(IPAddressHost, MalformedIPv4YieldsEmptyAddress) { + IPAddress addr("not-an-ip"); + EXPECT_FALSE(addr.is_set()); + EXPECT_TRUE(addr.is_ip4()); +} + +TEST(IPAddressHost, MalformedIPv6YieldsEmptyAddress) { + // "gg::1" looks like IPv6 (contains ':') but fails inet_pton; addr stays + // zeroed (type=V4 from memset) so is_set() is false and is_ip4() is true. + IPAddress addr("gg::1"); + EXPECT_FALSE(addr.is_set()); + EXPECT_TRUE(addr.is_ip4()); +} + +// ========================================================================= +// Cross-family +// ========================================================================= + +TEST(IPAddressHost, IPv4AndIPv6NotEqual) { + IPAddress v4("192.168.1.1"); + IPAddress v6("::1"); + EXPECT_NE(v4, v6); +} + +} // namespace esphome::network::testing + +#endif // USE_NETWORK_IPV6 +#endif // USE_HOST diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index ded0f17611..8c55546ce6 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -28,3 +28,32 @@ text_sensor: name: "PAN ID" ext_pan_id: name: "Extended PAN ID" + +sensor: + - platform: openthread_info + parent_average_rssi: + name: "Parent Average RSSI" + parent_last_rssi: + name: "Parent Last RSSI" + parent_link_quality_in: + name: "Parent Link Quality In" + parent_link_quality_out: + name: "Parent Link Quality Out" + tx_total: + name: "TX Total" + tx_retries: + name: "TX Retries" + tx_err_cca: + name: "TX CCA Errors" + tx_err_abort: + name: "TX Abort Errors" + rx_total: + name: "RX Total" + rx_err_fcs: + name: "RX FCS Errors" + attach_attempts: + name: "Attach Attempts" + parent_changes: + name: "Parent Changes" + partition_id_changes: + name: "Partition ID Changes" diff --git a/tests/components/ota/test.nrf52-adafruit.yaml b/tests/components/ota/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..e8ac96f051 --- /dev/null +++ b/tests/components/ota/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr diff --git a/tests/components/ota/test_backend_contract.cpp b/tests/components/ota/test_backend_contract.cpp new file mode 100644 index 0000000000..1b4fbbc32d --- /dev/null +++ b/tests/components/ota/test_backend_contract.cpp @@ -0,0 +1,49 @@ +// Pins the OTA backend contract concept so the surface it enforces cannot +// drift unnoticed: the build's real backend and a minimal conforming type +// must satisfy it, and a type missing a method or returning the wrong type +// must not. + +#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_host.h" + +namespace esphome::ota::testing { + +struct MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_OK; } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; } + OTAResponseTypes end() { return OTA_RESPONSE_OK; } + void abort() {} + bool supports_compression() { return false; } +}; +static_assert(OTABackendContract); + +// Each negative case derives from MinimalBackend and breaks exactly one +// requirement; the declaration in the derived struct hides the conforming +// one from the base. + +// begin() without the default ota_type argument breaks consumers that only +// pass the image size. +struct BackendWithoutDefaultOTAType : MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type) { return OTA_RESPONSE_OK; } +}; +static_assert(!OTABackendContract); + +struct BackendMissingAbort : MinimalBackend { + void abort() = delete; +}; +static_assert(!OTABackendContract); + +struct BackendWrongWriteReturn : MinimalBackend { + bool write(uint8_t *data, size_t len) { return true; } +}; +static_assert(!OTABackendContract); + +// Pin the build's real backend, not just local mocks: the unit test harness +// builds for the host platform, so this is the same check the factory's +// static_assert performs in a firmware compile. +#ifdef USE_HOST +static_assert(OTABackendContract); +#endif + +} // namespace esphome::ota::testing diff --git a/tests/components/ota/test_rsa_der.cpp b/tests/components/ota/test_rsa_der.cpp new file mode 100644 index 0000000000..aefce6769a --- /dev/null +++ b/tests/components/ota/test_rsa_der.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/ota/ota_rsa_der.h" + +namespace esphome::ota::testing { + +namespace { + +// A modulus with the top bit set, as every real 3072-bit modulus has. +std::array make_modulus(uint8_t first = 0xC5) { + std::array modulus{}; + modulus.fill(0xAB); + modulus[0] = first; + modulus[RSA_3072_MODULUS_BYTES - 1] = 0x01; // odd, like a real modulus + return modulus; +} + +} // namespace + +// e = 65537, the exponent espsecure uses. +TEST(RsaDerPublicKey, StandardExponent) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + // 4 (SEQUENCE header) + 389 (modulus) + 5 (exponent) = 398 + ASSERT_EQ(len, 398u); + // SEQUENCE, 2-byte length of the 394-byte body + EXPECT_EQ(der[0], 0x30); + EXPECT_EQ(der[1], 0x82); + EXPECT_EQ((der[2] << 8) | der[3], 394); + // INTEGER, 2-byte length 385, sign pad, then the modulus + EXPECT_EQ(der[4], 0x02); + EXPECT_EQ(der[5], 0x82); + EXPECT_EQ((der[6] << 8) | der[7], 385); + EXPECT_EQ(der[8], 0x00); + EXPECT_EQ(0, memcmp(der + 9, modulus.data(), modulus.size())); + // INTEGER, 3 bytes, leading zero of the input dropped + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x03); + EXPECT_EQ(der[exp_at + 2], 0x01); + EXPECT_EQ(der[exp_at + 3], 0x00); + EXPECT_EQ(der[exp_at + 4], 0x01); +} + +// An exponent whose top bit is set needs a 0x00 sign pad, widening the body. +TEST(RsaDerPublicKey, ExponentNeedingSignPad) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x81}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, 397u); // 4 + 389 + 4 + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x02); // pad + one value byte + EXPECT_EQ(der[exp_at + 2], 0x00); + EXPECT_EQ(der[exp_at + 3], 0x81); +} + +// The widest exponent still fits the documented buffer size. +TEST(RsaDerPublicKey, WidestExponentFitsBuffer) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, RSA_DER_PUBKEY_MAX); // 4 + 389 + 7 + EXPECT_LE(len, sizeof(der)); +} + +TEST(RsaDerPublicKey, ZeroExponentRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x00}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)), 0u); +} + +// A buffer that cannot hold the result must be refused, not overrun. Sized +// against a heap vector so ASAN catches a write past the end. +TEST(RsaDerPublicKey, ShortBufferRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + for (size_t out_len : {size_t(0), size_t(1), size_t(4), size_t(100), size_t(397)}) { + std::vector der(out_len); + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der.data(), out_len), 0u) + << "out_len=" << out_len; + } +} + +} // namespace esphome::ota::testing diff --git a/tests/components/packet_transport/common.yaml b/tests/components/packet_transport/common.yaml index 9151cf27dc..5c6c8dd636 100644 --- a/tests/components/packet_transport/common.yaml +++ b/tests/components/packet_transport/common.yaml @@ -7,36 +7,40 @@ udp: addresses: ["239.0.60.53"] packet_transport: - platform: udp - update_interval: 5s - encryption: "our key goes here" - rolling_code_enable: true - ping_pong_enable: true - binary_sensors: - - binary_sensor_id1 - - id: binary_sensor_id1 - broadcast_id: other_id - sensors: - - sensor_id1 - - id: sensor_id1 - broadcast_id: other_id - providers: - - name: some-device-name - encryption: "their key goes here" + - platform: udp + id: transport_udp + update_interval: 5s + encryption: "our key goes here" + rolling_code_enable: true + ping_pong_enable: true + binary_sensors: + - binary_sensor_id1 + - id: binary_sensor_id1 + broadcast_id: other_id + sensors: + - sensor_id1 + - id: sensor_id1 + broadcast_id: other_id + providers: + - name: some-device-name + encryption: "their key goes here" sensor: - platform: template id: sensor_id1 - platform: packet_transport + transport_id: transport_udp provider: some-device-name id: our_id remote_id: some_sensor_id binary_sensor: - platform: packet_transport + transport_id: transport_udp provider: unencrypted-device id: other_binary_sensor_id - platform: packet_transport + transport_id: transport_udp provider: some-device-name type: status name: Some-Device Status diff --git a/tests/components/packet_transport/test.host.yaml b/tests/components/packet_transport/test.host.yaml index 49fdbbc9b2..f67b561226 100644 --- a/tests/components/packet_transport/test.host.yaml +++ b/tests/components/packet_transport/test.host.yaml @@ -3,36 +3,40 @@ udp: addresses: ["239.0.60.53"] packet_transport: - platform: udp - update_interval: 5s - encryption: "our key goes here" - rolling_code_enable: true - ping_pong_enable: true - binary_sensors: - - binary_sensor_id1 - - id: binary_sensor_id1 - broadcast_id: other_id - sensors: - - sensor_id1 - - id: sensor_id1 - broadcast_id: other_id - providers: - - name: some-device-name - encryption: "their key goes here" + - platform: udp + id: transport_udp + update_interval: 5s + encryption: "our key goes here" + rolling_code_enable: true + ping_pong_enable: true + binary_sensors: + - binary_sensor_id1 + - id: binary_sensor_id1 + broadcast_id: other_id + sensors: + - sensor_id1 + - id: sensor_id1 + broadcast_id: other_id + providers: + - name: some-device-name + encryption: "their key goes here" sensor: - platform: template id: sensor_id1 - platform: packet_transport + transport_id: transport_udp provider: some-device-name id: our_id remote_id: some_sensor_id binary_sensor: - platform: packet_transport + transport_id: transport_udp provider: unencrypted-device id: other_binary_sensor_id - platform: packet_transport + transport_id: transport_udp provider: some-device-name type: status name: Some-Device Status diff --git a/tests/components/pvvx_mithermometer/common-ln.yaml b/tests/components/pvvx_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..b40f8cfd53 --- /dev/null +++ b/tests/components/pvvx_mithermometer/common-ln.yaml @@ -0,0 +1,8 @@ +# Sensor only: the pvvx display is a GATT client and stays ESP32-only. +sensor: + - platform: pvvx_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: PVVX Temperature + humidity: + name: PVVX Humidity diff --git a/tests/components/pvvx_mithermometer/common.yaml b/tests/components/pvvx_mithermometer/common.yaml index 972f23122c..8e3e8284a6 100644 --- a/tests/components/pvvx_mithermometer/common.yaml +++ b/tests/components/pvvx_mithermometer/common.yaml @@ -3,6 +3,7 @@ wifi: password: password1 esp32_ble_tracker: + id: ble_tracker_hub ble_client: - mac_address: 01:02:03:04:05:06 @@ -26,7 +27,9 @@ display: it.print_battery(true); sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: pvvx_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: PVVX Temperature diff --git a/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8bf34382d4 --- /dev/null +++ b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + pvvx_mithermometer: !include common-ln.yaml diff --git a/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..756ebdf79d --- /dev/null +++ b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. The display platform is excluded: it needs ble_client +# (GATT), which only the esp32 tracker stack provides. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: pvvx_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK PVVX Temperature diff --git a/tests/components/radon_eye_ble/common-ln.yaml b/tests/components/radon_eye_ble/common-ln.yaml new file mode 100644 index 0000000000..cfa30b967f --- /dev/null +++ b/tests/components/radon_eye_ble/common-ln.yaml @@ -0,0 +1 @@ +radon_eye_ble: diff --git a/tests/components/radon_eye_ble/common.yaml b/tests/components/radon_eye_ble/common.yaml index 85638d5c0e..4779f5db27 100644 --- a/tests/components/radon_eye_ble/common.yaml +++ b/tests/components/radon_eye_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. radon_eye_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/radon_eye_ble/test.ln882x-ard.yaml b/tests/components/radon_eye_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f32bca2a56 --- /dev/null +++ b/tests/components/radon_eye_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + radon_eye_ble: !include common-ln.yaml diff --git a/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..88e69921b5 --- /dev/null +++ b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +radon_eye_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 5631c48f95..981946a9a4 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -198,7 +198,7 @@ button: 0xFF, ] - platform: template - name: Haier + name: Haier Long on_press: remote_transmitter.transmit_haier: code: @@ -217,6 +217,21 @@ button: 0x00, 0x05, ] + - platform: template + name: Haier Short + on_press: + remote_transmitter.transmit_haier: + code: + [ + 0xA6, + 0xDA, + 0x00, + 0x00, + 0x40, + 0x40, + 0x00, + 0x80, + ] - platform: template name: Mirage on_press: diff --git a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml new file mode 100644 index 0000000000..401a18c0de --- /dev/null +++ b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# Exercises the controller scan API from a lambda: scan start with +# interval/window in 0.625 ms BLE units and the active flag, stop, and the +# adapter MAC accessor. +esphome: + on_boot: + then: + - lambda: |- + uint8_t mac[6]; + id(ble).get_mac_msb_first(mac); + if (id(ble).scan_start(160, 48, false)) { + id(ble).scan_stop(); + } + if (id(ble).scan_start(160, 48, true)) { + id(ble).scan_stop(); + } + +rp2040_ble: + id: ble diff --git a/tests/components/rp2_ble_tracker/common-boundary.yaml b/tests/components/rp2_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..91e010b121 --- /dev/null +++ b/tests/components/rp2_ble_tracker/common-boundary.yaml @@ -0,0 +1,12 @@ +rp2_ble_tracker: + id: ble_tracker + scan_parameters: + # Boundary coverage: the documented 2.5 ms floor on window (expressible only + # via the microsecond-accurate validation), a non-round interval exercising the + # 0.625 ms unit conversion without collapsing onto the window's unit count, + # and the non-continuous config path. + interval: 5000us + window: 2500us + duration: 5min + active: false + continuous: false diff --git a/tests/components/rp2_ble_tracker/common.yaml b/tests/components/rp2_ble_tracker/common.yaml new file mode 100644 index 0000000000..633a4e1d0f --- /dev/null +++ b/tests/components/rp2_ble_tracker/common.yaml @@ -0,0 +1,17 @@ +rp2_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 30ms + duration: 5min + active: true + continuous: true + +# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI +# (same coverage arrangement as the esp32_ble_tracker tests). +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml new file mode 100644 index 0000000000..8b94f3cade --- /dev/null +++ b/tests/components/rp2_ble_tracker/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2_ble_tracker: !include common.yaml diff --git a/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml new file mode 100644 index 0000000000..b62a401320 --- /dev/null +++ b/tests/components/rp2_ble_tracker/validate-boundary.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2_ble_tracker: !include common-boundary.yaml diff --git a/tests/components/ruuvi_ble/common-ln.yaml b/tests/components/ruuvi_ble/common-ln.yaml new file mode 100644 index 0000000000..39e578f349 --- /dev/null +++ b/tests/components/ruuvi_ble/common-ln.yaml @@ -0,0 +1 @@ +ruuvi_ble: diff --git a/tests/components/ruuvi_ble/common.yaml b/tests/components/ruuvi_ble/common.yaml index 1f155fd8e1..0221d865ef 100644 --- a/tests/components/ruuvi_ble/common.yaml +++ b/tests/components/ruuvi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvi_ble/test.ln882x-ard.yaml b/tests/components/ruuvi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..5cc6a112ce --- /dev/null +++ b/tests/components/ruuvi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvi_ble: !include common-ln.yaml diff --git a/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c0436c0031 --- /dev/null +++ b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvitag/common-ln.yaml b/tests/components/ruuvitag/common-ln.yaml new file mode 100644 index 0000000000..3219624340 --- /dev/null +++ b/tests/components/ruuvitag/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature diff --git a/tests/components/ruuvitag/common.yaml b/tests/components/ruuvitag/common.yaml index 7990617710..ce6abf5bb5 100644 --- a/tests/components/ruuvitag/common.yaml +++ b/tests/components/ruuvitag/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ruuvitag + ble_hub_id: ble_tracker_hub mac_address: FF:56:D3:2F:7D:E8 humidity: name: RuuviTag Humidity diff --git a/tests/components/ruuvitag/test.ln882x-ard.yaml b/tests/components/ruuvitag/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9b0d8c2c58 --- /dev/null +++ b/tests/components/ruuvitag/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvitag: !include common-ln.yaml diff --git a/tests/components/ruuvitag/validate.bk72xx-ard.yaml b/tests/components/ruuvitag/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..967df8e53f --- /dev/null +++ b/tests/components/ruuvitag/validate.bk72xx-ard.yaml @@ -0,0 +1,38 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: ruuvitag + ble_hub_id: ble_tracker_hub + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature + pressure: + name: RuuviTag Pressure + acceleration: + name: RuuviTag Acceleration + acceleration_x: + name: RuuviTag Acceleration X + acceleration_y: + name: RuuviTag Acceleration Y + acceleration_z: + name: RuuviTag Acceleration Z + battery_voltage: + name: RuuviTag Battery Voltage + tx_power: + name: RuuviTag TX Power + movement_counter: + name: RuuviTag Movement Counter + measurement_sequence_number: + name: RuuviTag Measurement Sequence Number + # No ble_hub_id: exercises the generated binding real configs use. + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E9 + temperature: + name: BK RuuviTag Implicit Temperature diff --git a/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml b/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml new file mode 100644 index 0000000000..5ee1f308a3 --- /dev/null +++ b/tests/components/safe_mode/test-no-shutdown-confirm.esp32-idf.yaml @@ -0,0 +1,11 @@ +# Compile with OTA rollback support active (ota + safe_mode on ESP-IDF, the +# default) but boot_is_good_on_shutdown disabled, so an orderly shutdown does +# not confirm the app image; only boot_is_good_after / mark_successful do. +packages: + safe_mode: !include common-enabled.yaml + +safe_mode: + boot_is_good_on_shutdown: false + +ota: + - platform: esphome diff --git a/tests/components/sen5x/common.yaml b/tests/components/sen5x/common.yaml index 20f3a1dfd8..5cfff15243 100644 --- a/tests/components/sen5x/common.yaml +++ b/tests/components/sen5x/common.yaml @@ -24,10 +24,10 @@ sensor: name: PM <10µm Weight concentration id: pm_10_0 accuracy_decimals: 1 - nox: - name: NOx - voc: - name: VOC + nox_index: + name: NOx Index + voc_index: + name: VOC Index algorithm_tuning: index_offset: 100 learning_time_offset_hours: 12 diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 61ff9f1e0c..859e012c4a 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -26,10 +26,10 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 - nox: - name: NOx - voc: - name: VOC + nox_index: + name: NOx Index + voc_index: + name: VOC Index co2: name: Carbon Dioxide formaldehyde: diff --git a/tests/components/sendspin/common-image.yaml b/tests/components/sendspin/common-image.yaml new file mode 100644 index 0000000000..7c32a5e257 --- /dev/null +++ b/tests/components/sendspin/common-image.yaml @@ -0,0 +1,47 @@ +packages: + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(album_art)); + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + - logger.log: + format: "Album art displayed (late by %u ms)" + args: ["(unsigned) lateness_ms"] + # Stand-in for a display transition; with a transition image every display must end + # with transition_finished so the library releases the next artwork frame. + - delay: 300ms + - sendspin.image.transition_finished: album_slot + on_image_clear: + - logger.log: "Album art cleared" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_slot + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST + current_image: + id: artist_art diff --git a/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml new file mode 100644 index 0000000000..9084d77262 --- /dev/null +++ b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml @@ -0,0 +1,66 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + auto_clear_enabled: false + +lvgl: + displays: + - main_lcd + animations: + # Fades the top widget out to reveal the new artwork underneath. Starting it also snaps the + # widget back to full opacity, and on_stop acks the transition so the library can deliver the + # next artwork. + - id: album_art_crossfade + duration: 2s + widgets: + - id: outgoing_art + opa: + from: 100% + to: 0% + on_stop: + - sendspin.image.transition_finished: album_slot + widgets: + # Cross-fade pair: the bottom widget always shows the current artwork; the top widget is + # pointed at the outgoing frame on each display event and faded out over it. + - image: + id: incoming_art + src: album_art + - image: + id: outgoing_art + src: album_art + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + # Start the fade 1s before the track boundary so the 2s cross-fade straddles it. + display_offset: 1s + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + # A widget keeps drawing the buffer it was last pointed at until its source is set again, so + # both widgets are re-pointed on every display: the top widget at the outgoing frame + # (covering the bottom), the bottom widget at the new frame. The transition image is black + # before the first artwork, so the first fade needs no special case. + - lvgl.image.update: + id: outgoing_art + src: album_art_transition + - lvgl.image.update: + id: incoming_art + src: album_art + - lvgl.animation.start: album_art_crossfade diff --git a/tests/components/sendspin/test-image.esp32-idf.yaml b/tests/components/sendspin/test-image.esp32-idf.yaml new file mode 100644 index 0000000000..a4f9e492c6 --- /dev/null +++ b/tests/components/sendspin/test-image.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common-image.yaml diff --git a/tests/components/sgp4x/common.yaml b/tests/components/sgp4x/common.yaml index 4edda8fd1b..88b8166876 100644 --- a/tests/components/sgp4x/common.yaml +++ b/tests/components/sgp4x/common.yaml @@ -1,7 +1,7 @@ sensor: - platform: sgp4x i2c_id: i2c_bus - voc: + voc_index: name: VOC Index id: sgp40_voc_index algorithm_tuning: @@ -11,8 +11,8 @@ sensor: gating_max_duration_minutes: 180 std_initial: 50 gain_factor: 230 - nox: - name: NOx + nox_index: + name: NOx Index algorithm_tuning: index_offset: 100 learning_time_offset_hours: 12 diff --git a/tests/components/syslog/test.host.yaml b/tests/components/syslog/test.host.yaml index 31122437d5..d9aa8e529b 100644 --- a/tests/components/syslog/test.host.yaml +++ b/tests/components/syslog/test.host.yaml @@ -2,7 +2,7 @@ udp: addresses: ["239.0.60.53"] time: - platform: host + - platform: host syslog: port: 514 diff --git a/tests/components/test_display/common.yaml b/tests/components/test_display/common.yaml new file mode 100644 index 0000000000..c36cf4b997 --- /dev/null +++ b/tests/components/test_display/common.yaml @@ -0,0 +1,13 @@ +# The test_display platform (and its external_components entry) is provided by +# the shared package included from the test.*.yaml files. These extra instances +# exercise the remaining `dimensions` code paths: the width/height map form and +# the default when omitted. The package's own `test_display_screen` covers the +# "WIDTHxHEIGHT" string form. +display: + - platform: test_display + id: test_display_wh_dimensions + dimensions: + width: 320 + height: 240 + - platform: test_display + id: test_display_default_dimensions diff --git a/tests/components/test_display/components/test_display/__init__.py b/tests/components/test_display/components/test_display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/test_display/components/test_display/display.py b/tests/components/test_display/components/test_display/display.py new file mode 100644 index 0000000000..8503053b46 --- /dev/null +++ b/tests/components/test_display/components/test_display/display.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, CONF_WIDTH +from esphome.core import CoroPriority, coroutine_with_priority + +test_display_ns = cg.esphome_ns.namespace("test_display") +TestDisplay = test_display_ns.class_("TestDisplay", display.Display) + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TestDisplay), + cv.Optional(CONF_DIMENSIONS, default="100x100"): cv.Any( + cv.dimensions, + cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } + ), + ), + } +) + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await display.register_display(var, config) + + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + width, height = dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + else: + width, height = dimensions + cg.add(var.set_dimensions(width, height)) diff --git a/tests/components/test_display/components/test_display/test_display.h b/tests/components/test_display/components/test_display/test_display.h new file mode 100644 index 0000000000..3f2b03a773 --- /dev/null +++ b/tests/components/test_display/components/test_display/test_display.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/core/color.h" + +namespace esphome::test_display { + +/** A no-op display that draws nothing and uses no pins. + * + * It exists purely to satisfy components that require a display (for example + * touchscreens, which read the display dimensions) in configurations - most + * notably YAML build tests - where a real display driver would only get in the + * way by occupying GPIO pins and pulling in bus dependencies. + */ +class TestDisplay : public display::Display { + public: + void update() override { this->do_update_(); } + + void set_dimensions(int width, int height) { + this->width_ = width; + this->height_ = height; + } + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + + void draw_pixel_at(int x, int y, Color color) override {} + + protected: + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + int width_{0}; + int height_{0}; +}; + +} // namespace esphome::test_display diff --git a/tests/components/test_display/test.esp32-idf.yaml b/tests/components/test_display/test.esp32-idf.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.esp8266-ard.yaml b/tests/components/test_display/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/test_display/test.rp2040-ard.yaml b/tests/components/test_display/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dfc8006c38 --- /dev/null +++ b/tests/components/test_display/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + test_display_extra: !include common.yaml diff --git a/tests/components/thermopro_ble/common-ln.yaml b/tests/components/thermopro_ble/common-ln.yaml new file mode 100644 index 0000000000..10aff2d658 --- /dev/null +++ b/tests/components/thermopro_ble/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: ThermoPro Temperature + humidity: + name: ThermoPro Humidity diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml index 297725e1c3..63fab83c01 100644 --- a/tests/components/thermopro_ble/common.yaml +++ b/tests/components/thermopro_ble/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: thermopro_ble + ble_hub_id: ble_tracker_hub mac_address: FE:74:B8:6A:97:B7 temperature: name: "ThermoPro Temperature" diff --git a/tests/components/thermopro_ble/test.ln882x-ard.yaml b/tests/components/thermopro_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..b3a59e83fc --- /dev/null +++ b/tests/components/thermopro_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + thermopro_ble: !include common-ln.yaml diff --git a/tests/components/thermopro_ble/validate.bk72xx-ard.yaml b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8f1ec417be --- /dev/null +++ b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: thermopro_ble + ble_hub_id: ble_tracker_hub + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B8 + temperature: + name: BK ThermoPro Implicit Temperature diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index 1f9249f1ba..5cb6b99a8e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,19 +1,8 @@ -display: - - platform: ssd1306_i2c - i2c_id: i2c_bus - id: tt21100_ssd1306_i2c_display - model: SSD1306_128X64 - reset_pin: ${disp_reset_pin} - pages: - - id: tt21100_page1 - lambda: |- - it.rectangle(0, 0, it.get_width(), it.get_height()); - touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: tt21100_ssd1306_i2c_display + display: test_display_screen interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/tt21100/test.esp32-idf.yaml b/tests/components/tt21100/test.esp32-idf.yaml index 033aafb73c..a79695d611 100644 --- a/tests/components/tt21100/test.esp32-idf.yaml +++ b/tests/components/tt21100/test.esp32-idf.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO12 interrupt_pin: GPIO15 reset_pin: GPIO4 packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.esp8266-ard.yaml b/tests/components/tt21100/test.esp8266-ard.yaml index 25d1ff82e3..ae6977c6ec 100644 --- a/tests/components/tt21100/test.esp8266-ard.yaml +++ b/tests/components/tt21100/test.esp8266-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO0 interrupt_pin: GPIO15 reset_pin: GPIO16 packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/tt21100/test.rp2040-ard.yaml b/tests/components/tt21100/test.rp2040-ard.yaml index 0d13628294..98b2ad600c 100644 --- a/tests/components/tt21100/test.rp2040-ard.yaml +++ b/tests/components/tt21100/test.rp2040-ard.yaml @@ -1,9 +1,8 @@ substitutions: - disp_reset_pin: GPIO10 interrupt_pin: GPIO2 reset_pin: GPIO3 packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + tt21100: !include common.yaml diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h new file mode 100644 index 0000000000..1582358700 --- /dev/null +++ b/tests/components/ufm01/common.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/ufm01/ufm01.h" + +namespace esphome::ufm01::testing { + +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; +static constexpr uint8_t COMMAND_ACK = 0xE5; + +// UART mock with a byte queue for read-side simulation. +class QueuedMockUART : public uart::UARTComponent { + public: + std::deque rx_queue; + std::vector written_data; + + void enqueue(const std::vector &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + + void enqueue(std::initializer_list data) { + for (uint8_t byte : data) + this->rx_queue.push_back(byte); + } + + void clear_rx() { this->rx_queue.clear(); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx_queue.size() < len) + return false; + for (size_t i = 0; i < len; ++i) { + data[i] = this->rx_queue.front(); + this->rx_queue.pop_front(); + } + return true; + } + + bool peek_byte(uint8_t *data) override { + if (this->rx_queue.empty()) + return false; + *data = this->rx_queue.front(); + return true; + } + + size_t available() override { return this->rx_queue.size(); } + + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + + void write_array(const uint8_t *data, size_t len) override { this->written_data.assign(data, data + len); } + + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableUFM01 : public UFM01Component { + public: + void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } + + bool process_active_stream() { return this->process_active_stream_(); } + + PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + + bool consume_ack() { return this->consume_ack_(); } + + void start_passive_read() { this->start_passive_read_(); } + + void loop_startup() { this->loop_startup_(); } + + OperatingMode operating_mode() const { return this->operating_mode_; } + + StartupPhase startup_phase() const { return this->startup_phase_; } + + int32_t read_index() const { return this->read_index_; } + + size_t passive_index() const { return this->passive_index_; } + + uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + + void prepare_passive_read() { + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); + } + + void init_wait_phase() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::WAIT; + this->startup_wait_ms_ = 60000; + this->phase_start_ms_ = millis(); + } + + void reset_state() { + this->read_index_ = 0; + this->last_valid_frame_ms_ = 0; + this->passive_index_ = 0; + this->passive_read_pending_ = false; + } +}; + +inline std::array make_active_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = FRAME_START_BYTE_2; + frame[15] = FRAME_FLAG_INSTANT_FLOW; + frame[21] = FRAME_FLAG_RESERVED_SECTION; + frame[24] = FRAME_FLAG_TEMP; + frame[31] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 30; ++i) + sum += frame[i]; + frame[30] = sum; + return frame; +} + +inline std::array make_passive_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2; + frame[9] = FRAME_FLAG_INSTANT_FLOW; + frame[15] = FRAME_FLAG_TEMP; + frame[22] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += frame[i]; + frame[21] = sum; + return frame; +} + +class UFM01Test : public ::testing::Test { + protected: + void SetUp() override { + this->mock_uart_.clear_rx(); + this->mock_uart_.written_data.clear(); + this->ufm01_.set_mock_uart(&this->mock_uart_); + this->ufm01_.reset_state(); + } + + QueuedMockUART mock_uart_; + TestableUFM01 ufm01_; +}; + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp new file mode 100644 index 0000000000..3b1b148d50 --- /dev/null +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -0,0 +1,83 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ValidActiveFrameAccepted) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { + this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); +} + +TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { + auto frame = make_active_frame(); + frame[30] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_FALSE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, InvalidPassiveChecksumFails) { + auto frame = make_passive_frame(); + frame[21] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({0x00, 0x01, 0x02}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); + EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + + this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp new file mode 100644 index 0000000000..6b7feb0f1b --- /dev/null +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -0,0 +1,43 @@ +#include "common.h" + +#include "esphome/core/component.h" + +namespace esphome::ufm01::testing { + +TEST(UFM01SetupPriority, IsLate) { + TestableUFM01 ufm01; + EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); +} + +TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { + this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); + + EXPECT_TRUE(this->ufm01_.consume_ack()); + EXPECT_EQ(this->mock_uart_.available(), 1u); +} + +TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } + +TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::WAIT); +} + +TEST_F(UFM01Test, StartPassiveReadSendsCommand) { + this->ufm01_.start_passive_read(); + + ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); + EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); + EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); + EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ultrasonic/test.nrf52-adafruit.yaml b/tests/components/ultrasonic/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/ultrasonic/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/web_server/common.yaml b/tests/components/web_server/common.yaml index 5a05a58c2d..d7b880efe6 100644 --- a/tests/components/web_server/common.yaml +++ b/tests/components/web_server/common.yaml @@ -4,6 +4,17 @@ wifi: binary_sensor: cover: + - platform: template + name: "Template Cover Assumed" + # assumed_state must be reflected in the web_server JSON (detail=all) + assumed_state: true + lambda: 'return COVER_OPEN;' + open_action: + - logger.log: open_action + close_action: + - logger.log: close_action + stop_action: + - logger.log: stop_action fan: light: sensor: diff --git a/tests/components/web_server/test-basicauth.esp8266-ard.yaml b/tests/components/web_server/test-basicauth.esp8266-ard.yaml new file mode 100644 index 0000000000..6a01180892 --- /dev/null +++ b/tests/components/web_server/test-basicauth.esp8266-ard.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + type: basic diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index e4d50d7776..6a01180892 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -4,5 +4,5 @@ packages: web_server: auth: username: admin - password: password + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic diff --git a/tests/components/xiaomi_ble/common-ln.yaml b/tests/components/xiaomi_ble/common-ln.yaml new file mode 100644 index 0000000000..d46c306d65 --- /dev/null +++ b/tests/components/xiaomi_ble/common-ln.yaml @@ -0,0 +1 @@ +xiaomi_ble: diff --git a/tests/components/xiaomi_ble/common.yaml b/tests/components/xiaomi_ble/common.yaml index 9d10393177..f218426c23 100644 --- a/tests/components/xiaomi_ble/common.yaml +++ b/tests/components/xiaomi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/xiaomi_ble/test.ln882x-ard.yaml b/tests/components/xiaomi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..014985c5c5 --- /dev/null +++ b/tests/components/xiaomi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_ble: !include common-ln.yaml diff --git a/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..20b42c4263 --- /dev/null +++ b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/xiaomi_cgd1/common-ln.yaml b/tests/components/xiaomi_cgd1/common-ln.yaml new file mode 100644 index 0000000000..0ee92e3e14 --- /dev/null +++ b/tests/components/xiaomi_cgd1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level diff --git a/tests/components/xiaomi_cgd1/common.yaml b/tests/components/xiaomi_cgd1/common.yaml index 94ed09e8f2..032a6d5c19 100644 --- a/tests/components/xiaomi_cgd1/common.yaml +++ b/tests/components/xiaomi_cgd1/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: diff --git a/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..844f9c97bc --- /dev/null +++ b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgd1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5980fba7ff --- /dev/null +++ b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGD1 Implicit Temperature diff --git a/tests/components/xiaomi_cgdk2/common-ln.yaml b/tests/components/xiaomi_cgdk2/common-ln.yaml new file mode 100644 index 0000000000..f8ff21bd5b --- /dev/null +++ b/tests/components/xiaomi_cgdk2/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/common.yaml b/tests/components/xiaomi_cgdk2/common.yaml index dddca56222..d5040aa0be 100644 --- a/tests/components/xiaomi_cgdk2/common.yaml +++ b/tests/components/xiaomi_cgdk2/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGDK2 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGDK2 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6f0fb03fd8 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgdk2: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..0eb2617cd7 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGDK2 Implicit Temperature diff --git a/tests/components/xiaomi_cgg1/common-ln.yaml b/tests/components/xiaomi_cgg1/common-ln.yaml new file mode 100644 index 0000000000..f26d31ed50 --- /dev/null +++ b/tests/components/xiaomi_cgg1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/common.yaml b/tests/components/xiaomi_cgg1/common.yaml index 170aebfbde..e4a3ef4ba7 100644 --- a/tests/components/xiaomi_cgg1/common.yaml +++ b/tests/components/xiaomi_cgg1/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGG1 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGG1 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..76ebbc01ed --- /dev/null +++ b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgg1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..11420925a0 --- /dev/null +++ b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGG1 Implicit Temperature diff --git a/tests/components/xiaomi_cgpr1/common-ln.yaml b/tests/components/xiaomi_cgpr1/common-ln.yaml new file mode 100644 index 0000000000..675d7ac18e --- /dev/null +++ b/tests/components/xiaomi_cgpr1/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_cgpr1 + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 Battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance diff --git a/tests/components/xiaomi_cgpr1/common.yaml b/tests/components/xiaomi_cgpr1/common.yaml index 48082a886c..d713e5e996 100644 --- a/tests/components/xiaomi_cgpr1/common.yaml +++ b/tests/components/xiaomi_cgpr1/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub name: CGPR1 Motion mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..7199fd6a6c --- /dev/null +++ b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgpr1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..749adfebe2 --- /dev/null +++ b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 Battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgpr1 + name: BK CGPR1 Implicit Motion + mac_address: "12:34:56:12:34:57" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_gcls002/common-ln.yaml b/tests/components/xiaomi_gcls002/common-ln.yaml new file mode 100644 index 0000000000..606f78e7cd --- /dev/null +++ b/tests/components/xiaomi_gcls002/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance diff --git a/tests/components/xiaomi_gcls002/common.yaml b/tests/components/xiaomi_gcls002/common.yaml index 32990708cc..86ec068a19 100644 --- a/tests/components/xiaomi_gcls002/common.yaml +++ b/tests/components/xiaomi_gcls002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: GCLS02 Temperature diff --git a/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1bec7b000c --- /dev/null +++ b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_gcls002: !include common-ln.yaml diff --git a/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f84d325685 --- /dev/null +++ b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK GCLS02 Implicit Temperature diff --git a/tests/components/xiaomi_hhccjcy01/common-ln.yaml b/tests/components/xiaomi_hhccjcy01/common-ln.yaml new file mode 100644 index 0000000000..1fcbe985eb --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level diff --git a/tests/components/xiaomi_hhccjcy01/common.yaml b/tests/components/xiaomi_hhccjcy01/common.yaml index 0def909488..756f1280f6 100644 --- a/tests/components/xiaomi_hhccjcy01/common.yaml +++ b/tests/components/xiaomi_hhccjcy01/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: Xiaomi HHCCJCY01 Temperature diff --git a/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3cb949bb83 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy01: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..da52b527e9 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY01 Implicit Temperature diff --git a/tests/components/xiaomi_hhccjcy10/common-ln.yaml b/tests/components/xiaomi_hhccjcy10/common-ln.yaml new file mode 100644 index 0000000000..c71b5cc1e7 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/common.yaml b/tests/components/xiaomi_hhccjcy10/common.yaml new file mode 100644 index 0000000000..79efdde42d --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common.yaml @@ -0,0 +1,18 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml new file mode 100644 index 0000000000..bc67f843ff --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_hhccjcy10: !include common.yaml diff --git a/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8fe9e74dfd --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy10: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e39bcfb8be --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY10 Implicit Temperature diff --git a/tests/components/xiaomi_hhccpot002/common-ln.yaml b/tests/components/xiaomi_hhccpot002/common-ln.yaml new file mode 100644 index 0000000000..6f39b6a2b8 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity diff --git a/tests/components/xiaomi_hhccpot002/common.yaml b/tests/components/xiaomi_hhccpot002/common.yaml index 2e5fa14620..cee426f100 100644 --- a/tests/components/xiaomi_hhccpot002/common.yaml +++ b/tests/components/xiaomi_hhccpot002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 moisture: name: HHCCPOT002 Moisture diff --git a/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1f69281400 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccpot002: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c4003ecf4b --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:62 + moisture: + name: BK HHCCPOT002 Implicit Moisture diff --git a/tests/components/xiaomi_jqjcy01ym/common-ln.yaml b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml new file mode 100644 index 0000000000..c20269eab4 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level diff --git a/tests/components/xiaomi_jqjcy01ym/common.yaml b/tests/components/xiaomi_jqjcy01ym/common.yaml index 54c4b33dcd..1aace227cf 100644 --- a/tests/components/xiaomi_jqjcy01ym/common.yaml +++ b/tests/components/xiaomi_jqjcy01ym/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: JQJCY01YM Temperature diff --git a/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f3196e5188 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_jqjcy01ym: !include common-ln.yaml diff --git a/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c63ddcae3e --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK JQJCY01YM Implicit Temperature diff --git a/tests/components/xiaomi_lywsd02/common-ln.yaml b/tests/components/xiaomi_lywsd02/common-ln.yaml new file mode 100644 index 0000000000..ea3ec6647f --- /dev/null +++ b/tests/components/xiaomi_lywsd02/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level diff --git a/tests/components/xiaomi_lywsd02/common.yaml b/tests/components/xiaomi_lywsd02/common.yaml index 3e40ab8d70..76638cec5e 100644 --- a/tests/components/xiaomi_lywsd02/common.yaml +++ b/tests/components/xiaomi_lywsd02/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub mac_address: 3F:5B:7D:82:58:4E temperature: name: Xiaomi LYWSD02 Temperature diff --git a/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml new file mode 100644 index 0000000000..cc3e0bca1e --- /dev/null +++ b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..59d9045e37 --- /dev/null +++ b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4F + temperature: + name: BK Xiaomi LYWSD02 Implicit Temperature diff --git a/tests/components/xiaomi_lywsd02mmc/common-ln.yaml b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml new file mode 100644 index 0000000000..9e81de78ae --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level diff --git a/tests/components/xiaomi_lywsd02mmc/common.yaml b/tests/components/xiaomi_lywsd02mmc/common.yaml index e63f585830..870a4f4916 100644 --- a/tests/components/xiaomi_lywsd02mmc/common.yaml +++ b/tests/components/xiaomi_lywsd02mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:54:5E:18 bindkey: 2529d8e0d23150a588675cc54ad48400 temperature: diff --git a/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bcbe4c20d5 --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f5266b65af --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:19 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: BK Xiaomi LYWSD02MMC Implicit Temperature diff --git a/tests/components/xiaomi_lywsd03mmc/common-ln.yaml b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml new file mode 100644 index 0000000000..fe9b0b7b32 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level diff --git a/tests/components/xiaomi_lywsd03mmc/common.yaml b/tests/components/xiaomi_lywsd03mmc/common.yaml index d10a859c56..907fdb9078 100644 --- a/tests/components/xiaomi_lywsd03mmc/common.yaml +++ b/tests/components/xiaomi_lywsd03mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: e9efaa6873f9f9c87a5e75a5f814801c temperature: diff --git a/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..c85742c495 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd03mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e13b4dac47 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:79 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: BK Xiaomi LYWSD03MMC Implicit Temperature diff --git a/tests/components/xiaomi_lywsdcgq/common-ln.yaml b/tests/components/xiaomi_lywsdcgq/common-ln.yaml new file mode 100644 index 0000000000..6a458a5b2a --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level diff --git a/tests/components/xiaomi_lywsdcgq/common.yaml b/tests/components/xiaomi_lywsdcgq/common.yaml index d8422b4c0c..147b77c2d1 100644 --- a/tests/components/xiaomi_lywsdcgq/common.yaml +++ b/tests/components/xiaomi_lywsdcgq/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: Xiaomi LYWSDCGQ Temperature diff --git a/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml new file mode 100644 index 0000000000..48aa38be38 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsdcgq: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..030f74afa3 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK Xiaomi LYWSDCGQ Implicit Temperature diff --git a/tests/components/xiaomi_mhoc303/common-ln.yaml b/tests/components/xiaomi_mhoc303/common-ln.yaml new file mode 100644 index 0000000000..ca89047a68 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level diff --git a/tests/components/xiaomi_mhoc303/common.yaml b/tests/components/xiaomi_mhoc303/common.yaml index e4353d3c6a..74c96fc26d 100644 --- a/tests/components/xiaomi_mhoc303/common.yaml +++ b/tests/components/xiaomi_mhoc303/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C temperature: name: MHO-C303 Temperature diff --git a/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6e927dafe8 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc303: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5c7f29c98e --- /dev/null +++ b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1D + temperature: + name: BK MHO-C303 Implicit Temperature diff --git a/tests/components/xiaomi_mhoc401/common-ln.yaml b/tests/components/xiaomi_mhoc401/common-ln.yaml new file mode 100644 index 0000000000..43641f66d1 --- /dev/null +++ b/tests/components/xiaomi_mhoc401/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1C + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/common.yaml b/tests/components/xiaomi_mhoc401/common.yaml index ae378f5604..646961b3b6 100644 --- a/tests/components/xiaomi_mhoc401/common.yaml +++ b/tests/components/xiaomi_mhoc401/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C bindkey: "eef418daf699a0c188f3bfd17e4565d9" temperature: - name: MHO-C303 Temperature + name: MHO-C401 Temperature humidity: - name: MHO-C303 Humidity + name: MHO-C401 Humidity battery_level: - name: MHO-C303 Battery Level + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml new file mode 100644 index 0000000000..a20f24671d --- /dev/null +++ b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc401: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f7a2bd3e4f --- /dev/null +++ b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + bindkey: "eef418daf699a0c188f3bfd17e4565d9" + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1D + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK MHO-C401 Implicit Temperature diff --git a/tests/components/xiaomi_miscale/common-ln.yaml b/tests/components/xiaomi_miscale/common-ln.yaml new file mode 100644 index 0000000000..38c3287402 --- /dev/null +++ b/tests/components/xiaomi_miscale/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" diff --git a/tests/components/xiaomi_miscale/common.yaml b/tests/components/xiaomi_miscale/common.yaml index 89f32ad199..673db86311 100644 --- a/tests/components/xiaomi_miscale/common.yaml +++ b/tests/components/xiaomi_miscale/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub mac_address: '5C:CA:D3:70:D4:A2' weight: name: "Xiaomi Mi Scale Weight" diff --git a/tests/components/xiaomi_miscale/test.ln882x-ard.yaml b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml new file mode 100644 index 0000000000..88c5054ae3 --- /dev/null +++ b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_miscale: !include common-ln.yaml diff --git a/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..46fc7d0a2a --- /dev/null +++ b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A3' + weight: + name: "BK Xiaomi Mi Scale Implicit Weight" diff --git a/tests/components/xiaomi_mjyd02yla/common-ln.yaml b/tests/components/xiaomi_mjyd02yla/common-ln.yaml new file mode 100644 index 0000000000..04117e1565 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_mjyd02yla + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level diff --git a/tests/components/xiaomi_mjyd02yla/common.yaml b/tests/components/xiaomi_mjyd02yla/common.yaml index dffcef84c4..1a2c67c971 100644 --- a/tests/components/xiaomi_mjyd02yla/common.yaml +++ b/tests/components/xiaomi_mjyd02yla/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub name: MJYD02YL-A Motion mac_address: 50:EC:50:CD:32:02 bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3e7ec5e9ba --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mjyd02yla: !include common-ln.yaml diff --git a/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b47069a939 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mjyd02yla + name: BK MJYD02YL-A Implicit Motion + mac_address: 50:EC:50:CD:32:03 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mue4094rt/common-ln.yaml b/tests/components/xiaomi_mue4094rt/common-ln.yaml new file mode 100644 index 0000000000..9d28a7e7f8 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/common-ln.yaml @@ -0,0 +1,5 @@ +binary_sensor: + - platform: xiaomi_mue4094rt + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/common.yaml b/tests/components/xiaomi_mue4094rt/common.yaml index 4f0e5ccbae..bd5d9348ea 100644 --- a/tests/components/xiaomi_mue4094rt/common.yaml +++ b/tests/components/xiaomi_mue4094rt/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub name: MUE4094RT Motion mac_address: 7A:80:8E:19:36:BA timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bd2ccc4e59 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mue4094rt: !include common-ln.yaml diff --git a/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..10a537089b --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mue4094rt + name: BK MUE4094RT Implicit Motion + mac_address: 7A:80:8E:19:36:BB + timeout: 5s diff --git a/tests/components/xiaomi_rtcgq02lm/common-ln.yaml b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml new file mode 100644 index 0000000000..4a04476457 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml @@ -0,0 +1,20 @@ +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_rtcgq02lm/common.yaml b/tests/components/xiaomi_rtcgq02lm/common.yaml index a2e0c66ba5..4d235f6813 100644 --- a/tests/components/xiaomi_rtcgq02lm/common.yaml +++ b/tests/components/xiaomi_rtcgq02lm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_rtcgq02lm: - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub mac_address: 01:02:03:04:05:06 bindkey: "48403ebe2d385db8d0c187f81e62cb64" diff --git a/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6ef79a6626 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_rtcgq02lm: !include common-ln.yaml diff --git a/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..9c67182050 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml @@ -0,0 +1,32 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + + # No ble_hub_id: exercises the generated binding real configs use. + - id: motion_rtcgq02lm_implicit + mac_address: 01:02:03:04:05:07 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_wx08zm/common-ln.yaml b/tests/components/xiaomi_wx08zm/common-ln.yaml new file mode 100644 index 0000000000..83766c084b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/common-ln.yaml @@ -0,0 +1,8 @@ +binary_sensor: + - platform: xiaomi_wx08zm + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level diff --git a/tests/components/xiaomi_wx08zm/common.yaml b/tests/components/xiaomi_wx08zm/common.yaml index 3e83ad3e95..6e43a92d2e 100644 --- a/tests/components/xiaomi_wx08zm/common.yaml +++ b/tests/components/xiaomi_wx08zm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub name: WX08ZM Activation State mac_address: 74:a3:4a:b5:07:34 tablet: diff --git a/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..81f05c0c7b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_wx08zm: !include common-ln.yaml diff --git a/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fb9a4e3652 --- /dev/null +++ b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_wx08zm + name: BK WX08ZM Implicit Activation State + mac_address: 74:a3:4a:b5:07:35 diff --git a/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml new file mode 100644 index 0000000000..2a0778c2a7 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level diff --git a/tests/components/xiaomi_xmwsdj04mmc/common.yaml b/tests/components/xiaomi_xmwsdj04mmc/common.yaml index fe7a11efc5..1de13b2bc5 100644 --- a/tests/components/xiaomi_xmwsdj04mmc/common.yaml +++ b/tests/components/xiaomi_xmwsdj04mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub mac_address: 84:B4:DB:5D:A3:8F bindkey: d8ca2ed09bb5541dc8f045ca360b00ea temperature: diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..749473a022 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_xmwsdj04mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4139263b52 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:90 + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: BK Xiaomi XMWSDJ04MMC Implicit Temperature diff --git a/tests/components/zephyr_pwm/common.yaml b/tests/components/zephyr_pwm/common.yaml new file mode 100644 index 0000000000..248499951e --- /dev/null +++ b/tests/components/zephyr_pwm/common.yaml @@ -0,0 +1,9 @@ +output: + - platform: zephyr_pwm + id: pwm_output_1 + pin: P0.02 + - platform: zephyr_pwm + id: pwm_output_2 + pin: + number: 10 + inverted: true diff --git a/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index c689d07f6b..cc0d28ea61 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -13,6 +13,7 @@ sensor: - platform: template name: "Analog 1" lambda: return 10.0; + accuracy_decimals: 0 - platform: template name: "Analog 2" lambda: return 11.0; diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 6cac9c9e2a..ac25fb8faf 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -27,3 +27,6 @@ zigbee: on_join: then: - logger.log: "Joined network" + on_start: + then: + - logger.log: "Started zigbee stack" diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index bc39b371f5..c05c4053a5 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -7,6 +7,9 @@ zigbee: on_join: then: - logger.log: "Joined network" + on_start: + then: + - logger.log: "Started zigbee stack" time: - platform: zigbee diff --git a/tests/integration/README.md b/tests/integration/README.md index 4de08777b0..44d9e0d644 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -187,6 +187,7 @@ loop = asyncio.get_running_loop() states: dict[int, EntityState] = {} state_future: asyncio.Future[EntityState] = loop.create_future() + def on_state(state: EntityState) -> None: """This callback only receives NEW state changes, not initial states.""" states[state.key] = state @@ -195,6 +196,7 @@ def on_state(state: EntityState) -> None: if not state_future.done(): state_future.set_result(state) + # Get entities and set up state synchronization entities, services = await client.list_entities_services() initial_state_helper = InitialStateHelper(entities) @@ -228,6 +230,7 @@ loop = asyncio.get_running_loop() states: dict[int, EntityState] = {} state_future: asyncio.Future[EntityState] = loop.create_future() + def on_state(state: EntityState) -> None: states[state.key] = state # Check for specific condition using isinstance @@ -235,6 +238,7 @@ def on_state(state: EntityState) -> None: if not state_future.done(): state_future.set_result(state) + client.subscribe_states(on_state) # Wait for state with timeout @@ -263,11 +267,13 @@ entity_count = 50 received_states: set[int] = set() all_states_future: asyncio.Future[bool] = loop.create_future() + def on_state(state: EntityState) -> None: received_states.add(state.key) if len(received_states) >= entity_count and not all_states_future.done(): all_states_future.set_result(True) + client.subscribe_states(on_state) await asyncio.wait_for(all_states_future, timeout=10.0) ``` @@ -367,6 +373,7 @@ service_future = loop.create_future() connected_pattern = re.compile(r"Client .* connected from") service_pattern = re.compile(r"Service called") + def check_output(line: str) -> None: """Check log output for expected messages.""" if not connected_future.done() and connected_pattern.search(line): @@ -374,6 +381,7 @@ def check_output(line: str) -> None: elif not service_future.done() and service_pattern.search(line): service_future.set_result(True) + async with run_compiled(yaml_config, line_callback=check_output): async with api_client_connected() as client: # Wait for specific log message diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a9c9e0686f..1bf799b658 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" + # Compile with THIS tree's esphome sources, not wherever the venv's editable + # install points (which may be a different git worktree or checkout). + repo_root = str(Path(__file__).resolve().parent.parent.parent) + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env @@ -101,7 +106,7 @@ def shared_platformio_cache() -> Generator[Path]: env = _get_platformio_env(cache_dir) subprocess.run( - ["esphome", "compile", str(config_path)], + [sys.executable, "-m", "esphome", "compile", str(config_path)], check=True, cwd=init_dir, env=env, @@ -245,6 +250,8 @@ async def compile_esphome( for attempt in range(max_retries): # Compile using subprocess, inheriting stdout/stderr to show progress proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", "esphome", "compile", str(config_path), diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 7596983ee2..95f6a0321e 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_name, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,15 +25,16 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _get_name_for_object_id( +def _resolve_entity_name( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Get the name used for object_id computation. + """Resolve the effective name for an entity. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data. + name to use for computing object_id client-side from API data; the same + name is what the device hashes into the entity key. Args: entity: The entity to get name for @@ -72,27 +73,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) - return compute_object_id(name_for_id) + name = _resolve_entity_name(entity, device_info, device_id_to_name) + return compute_object_id(name) -def compute_entity_hash( +def compute_entity_key( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected object_id hash for an entity. + """Compute expected entity key for an entity. Args: - entity: The entity to compute hash for + entity: The entity to compute the key for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash + The computed FNV-1 hash of the raw name """ - name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) - return fnv1_hash_object_id(name_for_id) + name = _resolve_entity_name(entity, device_info, device_id_to_name) + return fnv1_hash_name(name) def verify_entity_object_id( @@ -118,7 +119,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) + expected_hash = compute_entity_key(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml b/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml new file mode 100644 index 0000000000..2f30a07d0d --- /dev/null +++ b/tests/integration/fixtures/blocking_warning_log_time_not_charged_to_next_operation.yaml @@ -0,0 +1,66 @@ +esphome: + name: blocking-warning-cascade + on_boot: + then: + - script.execute: blocking_60 + - script.execute: blocking_90 + - script.execute: blocking_120 + +host: + +api: + +logger: + level: DEBUG + on_message: + level: WARN + then: + - lambda: |- + uint32_t injected_delay = 0; + if (strstr(message, "blocking_60 took a long time") != nullptr) { + injected_delay = 60; + } else if (strstr(message, "blocking_90 took a long time") != nullptr) { + injected_delay = 90; + } else if (strstr(message, "blocking_120 took a long time") != nullptr) { + injected_delay = 120; + } + if (injected_delay != 0) { + id(injected_delay_total) += injected_delay; + const uint32_t start = millis(); + while (millis() - start < injected_delay) { + } + } + +globals: + - id: injected_delay_total + type: uint32_t + initial_value: "0" + +script: + - id: blocking_60 + then: + - delay: 20ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + + - id: blocking_90 + then: + - delay: 300ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + + - id: blocking_120 + then: + - delay: 600ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } + - delay: 200ms + - logger.log: + format: "BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=%u" + args: [id(injected_delay_total)] diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index 2097b2fbf9..d4511bb8c6 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,6 +71,38 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } + // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") + uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); + if (hash_raw == 0x8cec6fb0) { + ESP_LOGI("FNV1_OID", "raw PASSED"); + } else { + ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); + } + + // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") + uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); + if (hash_raw_utf8 == 0x531a74aa) { + ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); + } else { + ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); + } + + // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") + uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); + if (hash_old_utf8 == 0x965698f3) { + ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); + } else { + ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); + } + + // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") + uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); + if (hash_old_cjk == 0x3276cb9f) { + ESP_LOGI("FNV1_OID", "old_cjk PASSED"); + } else { + ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); + } + host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 01e4394559..582add90a8 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,10 +156,17 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference hashes for entities that actually store preferences - ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); - ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); + // Log preference key bases for entities that actually store preferences. + // This is the key base make_entity_preference() uses: entity key XOR device id. + ESP_LOGI("test", "Device A Switch Pref Hash: %u", + id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", + id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", + id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", + id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", + id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); + ESP_LOGI("test", "Main Number Pref Hash: %u", + id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml new file mode 100644 index 0000000000..a9b01fc2d2 --- /dev/null +++ b/tests/integration/fixtures/preference_key_migration.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-pref-key-migration + +host: +api: +logger: + +switch: + - platform: template + id: test_switch_restore + name: Test Switch + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: test_number_restore + name: Test Number + optimistic: true + restore_value: true + initial_value: 1.0 + min_value: 0 + max_value: 100 + step: 0.5 + +text: + - platform: template + id: test_text_restore + name: Test Text + mode: text + optimistic: true + restore_value: true + initial_value: fallback + min_length: 0 + max_length: 20 diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index 25decf20f5..ae95e095f6 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: retry_counter - type: int - initial_value: '0' - id: defer_counter type: int initial_value: '0' @@ -118,29 +115,7 @@ script: id(timeout_counter) += 1; }); - // Test 10: set_retry with numeric ID - App.scheduler.set_retry(component1, 6001U, 50, 3, - [](uint8_t retry_countdown) { - id(retry_counter)++; - ESP_LOGI("test", "Numeric retry 6001 attempt %d (countdown=%d)", - id(retry_counter), retry_countdown); - if (id(retry_counter) >= 2) { - ESP_LOGI("test", "Numeric retry 6001 done"); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - // Test 11: cancel_retry with numeric ID - App.scheduler.set_retry(component1, 6002U, 100, 5, - [](uint8_t retry_countdown) { - ESP_LOGE("test", "ERROR: Numeric retry 6002 should have been cancelled"); - return RetryResult::RETRY; - }); - App.scheduler.cancel_retry(component1, 6002U); - ESP_LOGI("test", "Cancelled numeric retry 6002"); - - // Test 12: defer with numeric ID (Component method) + // Test 10: defer with numeric ID (Component method) class TestDeferComponent : public Component { public: void test_defer_methods() { @@ -161,7 +136,7 @@ script: static TestDeferComponent test_defer_component; test_defer_component.test_defer_methods(); - // Test 13: cancel_defer with numeric ID (Component method) + // Test 11: cancel_defer with numeric ID (Component method) class TestCancelDeferComponent : public Component { public: void test_cancel_defer() { @@ -181,8 +156,8 @@ script: - id: report_results then: - lambda: |- - ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d", - id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter)); + ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Defers: %d", + id(timeout_counter), id(interval_counter), id(defer_counter)); sensor: - platform: template diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml deleted file mode 100644 index cdf71152bd..0000000000 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ /dev/null @@ -1,287 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-retry-test - on_boot: - priority: -100 - then: - - logger.log: "Starting scheduler retry tests" - # Run all tests sequentially with delays - - script.execute: run_all_tests - -host: -api: -logger: - level: VERY_VERBOSE - -globals: - - id: simple_retry_counter - type: int - initial_value: '0' - - id: backoff_retry_counter - type: int - initial_value: '0' - - id: backoff_last_attempt_time - type: uint32_t - initial_value: '0' - - id: immediate_done_counter - type: int - initial_value: '0' - - id: cancel_retry_counter - type: int - initial_value: '0' - - id: empty_name_retry_counter - type: int - initial_value: '0' - - id: script_retry_counter - type: int - initial_value: '0' - - id: multiple_same_name_counter - type: int - initial_value: '0' - - id: const_char_retry_counter - type: int - initial_value: '0' - - id: static_char_retry_counter - type: int - initial_value: '0' - -# Using different component types for each test to ensure isolation -sensor: - - platform: template - name: Simple Retry Test Sensor - id: simple_retry_sensor - lambda: return 1.0; - update_interval: never - - - platform: template - name: Backoff Retry Test Sensor - id: backoff_retry_sensor - lambda: return 2.0; - update_interval: never - - - platform: template - name: Immediate Done Test Sensor - id: immediate_done_sensor - lambda: return 3.0; - update_interval: never - -binary_sensor: - - platform: template - name: Cancel Retry Test Binary Sensor - id: cancel_retry_binary_sensor - lambda: return false; - - - platform: template - name: Empty Name Test Binary Sensor - id: empty_name_binary_sensor - lambda: return true; - -switch: - - platform: template - name: Script Retry Test Switch - id: script_retry_switch - optimistic: true - - - platform: template - name: Multiple Same Name Test Switch - id: multiple_same_name_switch - optimistic: true - -script: - - id: run_all_tests - then: - # Test 1: Simple retry - - logger.log: "=== Test 1: Simple retry ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - App.scheduler.set_retry(component, "simple_retry", 50, 3, - [](uint8_t retry_countdown) { - id(simple_retry_counter)++; - ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)", - id(simple_retry_counter), retry_countdown); - - if (id(simple_retry_counter) >= 2) { - ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter)); - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 2: Backoff retry - - logger.log: "=== Test 2: Retry with backoff ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - App.scheduler.set_retry(component, "backoff_retry", 50, 4, - [](uint8_t retry_countdown) { - id(backoff_retry_counter)++; - uint32_t now = millis(); - uint32_t interval = 0; - - // Only calculate interval after first attempt - if (id(backoff_retry_counter) > 1) { - interval = now - id(backoff_last_attempt_time); - } - id(backoff_last_attempt_time) = now; - - ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)", - id(backoff_retry_counter), retry_countdown, interval); - - if (id(backoff_retry_counter) == 1) { - ESP_LOGI("test", "First call was immediate"); - } else if (id(backoff_retry_counter) == 2) { - ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval); - } else if (id(backoff_retry_counter) == 3) { - ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval); - } else if (id(backoff_retry_counter) == 4) { - ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval); - ESP_LOGI("test", "Backoff retry completed"); - return RetryResult::DONE; - } - - return RetryResult::RETRY; - }, 2.0f); - - # Test 3: Immediate done - - logger.log: "=== Test 3: Immediate done ===" - - lambda: |- - auto *component = id(immediate_done_sensor); - App.scheduler.set_retry(component, "immediate_done", 50, 5, - [](uint8_t retry_countdown) { - id(immediate_done_counter)++; - ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown); - return RetryResult::DONE; - }); - - # Test 4: Cancel retry - - logger.log: "=== Test 4: Cancel retry ===" - - lambda: |- - auto *component = id(cancel_retry_binary_sensor); - App.scheduler.set_retry(component, "cancel_test", 30, 10, - [](uint8_t retry_countdown) { - id(cancel_retry_counter)++; - ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter)); - return RetryResult::RETRY; - }); - - // Cancel it after 100ms - App.scheduler.set_timeout(component, "cancel_timer", 100, []() { - bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test"); - ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false"); - ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter)); - }); - - # Test 5: Empty name retry - - logger.log: "=== Test 5: Empty name retry ===" - - lambda: |- - auto *component = id(empty_name_binary_sensor); - App.scheduler.set_retry(component, "", 100, 5, - [](uint8_t retry_countdown) { - id(empty_name_retry_counter)++; - ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter)); - return RetryResult::RETRY; - }); - - // Try to cancel after 150ms - App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() { - bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), ""); - ESP_LOGI("test", "Empty name retry cancel result: %s", - cancelled ? "true" : "false"); - ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter)); - }); - - # Test 6: Component method - - logger.log: "=== Test 6: Component::set_retry method ===" - - lambda: |- - class TestRetryComponent : public Component { - public: - void test_retry() { - this->set_retry(50, 3, - [](uint8_t retry_countdown) { - id(script_retry_counter)++; - ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter)); - if (id(script_retry_counter) >= 2) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }, 1.5f); - } - }; - - static TestRetryComponent test_component; - test_component.test_retry(); - - # Test 7: Multiple same name - - logger.log: "=== Test 7: Multiple retries with same name ===" - - lambda: |- - auto *component = id(multiple_same_name_switch); - - // Set first retry - App.scheduler.set_retry(component, "duplicate_retry", 100, 5, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 1; - ESP_LOGI("test", "First duplicate retry - should not run"); - return RetryResult::RETRY; - }); - - // Set second retry with same name (should cancel first) - App.scheduler.set_retry(component, "duplicate_retry", 50, 3, - [](uint8_t retry_countdown) { - id(multiple_same_name_counter) += 10; - ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)", - id(multiple_same_name_counter)); - if (id(multiple_same_name_counter) >= 20) { - return RetryResult::DONE; - } - return RetryResult::RETRY; - }); - - # Test 8: Const char* overloads - - logger.log: "=== Test 8: Const char* overloads ===" - - lambda: |- - auto *component = id(simple_retry_sensor); - - // Test 8a: Direct string literal - App.scheduler.set_retry(component, "const_char_test", 30, 2, - [](uint8_t retry_countdown) { - id(const_char_retry_counter)++; - ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); - return RetryResult::DONE; - }); - - # Test 9: Static const char* variable - - logger.log: "=== Test 9: Static const char* ===" - - lambda: |- - auto *component = id(backoff_retry_sensor); - - static const char* STATIC_NAME = "static_retry_test"; - App.scheduler.set_retry(component, STATIC_NAME, 20, 1, - [](uint8_t retry_countdown) { - id(static_char_retry_counter)++; - ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); - return RetryResult::DONE; - }); - - // Cancel with same static const char* - App.scheduler.set_timeout(component, "static_cancel", 10, []() { - static const char* STATIC_NAME = "static_retry_test"; - bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); - ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); - }); - - # Wait for all tests to complete before reporting - - delay: 500ms - - # Final report - - logger.log: "=== Retry Test Results ===" - - lambda: |- - ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter)); - ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter)); - ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter)); - ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter)); - ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); - ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); - ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); - ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); - ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); - ESP_LOGI("test", "All retry tests completed"); diff --git a/tests/integration/fixtures/script_queued.yaml b/tests/integration/fixtures/script_queued.yaml index 996dd6436f..c8c56113db 100644 --- a/tests/integration/fixtures/script_queued.yaml +++ b/tests/integration/fixtures/script_queued.yaml @@ -1,5 +1,17 @@ esphome: name: test-script-queued + on_boot: + # Default priority (600.0) runs before the script component is set up + # This tests that an instance queued during boot still gets dequeued + # once the main loop starts (the idle-loop disabling must not eat it) + then: + - logger.log: "=== BOOT: Executing queued script twice ===" + - script.execute: + id: boot_script + tag: 1 + - script.execute: + id: boot_script + tag: 2 host: api: @@ -98,6 +110,15 @@ api: - script.execute: no_params_script - script.execute: no_params_script + # Test 6: Re-execute after stop() cleared the queue + # (the idle loop must re-enable on demand) + - action: test_after_stop + then: + - logger.log: "=== TEST 6: Re-execute after stop ===" + - script.execute: + id: stop_script + num: 9 + logger: level: DEBUG @@ -168,3 +189,18 @@ script: - logger.log: "No params: START" - delay: 50ms - logger.log: "No params: END" + + # Boot script: executed twice from on_boot before setup() + - id: boot_script + mode: queued + max_runs: 3 + parameters: + tag: int + then: + - logger.log: + format: "Boot queued: START %d" + args: ['tag'] + - delay: 50ms + - logger.log: + format: "Boot queued: END %d" + args: ['tag'] diff --git a/tests/integration/fixtures/script_queued_idle_loop.yaml b/tests/integration/fixtures/script_queued_idle_loop.yaml new file mode 100644 index 0000000000..7d5d3cb86f --- /dev/null +++ b/tests/integration/fixtures/script_queued_idle_loop.yaml @@ -0,0 +1,25 @@ +esphome: + name: test-script-queued-idle + +host: +api: + actions: + # Execute twice: the first runs immediately, the second gets queued, + # which must re-enable the loop; draining must disable it again + - action: run_twice + then: + - script.execute: idle_script + - script.execute: idle_script + +# VERY_VERBOSE exposes the component framework's "loop disabled" and +# "loop enabled" messages that this test asserts on +logger: + level: VERY_VERBOSE + +script: + - id: idle_script + mode: queued + then: + - logger.log: "idle_script: START" + - delay: 50ms + - logger.log: "idle_script: END" diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml new file mode 100644 index 0000000000..8857bf8c96 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml @@ -0,0 +1,150 @@ +esphome: + name: uart-mock-modbus-broadcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true # controller polls at boot; forwarding must already be active + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: srv1_reg + type: int + initial_value: "0" + - id: srv2_reg + type: int + initial_value: "0" + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + # Polling is off until the test has subscribed; the Start Scenario button starts it, so the + # first poll is never lost to a boot-time race ahead of the API subscription. + update_interval: never + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv1_reg); + write_lambda: |- + id(srv1_reg) = x; + return true; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv2_reg); + write_lambda: |- + id(srv2_reg) = x; + return true; + +sensor: + # Normal polling continues before and after the broadcast: the old behavior burned a + # timeout per broadcast, which surfaces as modbus warnings and failed expectations here. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + # Republish every poll (the value is constant 919): the test observes successive publishes to + # prove polling continues before and after the broadcast, which dedup would otherwise hide. + force_update: true + # The servers' written values, published locally. + - platform: template + name: "srv1_written" + lambda: return id(srv1_reg); + update_interval: 0.2s + - platform: template + name: "srv2_written" + lambda: return id(srv2_reg); + update_interval: 0.2s + # Whether the hub accepted the broadcast into the transmit queue (the bool queue_pdu() returns). + - platform: template + name: "broadcast_accepted" + id: broadcast_accepted + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Start polling now that the test has subscribed. + id(modbus_controller_1).set_update_interval(1000); + id(modbus_controller_1).start_poller(); + // Broadcast (address 0) write single register: reg 0x10 = 777 on every server. + // PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC. + const uint8_t pdu[] = {0x06, 0x00, 0x10, 0x03, 0x09}; + // queue_pdu() returns whether the broadcast was accepted into the machine (the answer this PR + // makes meaningful); publish it so the test asserts the accept, not just the servers' writes. + bool accepted = id(virtual_modbus_client)->queue_pdu(0x00, pdu); + id(broadcast_accepted).publish_state(accepted ? 1.0f : 0.0f); diff --git a/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml new file mode 100644 index 0000000000..f85206107f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_inline.yaml @@ -0,0 +1,108 @@ +esphome: + name: uart-mock-modbus-client-inline + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + # Short wait so the no-reply cases (address 2 below) time out well within the test window. + send_wait_time: 500ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return 1234; + +sensor: + - platform: template + name: "inline_value" + id: inline_value + - platform: template + name: "timeout_flag" + id: timeout_flag + - platform: template + name: "skipped_flag" + id: skipped_flag + +# The same write action fired twice while its first frame is still awaiting a reply: the hub drops the +# duplicate write (writes are never merged) and the second firing resolves via its own on_not_sent. +# mode: parallel so the second run starts while the first send is pending. +script: + - id: dup_write + mode: parallel + then: + - modbus_client.send: + address: 2 + pdu: [0x06, 0x00, 0x10, 0x01, 0x02] + on_not_sent: + then: + - lambda: "id(skipped_flag).publish_state(1);" + +# Each action is its own hub device: address 1 is served by the mock server, address 2 answers nothing. +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Per-send inline on_response: decode this reply where the send was fired (fire-and-continue). + - modbus_client.send: + address: 1 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_response: + then: + - lambda: |- + if (response.size() >= 4) + id(inline_value).publish_state((response[2] << 8) | response[3]); + # No server answers address 2, so this resolves via on_no_response. + - modbus_client.send: + address: 2 + pdu: [0x03, 0x00, 0x10, 0x00, 0x01] + on_no_response: + then: + - lambda: "id(timeout_flag).publish_state(1);" + - script.execute: dup_write + - script.execute: dup_write diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml new file mode 100644 index 0000000000..1f89889c95 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml @@ -0,0 +1,111 @@ +esphome: + name: uart-mock-modbus-cli-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Two virtual buses looped back to each other: the client's transmissions reach the server and the +# server's replies reach the client. auto_start so forwarding is active before the button fires. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: the read publishes what it returns, so the test can confirm the + # write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(srv_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + +sensor: + # Server-side observations. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "srv_read_1" + id: srv_read_1 + # Client-side read-back: the values the client's on_response received. + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 0x01 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml new file mode 100644 index 0000000000..e445093625 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -0,0 +1,176 @@ +esphome: + name: uart-mock-modbus-client-typed + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: reg10 + type: uint16_t + initial_value: "0" + - id: reg11 + type: uint16_t + initial_value: "0" + - id: reg12 + type: uint16_t + initial_value: "0" + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: |- + id(reg10) = x; + return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: |- + id(reg11) = x; + return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: |- + id(reg12) = x; + return true; + +sensor: + - platform: template + name: "typed_value" + id: typed_value + - platform: template + name: "ack_flag" + id: ack_flag + - platform: template + name: "error_code" + id: error_code + - platform: template + name: "coil_error_code" + id: coil_error_code + - platform: template + name: "multi_value" + id: multi_value + - platform: template + name: "multi_coil_error" + id: multi_coil_error + - platform: template + name: "not_sent_flag" + id: not_sent_flag + +# Typed actions end to end: a typed write lands on the server (ack -> ack_flag), the typed read-back +# decodes the written value from the reply words (values[0] -> typed_value), and a read of an unserved +# register resolves via on_error with the device's exception code (-> error_code). +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - modbus_client.write_single_register: + address: 1 + start_address: 0x10 + value: 777 + on_response: + then: + - lambda: "id(ack_flag).publish_state(1);" + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(typed_value).publish_state(values[0]); + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x99 + on_error: + then: + - lambda: "id(error_code).publish_state((int) exception_code);" + # The mock server maps no bits, so it does not implement the coil function: a coil read draws + # ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery. + - modbus_client.read_coils: + address: 1 + start_address: 0x00 + count: 8 + on_error: + then: + - lambda: "id(coil_error_code).publish_state((int) exception_code);" + # Multi-register write (fc 0x10, served) then read-back of the second written register. + - modbus_client.write_multiple_registers: + address: 1 + start_address: 0x11 + values: [111, 222] + on_response: + then: + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x12 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(multi_value).publish_state(values[0]); + # A count lambda can go out of spec at runtime: the builder rejects it into an empty PDU, the hub + # refuses that at the door, and the send resolves via on_not_sent (no reply will ever come). + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + count: !lambda "return 0;" + on_not_sent: + then: + - lambda: "id(not_sent_flag).publish_state(1);" + # Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION. + - modbus_client.write_multiple_coils: + address: 1 + start_address: 0x00 + values: [true, false, true] + on_error: + then: + - lambda: "id(multi_coil_error).publish_state((int) exception_code);" diff --git a/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml new file mode 100644 index 0000000000..738e691110 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_custom_command.yaml @@ -0,0 +1,87 @@ +esphome: + name: uart-mock-modbus-custom-command + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 259; + +sensor: + # Plain read to confirm the controller <-> server link is up. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "plain_read" + address: 0x01 + register_type: holding + value_type: U_WORD + # Custom command: a raw frame {device address, function code, address hi, address lo, + # count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001, + # count 1; the lambda parses the response payload (the register value, big-endian). + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "custom_read" + custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01] + lambda: |- + if (data.size() < 2) return {}; + return (float) ((data[0] << 8) | data[1]); + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_fairness.yaml b/tests/integration/fixtures/uart_mock_modbus_fairness.yaml new file mode 100644 index 0000000000..e2918c82dd --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_fairness.yaml @@ -0,0 +1,125 @@ +esphome: + name: uart-mock-modbus-fairness + +host: +api: +logger: + # DEBUG (not VERBOSE) keeps the log volume manageable while both controllers + # hammer the bus at a high rate. + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Counters for the number of requests seen on the bus for each device address. +globals: + - id: req_count_1 + type: int + initial_value: "0" + - id: req_count_2 + type: int + initial_value: "0" + +uart_mock: + - id: virtual_uart + baud_rate: 9600 + # auto_start so the mock is ready to deliver injected responses. Polling + # itself is gated by the Start button below. + auto_start: true + debug: + on_tx: + - then: + # Count each outgoing request by device address (byte 0 of the frame). + - lambda: |- + if (data.empty()) + return; + if (data[0] == 0x01) { + id(req_count_1) += 1; + id(requests_1).publish_state(id(req_count_1)); + } else if (data[0] == 0x02) { + id(req_count_2) += 1; + id(requests_2).publish_state(id(req_count_2)); + } + # Reply directly with a canned, CRC-correct "read holding register" + # response for whichever device was addressed (both controllers only + # ever issue this one fixed request, so the responses are constant). + - uart_mock.inject_rx: + id: virtual_uart + data: !lambda |- + if (!data.empty() && data[0] == 0x01) + return {0x01, 0x03, 0x02, 0x00, 0x6F, 0xF8, 0x68}; // value 111 + if (!data.empty() && data[0] == 0x02) + return {0x02, 0x03, 0x02, 0x00, 0xDE, 0x7C, 0x1C}; // value 222 + return {}; + +modbus: + - uart_id: virtual_uart + id: virtual_modbus_client + role: client + turnaround_time: 15ms #This is longer than the polling interval to cause contention + +# Two controllers sharing one client bus, each polling a different device. +# Polling is started by the test (update_interval: never until then) so counting +# only begins once the API client has subscribed. +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + id: modbus_controller_1 + update_interval: never + - address: 2 + modbus_id: virtual_modbus_client + id: modbus_controller_2 + update_interval: never + +sensor: + # These sensors define the register range each controller polls (and so drive + # the requests). Their values are not checked by the test. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: reg_1 + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: reg_2 + address: 0x01 + register_type: holding + value_type: U_WORD + # Request counters exposed to the test. Updated manually from the on_tx hook. + - platform: template + name: requests_1 + id: requests_1 + update_interval: never + - platform: template + name: requests_2 + id: requests_2 + update_interval: never + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Poll much faster than the bus can service so both controllers always + // have a request pending and must contend for the bus. + id(modbus_controller_1).set_update_interval(10); + id(modbus_controller_1).start_poller(); + id(modbus_controller_2).set_update_interval(10); + id(modbus_controller_2).start_poller(); + - platform: template + name: "Stop Scenario" + id: stop_scenario_btn + on_press: + - lambda: |- + id(modbus_controller_1).stop_poller(); + id(modbus_controller_2).stop_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_grouping.yaml b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml new file mode 100644 index 0000000000..a5394f1d05 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_grouping.yaml @@ -0,0 +1,234 @@ +esphome: + name: uart-mock-modbus-group + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + responses: + # One entry per range the controller polls. A frame the controller does not send goes unanswered, + # so these also pin the grouping: an extra or differently shaped read fails the test. + - expect_tx: [0x01, 0x01, 0x00, 0x10, 0x00, 0x02, 0xBC, 0x0E] # coils 0x10 count 2 + inject_rx: [0x01, 0x01, 0x01, 0x01, 0x90, 0x48] # bit0 set, bit1 clear + - expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x01, 0x85, 0xE8] # holding 0x160 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x60, 0xB9, 0xFC] # 352 + - expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x85, 0xF6] # holding 0x100 count 1 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x11, 0x02, 0x22, 0x2A, 0xB3] # 4 bytes: 273 then 546 + - expect_tx: [0x01, 0x03, 0x01, 0x20, 0x00, 0x04, 0x44, 0x3F] # holding 0x120 count 4 + inject_rx: [0x01, 0x03, 0x08, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x7A, 0x25] + - expect_tx: [0x01, 0x03, 0x01, 0x30, 0x00, 0x02, 0xC5, 0xF8] # holding 0x130 count 2 + inject_rx: [0x01, 0x03, 0x06, 0x0A, 0xAA, 0xFF, 0xFF, 0x0B, 0xBB, 0x7E, 0xA0] # 6 bytes + - expect_tx: [0x01, 0x03, 0x01, 0x40, 0x00, 0x01, 0x84, 0x22] # holding 0x140 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x40, 0xB8, 0x24] # 320 + - expect_tx: [0x01, 0x03, 0x01, 0x45, 0x00, 0x01, 0x94, 0x23] # holding 0x145 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x45, 0x78, 0x27] # 325 + - expect_tx: [0x01, 0x03, 0x01, 0x50, 0x00, 0x02, 0xC5, 0xE6] # holding 0x150 count 2 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x50, 0x01, 0x51, 0x3B, 0xB2] # 336, 337 + - expect_tx: [0x01, 0x03, 0x01, 0x80, 0x00, 0x02, 0xC4, 0x1F] # holding 0x180 count 2 + inject_rx: [0x01, 0x03, 0x06, 0x11, 0x11, 0x22, 0x22, 0x33, 0x33, 0x20, 0xA0] # 6 bytes + # 0x181 answers with the same value whether it is read on its own or as part of the block above, + # so the sensor there is pinned to one value regardless of which range it lands in. + - expect_tx: [0x01, 0x03, 0x01, 0x81, 0x00, 0x01, 0xD5, 0xDE] # holding 0x181 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x33, 0x33, 0xEC, 0xA1] # 13107 + - expect_tx: [0x01, 0x03, 0x01, 0x70, 0x00, 0x03, 0x05, 0xEC] # holding 0x170 count 3 + inject_rx: [0x01, 0x03, 0x06, 0x00, 0x2A, 0x1B, 0x2C, 0x03, 0x0D, 0x3E, 0xAB] # 6 bytes + - expect_tx: [0x01, 0x03, 0x01, 0x61, 0x00, 0x01, 0xD4, 0x28] # holding 0x161 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x61, 0x78, 0x3C] # 353 + +modbus: + uart_id: virtual_uart_dev + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + id: modbus_controller_ok + max_cmd_retries: 2 + update_interval: never + +# Each block below is a distinct address range exercising one grouping relationship. The blocks are far +# enough apart that they never merge into each other. +sensor: + # A - two sensors on one register that returns more bytes than its count implies (response_size), + # reading different halves of it. + - platform: modbus_controller + name: "reuse_lo" + address: 0x100 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "reuse_hi" + address: 0x100 + register_type: holding + value_type: U_WORD + offset: 2 + response_size: 4 + modbus_controller_id: modbus_controller_ok + + # C - plain contiguous registers of differing widths. + - platform: modbus_controller + name: "ext_word" + address: 0x120 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "ext_next" + address: 0x121 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "ext_dword" + address: 0x122 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + + # D - a wide (response_size) register followed by a contiguous one: the follower must start after the + # bytes the wide register actually returned, not after 2 * register_count. + - platform: modbus_controller + name: "wide_first" + address: 0x130 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "wide_next" + address: 0x131 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # E - a gap: these must never share a range. + - platform: modbus_controller + name: "gap_low" + address: 0x140 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "gap_high" + address: 0x145 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # F - contiguous registers where the second asks for a slower rate. + - platform: modbus_controller + name: "rate_first" + address: 0x150 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "rate_slow" + address: 0x151 + register_type: holding + value_type: U_WORD + skip_updates: 5 + modbus_controller_id: modbus_controller_ok + + # B - a wide value and one of its halves share a start address, with a contiguous sensor after them. + # The differing offsets give these a defined order, unlike two sensors that differ only in width. + - platform: modbus_controller + name: "shared_dword" + address: 0x170 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "shared_high" + address: 0x170 + register_type: holding + value_type: U_WORD + offset: 2 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "shared_after" + address: 0x172 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # I - a register that returns more bytes than its count implies, sharing its address with a plain + # wider sensor. Whether the sensor after them is read as part of that block or on its own, it must + # decode 0x181 - never the bytes that lie two into the block, which is where the widened register + # count alone would put it. + - platform: modbus_controller + name: "masked_wide" + address: 0x180 + register_type: holding + value_type: U_WORD + response_size: 4 + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "masked_pair" + address: 0x180 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "masked_after" + address: 0x181 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + + # H - a sensor pinned to its own range, followed by a contiguous one. + - platform: modbus_controller + name: "forced_first" + address: 0x160 + register_type: holding + value_type: U_WORD + force_new_range: true + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "forced_next" + address: 0x161 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + +binary_sensor: + # G - contiguous coils, addressed by bit. + - platform: modbus_controller + name: "coil_first" + address: 0x10 + register_type: coil + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "coil_next" + address: 0x11 + register_type: coil + modbus_controller_id: modbus_controller_ok + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_offline.yaml b/tests/integration/fixtures/uart_mock_modbus_offline.yaml new file mode 100644 index 0000000000..e4d2dfa294 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_offline.yaml @@ -0,0 +1,95 @@ +esphome: + name: uart-mock-modbus-offline + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Whether the mock device answers requests. Starts false so the controller +# runs through its retries and goes offline; the test flips it via the +# "Serve" button to exercise the offline retry/recovery path. +globals: + - id: serve + type: bool + initial_value: "false" + +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + on_tx: + # While serve is false every request times out; once true, answer the + # (only) request - read holding register 3 on device 1 - with value 259. + - uart_mock.inject_rx: + id: virtual_uart + data: !lambda |- + if (!id(serve)) + return {}; + return {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + +modbus: + - uart_id: virtual_uart + id: virtual_modbus_client + send_wait_time: 100ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + id: ctl + max_cmd_retries: 1 + # offline_skip_updates and the sensor's skip_updates deliberately share a period: offline + # probing must follow the offline cadence alone, or phase combinations like this one can + # leave the device never probing again. + offline_skip_updates: 1 + update_interval: never + on_offline: + then: + - lambda: id(link_state).publish_state(0); + on_online: + then: + - lambda: id(link_state).publish_state(1); + +sensor: + - platform: modbus_controller + modbus_controller_id: ctl + name: reg + id: reg + address: 0x03 + register_type: holding + value_type: U_WORD + skip_updates: 1 + # Mirrors the controller's online state so the test can await the transitions. + - platform: template + name: link_state + id: link_state + update_interval: never + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(ctl).set_update_interval(200); + id(ctl).start_poller(); + - platform: template + name: "Serve" + id: serve_btn + on_press: + - globals.set: + id: serve + value: "true" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 20306bd73a..4a5d280a2f 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -64,9 +64,15 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 99; + - address: 0x02 + value_type: U_WORD_S + read_lambda: return 4660; - address: 0x03 value_type: S_WORD read_lambda: return -99; + - address: 0x04 + value_type: S_WORD_S + read_lambda: return -2; - address: 0x05 value_type: U_DWORD read_lambda: return 16909060; @@ -105,12 +111,30 @@ sensor: address: 0x01 register_type: holding value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s_raw" + address: 0x02 + register_type: holding + value_type: U_WORD - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_s_word" address: 0x03 register_type: holding value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_u_dword" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml new file mode 100644 index 0000000000..cb6fc6f074 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml @@ -0,0 +1,147 @@ +esphome: + name: uart-mock-modbus-srv-bits + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; + +# The same four bits are read both as coils (FC 0x01) and as discrete inputs +# (FC 0x02): the server serves both from one shared bit table, so the two +# views must always agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index b3b5e76e31..5ade49bd48 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -45,9 +45,15 @@ globals: - id: stored_u_word type: uint16_t initial_value: "11" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" - id: stored_s_word type: int16_t initial_value: "-11" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" - id: stored_u_dword type: uint32_t initial_value: "1001" @@ -103,10 +109,18 @@ modbus_server: value_type: U_WORD read_lambda: return id(stored_u_word); write_lambda: id(stored_u_word) = x; return true; + - address: 0x02 + value_type: U_WORD_S + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD read_lambda: return id(stored_s_word); write_lambda: id(stored_s_word) = x; return true; + - address: 0x04 + value_type: S_WORD_S + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD read_lambda: return id(stored_u_dword); @@ -155,12 +169,24 @@ sensor: address: 0x01 register_type: holding value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_s_word" address: 0x03 register_type: holding value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "reg_u_dword" @@ -231,6 +257,14 @@ number: value_type: U_WORD min_value: 0 max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "write_s_word" @@ -239,6 +273,14 @@ number: value_type: S_WORD min_value: -16777215 max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 - platform: modbus_controller modbus_controller_id: modbus_controller_1 name: "write_u_dword" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml new file mode 100644 index 0000000000..e998861c2d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml @@ -0,0 +1,106 @@ +esphome: + name: uart-mock-modbus-srv-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # FC 0x17 Read/Write Multiple Registers on device 1: + # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2). + # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must + # read back the just-written 0x1234 in the same request. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] + # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) - + # a write and read targeting a different register block. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10] + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + - id: stored_3 + type: uint16_t + initial_value: "0" + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # Writable + readable register backed by a global. The read publishes what it + # returns so the test can confirm the write half ran before the read half. + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(rw_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(rw_write_1).publish_state(x); + return true; + # Read-only register, read together with 0x01 by the first request's 2-register read. + - address: 0x02 + value_type: U_WORD + read_lambda: |- + id(rw_read_2).publish_state(0x00AA); + return 0x00AA; + # Second writable + readable register, targeted by the second request. + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(rw_read_3).publish_state(id(stored_3)); + return id(stored_3); + write_lambda: |- + id(stored_3) = x; + id(rw_write_3).publish_state(x); + return true; + +sensor: + - platform: template + name: "rw_write_1" + id: rw_write_1 + - platform: template + name: "rw_read_1" + id: rw_read_1 + - platform: template + name: "rw_read_2" + id: rw_read_2 + - platform: template + name: "rw_write_3" + id: rw_write_3 + - platform: template + name: "rw_read_3" + id: rw_read_3 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml new file mode 100644 index 0000000000..d3c091d67d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml @@ -0,0 +1,81 @@ +esphome: + name: uart-mock-modbus-srv-rw-inv + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # Malformed FC 0x17 Read/Write Multiple Registers, otherwise well formed (valid CRC): write + # quantity 2 but byte count 2 (2 registers need 4 bytes), i.e. byte count != 2x write quantity. + # The hub must reject it (ILLEGAL_DATA_VALUE) before touching any register. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02, 0x02, 0x12, 0x34, 0x09, 0x89] + # A valid FC 0x03 read of reg 0x0A injected afterwards. Its read_lambda fires the "probe" + # sensor, which (because injections run in order) signals the malformed frame was processed. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # The malformed request's write half spans 0x01-0x02. Both are registered so modbus_server's + # address pre-flight cannot reject the frame on its own: if the hub wrongly accepted it, these + # write_lambdas would fire the "write_seen" sensor. + - address: 0x01 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + - address: 0x02 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + # Processing probe: a valid read of this register fires after the malformed frame. + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(probe).publish_state(1); + return 1; + +sensor: + - platform: template + name: "write_seen" + id: write_seen + - platform: template + name: "probe" + id: probe + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml new file mode 100644 index 0000000000..25574d0c42 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_shared_address.yaml @@ -0,0 +1,160 @@ +esphome: + name: uart-mock-modbus-shared + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + responses: + # Three sensors, one frame. At 0x9001 a U_WORD (1 register) and a U_DWORD (2 registers) share the + # start address but cannot merge, so the range widens to count 2. A third sensor at 0x9002 falls + # inside the widened range and must read its slice of the same response rather than splitting into + # a second overlapping poll. The single expect_tx pins the "one frame on the wire" contract - any + # duplicate or overlapping range would put an extra frame on the bus and fail to match. + - expect_tx: [0x01, 0x03, 0x90, 0x01, 0x00, 0x02, 0xB8, 0xCB] # Read holding 0x9001 count 2 on device 1 + inject_rx: [0x01, 0x03, 0x04, 0x03, 0x97, 0x02, 0x91, 0x8B, 0x57] # 0x9001=0x0397, 0x9002=0x0291 + # A force_new_range sensor at a HIGH address (0x30) sorts before the plain sensor at a LOW address + # (0x10). The two must poll as separate ranges: the covered branch's lower-bound check prevents the + # 0x10 sensor from being absorbed into the forced 0x30 range with a wrapped byte offset. + - expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # Read holding 0x30 count 1 (forced range) + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x11, 0x79, 0xD8] # 0x30 = 0x0111 = 273 + - expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # Read holding 0x10 count 1 (own range) + inject_rx: [0x01, 0x03, 0x02, 0x02, 0x22, 0x39, 0x3D] # 0x10 = 0x0222 = 546 + # A wide sensor (U_QWORD at 0x100, 4 registers) followed by plain sensors at 0x101 and 0x103. + # None of them merge, so all three poll separately - exactly as before the range refactor. The + # 0x103 sensor sits at the wide range's tail address, so it must not anchor a re-use join on a + # mid-range predecessor and inherit its byte offset. + - expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x04, 0x45, 0xF5] # Read holding 0x100 count 4 + inject_rx: [0x01, 0x03, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x94, 0x3C] # = 100 + - expect_tx: [0x01, 0x03, 0x01, 0x01, 0x00, 0x01, 0xD4, 0x36] # Read holding 0x101 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0x41, 0x79, 0xE4] # 0x101 = 0x0141 = 321 + - expect_tx: [0x01, 0x03, 0x01, 0x03, 0x00, 0x01, 0x75, 0xF6] # Read holding 0x103 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x01, 0xA5, 0x79, 0xAF] # 0x103 = 0x01A5 = 421 + # A widened shared-address range at 0x200 plus a sensor at 0x201 carrying its own skip_updates. + # The sensor must keep its own range so the polling rates stay independent; if it were folded into + # the widened range it would decode 0x201 from THAT response (2, not 777) and drag the range's + # rate down to its own. + - expect_tx: [0x01, 0x03, 0x02, 0x00, 0x00, 0x02, 0xC5, 0xB3] # Read holding 0x200 count 2 + inject_rx: [0x01, 0x03, 0x04, 0x01, 0x41, 0x00, 0x02, 0x2A, 0x1A] # 0x200=0x0141, 0x201=0x0002 + - expect_tx: [0x01, 0x03, 0x02, 0x01, 0x00, 0x01, 0xD4, 0x72] # Read holding 0x201 count 1 + inject_rx: [0x01, 0x03, 0x02, 0x03, 0x09, 0x78, 0xB2] # 0x201 = 0x0309 = 777 + +modbus: + uart_id: virtual_uart_dev + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 1 + id: modbus_controller_ok + max_cmd_retries: 2 + update_interval: never + +sensor: + # Word sensor at 0x9001 (1 register) + - platform: modbus_controller + name: "shared_word" + address: 0x9001 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Dword sensor at the SAME address 0x9001 (2 registers) - non-mergeable, shares the range start + - platform: modbus_controller + name: "shared_dword" + address: 0x9001 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + # Word sensor at 0x9002 - inside the widened range, reads bytes 2-3 of the same response + - platform: modbus_controller + name: "covered_word" + address: 0x9002 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Forced sensor at a high address: sorts first, opens its own isolated range + - platform: modbus_controller + name: "forced_high" + address: 0x30 + register_type: holding + value_type: U_WORD + force_new_range: true + modbus_controller_id: modbus_controller_ok + # Plain sensor at a lower address: must get its own range, never absorbed into the forced one + - platform: modbus_controller + name: "plain_low" + address: 0x10 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Wide sensor spanning 0x100-0x103; the two sensors below sit inside its span but do not merge + - platform: modbus_controller + name: "wide_qword" + address: 0x100 + register_type: holding + value_type: U_QWORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "inside_wide" + address: 0x101 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # At the wide range's tail address: must decode its own poll, not inherit a mid-range byte offset + - platform: modbus_controller + name: "tail_of_wide" + address: 0x103 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + # Shared address 0x200: the dword widens the range the word opened (or vice versa) + - platform: modbus_controller + name: "rate_word" + address: 0x200 + register_type: holding + value_type: U_WORD + modbus_controller_id: modbus_controller_ok + - platform: modbus_controller + name: "rate_dword" + address: 0x200 + register_type: holding + value_type: U_DWORD + modbus_controller_id: modbus_controller_ok + # Inside the widened range but with its own skip_updates: must NOT be folded in, or the two sensors + # above would silently drop to this sensor's polling rate + - platform: modbus_controller + name: "own_rate" + address: 0x201 + register_type: holding + value_type: U_WORD + skip_updates: 100 + modbus_controller_id: modbus_controller_ok + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index f835bee3bc..c7f21d8a01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) +def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: + """Write preference entries, replacing the file's contents. + + Returns the path that was written. + """ + payload = b"" + for key, data in entries.items(): + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - path = host_prefs_path(device_name) - path.parent.mkdir(parents=True, exist_ok=True) - payload = struct.pack(" None: - """State callback suitable for ``subscribe_states``.""" - if not isinstance(state, SensorState) or state.missing_state: + def on_state(self, state: EntityState, first_pending_only: bool = False) -> None: + """State callback suitable for ``subscribe_states``. + + Args: + state: The state update to record + first_pending_only: Only allow the first pending expectation for this + sensor to match, instead of the first matching one. Used for + connect-time states so they cannot satisfy a later phase. + """ + if ( + not isinstance(state, (SensorState, BinarySensorState)) + or state.missing_state + ): return sensor_name = self.key_to_sensor.get(state.key) if not sensor_name or sensor_name not in self.sensor_states: return self.sensor_states[sensor_name].append(state.state) for expected_value, future in self._expectations.get(sensor_name, []): - if not future.done() and ( - expected_value is self._ANY or state.state == expected_value - ): + if future.done(): + continue + if expected_value is self._ANY or state.state == expected_value: future.set_result(True) break + if first_pending_only: + break async def await_change( self, future: asyncio.Future, name: str, timeout: float = 2.0 @@ -470,8 +483,22 @@ class SensorTracker: for name, future in futures.items(): await self.await_change(future, name, timeout=timeout) - async def setup_and_start_scenario(self, client) -> list: - """Wire up subscriptions, wait for initial states, press Start Scenario.""" + async def setup_and_start_scenario( + self, client: APIClient, match_initial_states: bool = False + ) -> list[EntityInfo]: + """Wire up subscriptions, wait for initial states, press Start Scenario. + + Args: + client: The connected API client + match_initial_states: Also match expectations against the states the + device sends when the client connects, so a value published before + the client subscribed still counts. Binary sensors need this: they + drop repeats, so a value that lands in the connect-time dump is + never sent again. Plain sensors publish on every poll, so there it + only saves waiting for the next one. Only the first pending + expectation per sensor can match, so a connect-time value cannot + satisfy a later phase. + """ entities, _ = await client.list_entities_services() self.key_to_sensor.update( build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) @@ -484,6 +511,9 @@ class SensorTracker: import pytest pytest.fail("Timeout waiting for initial states") + if match_initial_states: + for state in initial_state_helper.initial_states.values(): + self.on_state(state, first_pending_only=True) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" client.button_command(start_btn.key) diff --git a/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py new file mode 100644 index 0000000000..e8b08f2265 --- /dev/null +++ b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py @@ -0,0 +1,63 @@ +"""Regression test for blocking-warning log time attribution.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) +COMPLETE_PATTERN = re.compile(r"BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=(\d+)") +PRIMARY_SOURCES = {"blocking_60", "blocking_90", "blocking_120"} + + +@pytest.mark.asyncio +async def test_blocking_warning_log_time_not_charged_to_next_operation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Synchronous warning-log delays must not be charged to the next operation.""" + loop = asyncio.get_running_loop() + complete = asyncio.Event() + warnings: list[tuple[str, int, int]] = [] + injected_delay_total = 0 + + def check_output(line: str) -> None: + nonlocal injected_delay_total + if match := WARN_PATTERN.search(line): + warnings.append((match.group(1), int(match.group(2)), int(match.group(3)))) + if match := COMPLETE_PATTERN.search(line): + injected_delay_total = int(match.group(1)) + loop.call_soon_threadsafe(complete.set) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + await asyncio.wait_for(complete.wait(), timeout=10.0) + + assert injected_delay_total == 270, ( + f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms" + ) + + primary_warnings = [ + warning for warning in warnings if warning[0] in PRIMARY_SOURCES + ] + assert {warning[0] for warning in primary_warnings} == PRIMARY_SOURCES, ( + f"Expected one real blocking warning from each test script, got: {warnings}" + ) + + secondary_warnings = [ + warning for warning in warnings if warning[0] not in PRIMARY_SOURCES + ] + assert not secondary_warnings, ( + "Warning-handler time was incorrectly charged to the next operation: " + f"{secondary_warnings}" + ) diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py index 23e8ca04c2..0c2848a20c 100644 --- a/tests/integration/test_fnv1_hash_object_id.py +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -37,6 +37,10 @@ async def test_fnv1_hash_object_id( "special", "complex", "empty", + "raw", + "raw_utf8", + "old_utf8", + "old_cjk", } def on_log_line(line: str) -> None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index c8603e0682..8dafb37c64 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ hash generation (fnv1_hash_object_id in helpers.h) -3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) +2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) +3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_object_id(entity_name) + hash_from_name = fnv1_hash_name(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_object_id(expected_name) + expected_hash = fnv1_hash_name(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index 7199a2b371..b58593f2ef 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_object_id("My Friendly Device") + expected_hash = fnv1_hash_name("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index b548f02fde..45b5f730a6 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id +from esphome.helpers import fnv1_hash_name from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_object_id() with fallback to CORE.name + - Python used get_base_entity_name() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_object_id("test-device") + expected_hash = fnv1_hash_name("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py new file mode 100644 index 0000000000..e7f699bb12 --- /dev/null +++ b/tests/integration/test_preference_key_migration.py @@ -0,0 +1,165 @@ +"""Integration test for entity preference key migration. + +Entity keys are now the FNV-1 hash of the raw name instead of the sanitized +object_id (https://github.com/esphome/backlog/issues/85). On key-lookup +preference backends, make_entity_preference() must move data stored under the +old key to the new key, so devices keep their restored state after upgrading. + +This test seeds the host preferences file the way a pre-migration firmware +would have written it and verifies: +1. Data stored under the OLD key is restored (migration happened, no data loss) +2. Data already stored under the NEW key is never overwritten by old data +""" + +from __future__ import annotations + +import socket +import struct + +from aioesphomeapi import ( + NumberInfo, + NumberState, + SwitchInfo, + SwitchState, + TextInfo, + TextState, +) +import pytest + +from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-pref-key-migration" + +# The pre-migration preference key was the sanitized object_id hash; the new +# key is the raw-name hash. All entities are on the main device (device_id 0) +# and their preferences use no version salt, so the key is just the hash. +SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") +SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") +NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") +NUMBER_NEW_KEY = fnv1_hash_name("Test Number") + +# template_text salts its key with the length limits and pattern hash; this must +# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, +# no pattern configured) +TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) +TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF +TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF + +# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes +TEXT_MAX_LENGTH = 20 + + +def text_pref_payload(value: str) -> bytes: + """Build the length-prefixed buffer TextSaver stores for a value.""" + data = value.encode("utf-8") + assert len(data) <= TEXT_MAX_LENGTH + return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) + + +@pytest.mark.asyncio +async def test_preference_key_migration( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test that preferences stored under the old key survive the upgrade.""" + port, port_socket = reserved_tcp_port + + assert SWITCH_OLD_KEY != SWITCH_NEW_KEY + assert NUMBER_OLD_KEY != NUMBER_NEW_KEY + assert TEXT_OLD_KEY != TEXT_NEW_KEY + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def boot_and_get_initial_states() -> tuple[ + SwitchState, NumberState, TextState + ]: + """Boot the binary and return the restored entity states.""" + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + + entities, _ = await client.list_entities_services() + switch_entity = require_entity( + entities, "test_switch", SwitchInfo, "Test Switch" + ) + number_entity = require_entity( + entities, "test_number", NumberInfo, "Test Number" + ) + text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda s: None) + ) + await initial_state_helper.wait_for_initial_states() + + switch_state = initial_state_helper.initial_states[switch_entity.key] + number_state = initial_state_helper.initial_states[number_entity.key] + text_state = initial_state_helper.initial_states[text_entity.key] + assert isinstance(switch_state, SwitchState) + assert isinstance(number_state, NumberState) + assert isinstance(text_state, TextState) + return switch_state, number_state, text_state + + try: + # --- Run 1: only OLD keys present, as written by pre-migration firmware. + # The restored states prove the data was migrated to the new keys. + write_host_prefs( + DEVICE_NAME, + { + SWITCH_OLD_KEY: b"\x01", # bool: switch was ON + NUMBER_OLD_KEY: struct.pack(" None: - nonlocal timeout_count, interval_count, retry_count, defer_count - nonlocal numeric_interval_count, numeric_retry_count + nonlocal timeout_count, interval_count, defer_count + nonlocal numeric_interval_count # Strip ANSI color codes clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) @@ -97,18 +93,6 @@ async def test_scheduler_numeric_id_test( max_id_timeout_fired.set() timeout_count += 1 - # Check for numeric retry tests - elif "Numeric retry 6001 attempt" in clean_line: - match = re.search(r"attempt (\d+)", clean_line) - if match: - numeric_retry_count = int(match.group(1)) - - elif "Numeric retry 6001 done" in clean_line: - numeric_retry_done.set() - - elif "Cancelled numeric retry 6002" in clean_line: - numeric_retry_cancelled.set() - # Check for numeric defer tests elif "Component numeric defer 7001 fired" in clean_line: numeric_defer_7001_fired.set() @@ -122,14 +106,13 @@ async def test_scheduler_numeric_id_test( # Check for final results elif "Final results" in clean_line: match = re.search( - r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)", + r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)", clean_line, ) if match: timeout_count = int(match.group(1)) interval_count = int(match.group(2)) - retry_count = int(match.group(3)) - defer_count = int(match.group(4)) + defer_count = int(match.group(3)) final_results_logged.set() async with ( @@ -200,23 +183,6 @@ async def test_scheduler_numeric_id_test( except TimeoutError: pytest.fail("Max ID timeout did not fire within 0.5 seconds") - # Wait for numeric retry tests - try: - await asyncio.wait_for(numeric_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Numeric retry 6001 did not complete. Count: {numeric_retry_count}" - ) - - assert numeric_retry_count >= 2, ( - f"Expected at least 2 numeric retry attempts, got {numeric_retry_count}" - ) - - # Verify numeric retry was cancelled - assert numeric_retry_cancelled.is_set(), ( - "Numeric retry 6002 should have been cancelled" - ) - # Wait for numeric defer tests try: await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5) @@ -245,7 +211,4 @@ async def test_scheduler_numeric_id_test( assert interval_count >= 3, ( f"Expected at least 3 interval fires, got {interval_count}" ) - assert retry_count >= 2, ( - f"Expected at least 2 retry attempts, got {retry_count}" - ) assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}" diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py deleted file mode 100644 index 910034e5bb..0000000000 --- a/tests/integration/test_scheduler_retry_test.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Test scheduler retry functionality.""" - -import asyncio -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_retry_test( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler retry functionality works correctly.""" - # Track test progress - simple_retry_done = asyncio.Event() - backoff_retry_done = asyncio.Event() - immediate_done_done = asyncio.Event() - cancel_retry_done = asyncio.Event() - empty_name_retry_done = asyncio.Event() - component_retry_done = asyncio.Event() - multiple_name_done = asyncio.Event() - const_char_done = asyncio.Event() - static_char_done = asyncio.Event() - test_complete = asyncio.Event() - - # Track retry counts - simple_retry_count = 0 - backoff_retry_count = 0 - immediate_done_count = 0 - cancel_retry_count = 0 - empty_name_retry_count = 0 - component_retry_count = 0 - multiple_name_count = 0 - const_char_retry_count = 0 - static_char_retry_count = 0 - - # Track specific test results - cancel_result = None - empty_cancel_result = None - backoff_intervals = [] - - def on_log_line(line: str) -> None: - nonlocal simple_retry_count, backoff_retry_count, immediate_done_count - nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count - nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count - nonlocal cancel_result, empty_cancel_result - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Simple retry test - if "Simple retry attempt" in clean_line: - if match := re.search(r"Simple retry attempt (\d+)", clean_line): - simple_retry_count = int(match.group(1)) - - elif "Simple retry succeeded on attempt" in clean_line: - simple_retry_done.set() - - # Backoff retry test - elif "Backoff retry attempt" in clean_line: - if match := re.search( - r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line - ): - backoff_retry_count = int(match.group(1)) - interval = int(match.group(2)) - if backoff_retry_count > 1: # Skip first (immediate) call - backoff_intervals.append(interval) - - elif "Backoff retry completed" in clean_line: - backoff_retry_done.set() - - # Immediate done test - elif "Immediate done retry called" in clean_line: - immediate_done_count += 1 - immediate_done_done.set() - - # Cancel retry test - elif "Cancel test retry attempt" in clean_line: - cancel_retry_count += 1 - - elif "Retry cancellation result:" in clean_line: - cancel_result = "true" in clean_line - cancel_retry_done.set() - - # Empty name retry test - elif "Empty name retry attempt" in clean_line: - if match := re.search(r"Empty name retry attempt (\d+)", clean_line): - empty_name_retry_count = int(match.group(1)) - - elif "Empty name retry cancel result:" in clean_line: - empty_cancel_result = "true" in clean_line - - elif "Empty name retry ran" in clean_line: - empty_name_retry_done.set() - - # Component retry test - elif "Component retry attempt" in clean_line: - if match := re.search(r"Component retry attempt (\d+)", clean_line): - component_retry_count = int(match.group(1)) - if component_retry_count >= 2: - component_retry_done.set() - - # Multiple same name test - elif "Second duplicate retry attempt" in clean_line: - if match := re.search(r"counter=(\d+)", clean_line): - multiple_name_count = int(match.group(1)) - if multiple_name_count >= 20: - multiple_name_done.set() - - # Const char retry test - elif "Const char retry" in clean_line: - if match := re.search(r"Const char retry (\d+)", clean_line): - const_char_retry_count = int(match.group(1)) - const_char_done.set() - - # Static const char retry test - elif "Static const char retry" in clean_line: - if match := re.search(r"Static const char retry (\d+)", clean_line): - static_char_retry_count = int(match.group(1)) - static_char_done.set() - - elif "Static cancel result:" in clean_line: - # This is part of test 9, but we don't track it separately - pass - - # Test completion - elif "All retry tests completed" in clean_line: - test_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-retry-test" - - # Wait for simple retry test - try: - await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Simple retry test did not complete. Count: {simple_retry_count}" - ) - - assert simple_retry_count == 2, ( - f"Expected 2 simple retry attempts, got {simple_retry_count}" - ) - - # Wait for backoff retry test - try: - await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Backoff retry test did not complete. Count: {backoff_retry_count}" - ) - - assert backoff_retry_count == 4, ( - f"Expected 4 backoff retry attempts, got {backoff_retry_count}" - ) - - # Verify backoff intervals (allowing for timing variations) - assert len(backoff_intervals) >= 2, ( - f"Expected at least 2 intervals, got {len(backoff_intervals)}" - ) - if len(backoff_intervals) >= 3: - # First interval should be ~50ms (very wide tolerance for heavy system load) - assert 20 <= backoff_intervals[0] <= 150, ( - f"First interval {backoff_intervals[0]}ms not ~50ms" - ) - # Second interval should be ~100ms (50ms * 2.0) - assert 50 <= backoff_intervals[1] <= 250, ( - f"Second interval {backoff_intervals[1]}ms not ~100ms" - ) - # Third interval should be ~200ms (100ms * 2.0) - assert 100 <= backoff_intervals[2] <= 500, ( - f"Third interval {backoff_intervals[2]}ms not ~200ms" - ) - - # Wait for immediate done test - try: - await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Immediate done test did not complete. Count: {immediate_done_count}" - ) - - assert immediate_done_count == 1, ( - f"Expected 1 immediate done call, got {immediate_done_count}" - ) - - # Wait for cancel retry test - try: - await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0) - except TimeoutError: - pytest.fail( - f"Cancel retry test did not complete. Count: {cancel_retry_count}" - ) - - assert cancel_result is True, "Retry cancellation should have succeeded" - assert 2 <= cancel_retry_count <= 5, ( - f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}" - ) - - # Wait for empty name retry test - try: - await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Empty name retry test did not complete. Count: {empty_name_retry_count}" - ) - - # Empty name retry should run at least once before being cancelled - assert 1 <= empty_name_retry_count <= 3, ( - f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}" - ) - assert empty_cancel_result is True, ( - "Empty name retry cancel should have succeeded" - ) - - # Wait for component retry test - try: - await asyncio.wait_for(component_retry_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Component retry test did not complete. Count: {component_retry_count}" - ) - - assert component_retry_count >= 2, ( - f"Expected at least 2 component retry attempts, got {component_retry_count}" - ) - - # Wait for multiple same name test - try: - await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Multiple same name test did not complete. Count: {multiple_name_count}" - ) - - # Should be 20+ (only second retry should run) - assert multiple_name_count >= 20, ( - f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" - ) - - # Wait for const char retry test - try: - await asyncio.wait_for(const_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Const char retry test did not complete. Count: {const_char_retry_count}" - ) - - assert const_char_retry_count == 1, ( - f"Expected 1 const char retry call, got {const_char_retry_count}" - ) - - # Wait for static char retry test - try: - await asyncio.wait_for(static_char_done.wait(), timeout=1.0) - except TimeoutError: - pytest.fail( - f"Static char retry test did not complete. Count: {static_char_retry_count}" - ) - - assert static_char_retry_count == 1, ( - f"Expected 1 static char retry call, got {static_char_retry_count}" - ) - - # Wait for test completion - try: - await asyncio.wait_for(test_complete.wait(), timeout=1.0) - except TimeoutError: - pytest.fail("Test did not complete within timeout") diff --git a/tests/integration/test_script_queued.py b/tests/integration/test_script_queued.py index 84c7f950b6..db4621a507 100644 --- a/tests/integration/test_script_queued.py +++ b/tests/integration/test_script_queued.py @@ -26,6 +26,7 @@ async def test_script_queued( "stop": {"processed": [], "stop_logged": False}, "rejection": {"processed": [], "rejections": 0}, "no_params": {"executions": 0}, + "boot": {"ended": []}, } # Patterns for Test 1: Queue depth @@ -49,12 +50,21 @@ async def test_script_queued( # Patterns for Test 5: No params no_params_end = re.compile(r"No params: END") + # Patterns for boot script (executed twice from on_boot before setup) + boot_end = re.compile(r"Boot queued: END (\d+)") + + # Patterns for Test 6: Re-execute after stop + after_stop_end = re.compile(r"Stop test: END (\d+)") + # Test completion futures + boot_complete = loop.create_future() test1_complete = loop.create_future() test2_complete = loop.create_future() test3_complete = loop.create_future() test4_complete = loop.create_future() test5_complete = loop.create_future() + test5_again_complete = loop.create_future() + test6_complete = loop.create_future() def check_output(line: str) -> None: """Check log output for all test messages.""" @@ -122,11 +132,24 @@ async def test_script_queued( # Test 5: No params if no_params_end.search(line): test_results["no_params"]["executions"] += 1 - if ( - test_results["no_params"]["executions"] == 3 - and not test5_complete.done() - ): - test5_complete.set_result(True) + executions = test_results["no_params"]["executions"] + for count, future in ((3, test5_complete), (6, test5_again_complete)): + if executions == count and not future.done(): + future.set_result(True) + + # Boot script (queued from on_boot before setup) + if match := boot_end.search(line): + test_results["boot"]["ended"].append(int(match.group(1))) + if len(test_results["boot"]["ended"]) == 2 and not boot_complete.done(): + boot_complete.set_result(True) + + # Test 6: Re-execute after stop + if ( + (match := after_stop_end.search(line)) + and int(match.group(1)) == 9 + and not test6_complete.done() + ): + test6_complete.set_result(True) async with ( run_compiled(yaml_config, line_callback=check_output), @@ -135,6 +158,13 @@ async def test_script_queued( # Get services _, services = await client.list_entities_services() + # Boot: both executions from on_boot must complete, including the one + # that was queued before QueueingScript::setup() ran + await asyncio.wait_for(boot_complete, timeout=2.0) + assert sorted(test_results["boot"]["ended"]) == [1, 2], ( + f"Boot: Expected both on_boot executions to complete, got {sorted(test_results['boot']['ended'])}" + ) + # Test 1: Queue depth limit test_service = next((s for s in services if s.name == "test_queue_depth"), None) assert test_service is not None, "test_queue_depth service not found" @@ -203,3 +233,20 @@ async def test_script_queued( assert test_results["no_params"]["executions"] == 3, ( f"Test 5: Expected 3 executions, got {test_results['no_params']['executions']}" ) + + # Test 5 again: after the queue fully drained (loop disabled while + # idle), executing again must still work + test_service = next((s for s in services if s.name == "test_no_params"), None) + assert test_service is not None, "test_no_params service not found" + await client.execute_service(test_service, {}) + await asyncio.wait_for(test5_again_complete, timeout=2.0) + assert test_results["no_params"]["executions"] == 6, ( + f"Test 5 again: Expected 6 executions total, got {test_results['no_params']['executions']}" + ) + + # Test 6: a stopped script (queue cleared, loop disabled) must run + # again on the next execute; the future resolves only on "END 9" + test_service = next((s for s in services if s.name == "test_after_stop"), None) + assert test_service is not None, "test_after_stop service not found" + await client.execute_service(test_service, {}) + await asyncio.wait_for(test6_complete, timeout=2.0) diff --git a/tests/integration/test_script_queued_idle_loop.py b/tests/integration/test_script_queued_idle_loop.py new file mode 100644 index 0000000000..44f0ab7ec6 --- /dev/null +++ b/tests/integration/test_script_queued_idle_loop.py @@ -0,0 +1,85 @@ +"""Test that an idle queued script disables its loop and re-enables on demand.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_queued_idle_loop( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Assert the loop state transitions of a queued script via VV logs. + + Expected sequence: the idle script disables its loop on the first + iteration after boot, re-enables it when an instance gets queued, + and disables it again once the queue drains. + """ + loop = asyncio.get_running_loop() + + loop_state = re.compile(r"\bscript loop (disabled|enabled)\b") + script_end = re.compile(r"idle_script: END") + + transitions: list[str] = [] + end_count = 0 + + boot_disabled = loop.create_future() + enabled_after_queue = loop.create_future() + disabled_after_drain = loop.create_future() + runs_complete = loop.create_future() + + def check_output(line: str) -> None: + nonlocal end_count + if match := loop_state.search(line): + transitions.append(match.group(1)) + if transitions == ["disabled"] and not boot_disabled.done(): + boot_disabled.set_result(True) + elif ( + transitions == ["disabled", "enabled"] + and not enabled_after_queue.done() + ): + enabled_after_queue.set_result(True) + elif ( + transitions + == [ + "disabled", + "enabled", + "disabled", + ] + and not disabled_after_drain.done() + ): + disabled_after_drain.set_result(True) + + if script_end.search(line): + end_count += 1 + if end_count == 2 and not runs_complete.done(): + runs_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # The idle script must disable its loop on the first iteration + await asyncio.wait_for(boot_disabled, timeout=5.0) + + _, services = await client.list_entities_services() + run_twice = next((s for s in services if s.name == "run_twice"), None) + assert run_twice is not None, "run_twice service not found" + await client.execute_service(run_twice, {}) + + # Queueing the second instance must re-enable the loop + await asyncio.wait_for(enabled_after_queue, timeout=2.0) + # Both runs must complete and the drained queue must disable it again + await asyncio.wait_for(runs_complete, timeout=2.0) + await asyncio.wait_for(disabled_after_drain, timeout=2.0) + + assert transitions == ["disabled", "enabled", "disabled"], ( + f"Unexpected loop state sequence: {transitions}" + ) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6..d0b375dd25 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -9,6 +9,10 @@ test_uart_mock_modbus_no_threshold : Test modbus with no rx_full_threshold set (simulating USB UART / non-hardware UART). Verifies the 50ms fallback timeout handles chunked data with USB packet gaps. +test_uart_mock_modbus_fairness : + Two controllers sharing one client bus, both polling far faster than the bus + can service. Verifies the hub schedules them fairly (request counts within 1). + """ from __future__ import annotations @@ -17,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest from .state_utils import SensorTracker, find_entity @@ -199,6 +203,99 @@ async def test_uart_mock_modbus_server( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 (read/write multiple registers). + + Injects raw 0x17 request frames and checks the round-trip through the + server's read_lambda/write_lambda, independent of how the hub dispatches + 0x17 internally: + * one request writes reg 0x01 then reads regs 0x01+0x02 -- reg 0x01 reads + back the just-written value (the write happens before the read per + Modbus 6.17), and the second register is returned by the same + multi-register read; + * a second request writes and reads a different register block. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["rw_write_1", "rw_read_1", "rw_read_2", "rw_write_3", "rw_read_3"] + ) + futures = tracker.expect_all( + { + "rw_write_1": 4660, # 0x1234 written to reg 0x0001 + "rw_read_1": 4660, # reg 0x0001 reads back the just-written value + "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request + "rw_write_3": 22136, # 0x5678 written to reg 0x0003 + "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write_invalid( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 invalid-frame handling. + + Injects a well-formed (valid CRC) 0x17 request whose write byte count (2) + does not match 2x the write quantity (2 registers need 4 bytes), so the hub + must reject it with ILLEGAL_DATA_VALUE before touching any register. A valid + read is injected right after as a processing marker. + + The invalid frame is verified via bus-level signals rather than the reply + frame on the wire: the mock UART cannot observe the server's TX reliably on + the host platform (the server's transmission is gated by a millis()-based tx + delay), so instead we assert the request is rejected exactly once and never + applied to a register. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker(["write_seen", "probe"]) + probe_seen = tracker.expect("probe", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # The probe read is injected after the malformed frame, so once it fires + # the malformed frame has already been processed. + await tracker.await_change(probe_seen, "probe") + + # Exactly one bus-level rejection for the malformed frame (no cascade)... + invalid_warnings = [ + line for line in warning_log_lines if "Invalid number of registers" in line + ] + assert len(invalid_warnings) == 1, ( + "Expected exactly one invalid-frame rejection, got warnings:\n" + + "\n".join(warning_log_lines) + ) + assert len(error_log_lines) == 0, ( + "Expected no modbus errors, but got:\n" + "\n".join(error_log_lines) + ) + # ...and the rejected write is never applied to the target register. + assert not tracker.sensor_states["write_seen"], ( + f"malformed 0x17 must not write, but write_seen fired: {tracker.sensor_states['write_seen']}" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller( yaml_config: str, @@ -211,7 +308,10 @@ async def test_uart_mock_modbus_server_controller( expected_values = { "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_u_word_s_raw": 13330, "reg_s_word": -99, + "reg_s_word_s": -2, "reg_u_dword": 16909060, "reg_s_dword": -16909060, "reg_u_dword_r": pytest.approx(67305985), @@ -230,7 +330,10 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -245,14 +348,16 @@ async def test_uart_mock_modbus_server_controller_write( Verifies that writing to modbus server registers via the controller updates the server's stored values, which are then read back correctly on the next poll. - All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). + All 14 value types are tested: U/S_WORD, U/S_WORD_S, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). """ line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() register_test_cases: dict[str, RegisterTestCase] = { "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), + "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), + "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), @@ -290,7 +395,12 @@ async def test_uart_mock_modbus_server_controller_write( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the baseline can already be in the + # states the device sends on connect; matching it there saves waiting for + # the next poll + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -309,6 +419,72 @@ async def test_uart_mock_modbus_server_controller_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_bits( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test coil/discrete-input round trips between controller and server bits. + + The server serves four bits from one shared table. The controller reads + each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02), + so the two views must always agree. Two bits are then written back, one + via the single-coil write (FC 0x05) and one via the multiple-coils write + (FC 0x0F), and the new values must show up in both read views. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + initial_values = { + "bit_coil_0": True, + "bit_coil_1": False, + "bit_coil_2": False, + "bit_coil_3": True, + "bit_di_0": True, + "bit_di_1": False, + "bit_di_2": False, + "bit_di_3": True, + } + tracker = SensorTracker(list(initial_values.keys())) + + # Phase 1: expect initial baseline values in both read views + initial_futures = tracker.expect_all(initial_values) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + { + "bit_coil_2": True, + "bit_di_2": True, + "bit_coil_3": False, + "bit_di_3": False, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + # The controller polls from boot and binary sensors drop repeats, so the + # baseline can arrive only in the states the device sends on connect + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Flip both writable bits: 0x02 false -> true, 0x03 true -> false + for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): + entity = find_entity(entities, switch_name, SwitchInfo) + assert entity is not None, f"{switch_name} switch entity not found" + client.switch_command(entity.key, value) + + # Wait for both read views to reflect the written values + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -323,6 +499,407 @@ async def test_uart_mock_modbus_server_controller_multiple( tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_typed( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the typed modbus_client actions end to end (each action its own hub device). + + Start Scenario fires three typed actions: write_single_register puts 777 in server register 0x10 (the + ack fires on_response -> ack_flag); read_holding_registers reads it back, + with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); + a read of unserved register 0x99 resolves via on_error with the device's exception code + (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not + implement the coil function - proving the bit-read request and typed error delivery. A multi-register + write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler + (-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server + (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent + (-> not_sent_flag). + """ + + tracker = SensorTracker( + [ + "typed_value", + "ack_flag", + "error_code", + "coil_error_code", + "multi_value", + "multi_coil_error", + "not_sent_flag", + ] + ) + futures = tracker.expect_all( + { + "typed_value": 777, + "ack_flag": 1, + "error_code": 2, + "coil_error_code": 1, + "multi_value": 222, + "multi_coil_error": 1, + "not_sent_flag": 1, + } + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_inline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus_client.send actions: each action is its own hub device. + + Start Scenario fires: a read of served address 1 decoded in its inline on_response -> inline_value; a + read of address 2, which no server answers, resolving via on_no_response -> timeout_flag. A parallel + script fires the same write action twice while its first frame is pending; the hub drops the duplicate + write, and the second firing resolves via its own on_not_sent -> skipped_flag. This exercises + per-action reply routing, the no-reply path, and the one-outcome guarantee under the hub's write + dedup. + """ + + tracker = SensorTracker(["inline_value", "timeout_flag", "skipped_flag"]) + futures = tracker.expect_all( + {"inline_value": 1234, "timeout_flag": 1, "skipped_flag": 1} + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures, timeout=5.0) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_grouping( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Pins how sensors are grouped into polled ranges across the combinations that matter. + + Each block in the fixture covers one relationship between neighbouring sensors - sharing a wide + register, contiguous, separated by a gap, differing polling rates, coils, and a pinned range - so + that the frames on the wire and the byte each sensor decodes from are locked down. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + # Values are those the component produced before the range rework, captured from it directly. + expected_values = { + # one register returning 4 bytes, read as two halves + "reuse_lo": 273, + "reuse_hi": 546, + # contiguous registers, mixed widths + "ext_word": 4660, + "ext_next": 22136, + "ext_dword": pytest.approx(2596069120), + # a wide register pushes its neighbour past the bytes it actually returned + "wide_first": 2730, + "wide_next": 3003, + # a gap keeps them apart + "gap_low": 320, + "gap_high": 325, + # contiguous, second one polling more slowly + "rate_first": 336, + "rate_slow": 337, + # a wide value, one of its halves, and the register after it + "shared_dword": pytest.approx(2759468), + "shared_high": 6956, + "shared_after": 781, + # a wide register hidden behind a wider plain sibling, and the sensor after them + "masked_wide": 4369, + "masked_pair": pytest.approx(286335522), + "masked_after": 13107, + # pinned range, and the contiguous sensor after it + "forced_first": 352, + "forced_next": 353, + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + # Every frame sent must match one the mock answers, so an unexpected read (a range that split, + # merged or changed length) shows up here as an unanswered request. This is what pins the coil + # grouping too, since binary sensors carry no numeric state to compare. + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_shared_address( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Sensors sharing and overlapping one register range must all decode from a single read. + + A U_WORD and a U_DWORD share start address 0x9001 (non-mergeable, so the range widens to 2 + registers) and a third U_WORD at 0x9002 falls inside the widened range. A regression guard for the + range-grouping rewrite: without the same-address fallback the shared sensors land in duplicate + ranges and one never publishes; without the in-range join the 0x9002 sensor splits into a second + overlapping frame that the mock (which expects exactly one read) never answers. + + A force_new_range sensor at 0x30 plus a plain sensor at 0x10 pin the covered branch's lower-bound + check: the forced sensor sorts first, and without the bound the lower-address sensor is absorbed + into the forced range with a wrapped byte offset and never polls its own register. + + A U_QWORD at 0x100 with plain sensors at 0x101 and 0x103 pins that non-merging sensors inside a + wide sensor's span keep polling separately, and that the sensor at the span's tail address does not + anchor a re-use join on a mid-range predecessor (which would make it decode that sensor's bytes). + + A sensor at 0x201 carrying skip_updates sits inside a widened shared-address range at 0x200 but + keeps its own range, so polling rates stay independent; folding it in would also make it decode + 0x201 out of the shared response (2) instead of its own poll (777). + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + # 0x9001 = 0x0397 (919); 0x9001..0x9002 = 0x03970291 (60228241, approx: not exact in float32); + # 0x9002 = 0x0291 (657); 0x30 = 0x0111 (273); 0x10 = 0x0222 (546) + expected_values = { + "shared_word": 919, + "shared_dword": pytest.approx(60228241), + "covered_word": 657, + "forced_high": 273, + "plain_low": 546, + "wide_qword": 100, + "inside_wide": 321, + "tail_of_wide": 421, + "rate_word": 321, + "rate_dword": pytest.approx(21037058), + "own_rate": 777, + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_custom_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a custom_command sensor polling a register served by the mock server. + + The custom_command is a raw frame (device address + PDU); the hub appends the CRC and + routes the response back to the polling command, whose sensor lambda parses the payload. + Guards the custom polling wiring: the command must reference the sensor's custom_data and + decode the real function code, or nothing is ever transmitted. A plain read on the same + register anchors the bus. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"plain_read": 259, "custom_read": 259} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_offline( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A silent device drives the controller offline; answering again recovers it. + + The mock answers nothing at first, so the controller burns through max_cmd_retries + (1 retry after the first timeout) and fires on_offline. While offline it keeps + retrying every offline_skip_updates+1 cycles. The test then flips the mock to + answering; the next retry gets a response, on_online fires, and the register value + publishes. This pins the pooled non-response counter, can_send() gating, the + offline retry cadence, and recovery - none of which the responding-path tests touch. + + The fixture gives offline_skip_updates and the sensor's skip_updates the same period + on purpose: offline probing must follow the offline cadence alone, since requiring + both cadences to coincide leaves phase combinations where no probe ever goes out. + """ + + tracker = SensorTracker(["link_state", "reg"]) + offline_future = tracker.expect("link_state", 0) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # The unanswered poll and its retry each time out (~100ms), then on_offline fires. + await tracker.await_change(offline_future, "link_state", timeout=5.0) + + # Register the recovery expectations before waking the device so no update is missed. + online_future = tracker.expect("link_state", 1) + value_future = tracker.expect("reg", 259) + serve_btn = find_entity(entities, "serve", ButtonInfo) + assert serve_btn is not None, "Serve button not found" + client.button_command(serve_btn.key) + + # The next offline-cadence retry gets an answer: back online, value published. + await tracker.await_change(online_future, "link_state", timeout=5.0) + await tracker.await_change(value_future, "reg", timeout=5.0) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_fairness( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Two controllers sharing one bus should get a fair share of it. + + Both controllers poll different devices (addresses 1 and 2) on the same + client hub, far faster than the bus can service, so they continually + contend for it. The on_tx hook in the fixture counts the requests issued + for each address. With fair scheduling in the modbus hub, neither + controller should starve the other: the two request counts must end up + within 1 of each other. + """ + + tracker = SensorTracker(["requests_1", "requests_2"]) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Let both controllers hammer the bus for a while. + await asyncio.sleep(2.0) + + # Stop polling so the counters settle to a final, stable value (state + # coalescing means intermediate values may be skipped, but the final + # value is always delivered once changes stop). + stop_btn = find_entity(entities, "stop_scenario", ButtonInfo) + assert stop_btn is not None, "Stop Scenario button not found" + client.button_command(stop_btn.key) + await asyncio.sleep(0.5) + + assert tracker.sensor_states["requests_1"], "controller 1 issued no requests" + assert tracker.sensor_states["requests_2"], "controller 2 issued no requests" + count_1 = tracker.sensor_states["requests_1"][-1] + count_2 = tracker.sensor_states["requests_2"][-1] + + # Both must have polled repeatedly, otherwise "fairness" is meaningless. + assert count_1 >= 5 and count_2 >= 5, ( + f"expected both controllers to poll repeatedly, " + f"got controller 1={count_1}, controller 2={count_2}" + ) + # Fair scheduling: the bus alternates between the two pending requests, + # so the counts can differ by at most one in-flight request. + assert abs(count_1 - count_2) <= 1, ( + f"controllers did not get a fair share of the bus: " + f"controller 1 issued {count_1}, controller 2 issued {count_2}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A client broadcast write (address 0) reaches every server and costs no timeout. + + The scenario button sends a broadcast single-register write of 777 to register + 0x10; both servers must apply it. The client's normal polling sensor must keep + updating, and no modbus warnings may appear - the pre-broadcast-support behavior + parked the frame in the waiting slot until the send-wait timeout, which surfaced + here as 'Stop waiting for response' warnings and a stalled poll. + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["reg_u_word", "srv1_written", "srv2_written", "broadcast_accepted"] + ) + poll_before = tracker.expect("reg_u_word", 919) + written = tracker.expect_all({"srv1_written": 777, "srv2_written": 777}) + # queue_pdu() must accept the broadcast into the machine (return true), the answer this PR adds. + accepted = tracker.expect("broadcast_accepted", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_change(accepted, "broadcast_accepted") + await tracker.await_change(poll_before, "reg_u_word") + await tracker.await_all(written) + # Polling must continue after the broadcast (a burned timeout stalls it). + poll_after = tracker.expect("reg_u_word", 919) + await tracker.await_change(poll_after, "reg_u_word", timeout=3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end. + + The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server + applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran + (srv_write_1) and the read half came back to the client's on_response (client_read_0 = the + just-written 0x1234, client_read_1 = the read-only 0x00AA). + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] + ) + futures = tracker.expect_all( + { + "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 + "client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234 + "client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request + } + ) + async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8bbaa2773a..f3d4bbcba6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,11 +3,13 @@ from __future__ import annotations import ast +from collections.abc import Callable import importlib.util import json from pathlib import Path import subprocess import sys +from typing import Any import pytest @@ -205,6 +207,47 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: assert "sensitive_source" not in entry +def _wildcard_validator(value: Any) -> Any: + return value + + +def test_convert_keys_marker_wrapped_callable_key_normalizes() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional(_wildcard_validator): cv.string}, "/root") + + config_vars = converted["schema"]["config_vars"] + assert set(config_vars) == {"string"} + assert config_vars["string"]["key"] == "Optional" + assert config_vars["string"]["key_type"] == "_wildcard_validator" + + +def test_convert_keys_marker_wrapped_callable_beside_fixed_keys() -> None: + converted: dict = {} + _bls.convert_keys( + converted, + {cv.Required("id"): cv.string, cv.Optional(_wildcard_validator): cv.string}, + "/root", + ) + + assert set(converted["schema"]["config_vars"]) == {"id", "string"} + + +def test_convert_keys_bare_callable_dotted_qualname() -> None: + def make_validator() -> Callable[[Any], Any]: + def validator(value: Any) -> Any: + return value + + return validator + + converted: dict = {} + _bls.convert_keys(converted, {make_validator(): cv.string}, "/root") + + assert converted["key"] == "String" + assert converted["key_type"].endswith("make_validator..validator") + assert "at 0x" not in converted["key_type"] + assert set(converted["schema"]["config_vars"]) == {"string"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d018c6dbd0..80f572d9fe 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2475,6 +2475,35 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_top_level_python_change() -> None: + """Test benchmarks trigger on top-level esphome Python module changes. + + The Python benchmarks exercise config loading, so changes to modules + like config.py and yaml_util.py must run them; a regression in #16718 + went unnoticed because these files matched no trigger. + """ + for py_file in [ + "esphome/config.py", + "esphome/yaml_util.py", + "esphome/__main__.py", + "esphome/helpers.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[py_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {py_file}" + ) + + +def test_should_run_benchmarks_nested_python_change() -> None: + """Test benchmarks do NOT trigger for nested non-core Python changes.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/dashboard/web_server.py"], + ): + assert determine_jobs.should_run_benchmarks() is False + + def test_should_run_benchmarks_host_platform_change() -> None: """Test benchmarks trigger on host platform changes. @@ -2993,3 +3022,53 @@ def test_main_force_all_off_uses_detection( assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() + + +# Every platform the memory impact analysis can select must produce an ELF that +# find_elf_path knows how to locate. The analysis fails the job when it cannot +# find one, so a platform with an unknown layout would turn a clean build red. +_MEMORY_IMPACT_ELF_LAYOUTS = { + # Native ESP-IDF toolchain (the esp32 default): /build/firmware.elf + "esp32-c6-idf": "build/firmware.elf", + "esp32-idf": "build/firmware.elf", + "esp32-c3-idf": "build/firmware.elf", + "esp32-s2-idf": "build/firmware.elf", + "esp32-s3-idf": "build/firmware.elf", + # PlatformIO: /.pioenvs//firmware.elf + "esp8266-ard": ".pioenvs/{name}/firmware.elf", + "rp2040-ard": ".pioenvs/{name}/firmware.elf", + "rp2350-ard": ".pioenvs/{name}/firmware.elf", + # LibreTiny: /.pioenvs//raw_firmware.elf + "bk72xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "rtl87xx-ard": ".pioenvs/{name}/raw_firmware.elf", + "ln882x-ard": ".pioenvs/{name}/raw_firmware.elf", + # Zephyr: /.pioenvs//zephyr/[zephyr/]zephyr.elf + "nrf52-adafruit": ".pioenvs/{name}/zephyr/zephyr/zephyr.elf", +} + + +def test_memory_impact_platforms_have_known_elf_layout() -> None: + """Every selectable memory impact platform has a documented ELF layout. + + Adding a platform to the preference list without teaching find_elf_path + where its ELF lands would fail the memory impact job on a clean build. + """ + selectable = { + platform.value for platform in determine_jobs.MEMORY_IMPACT_PLATFORM_PREFERENCE + } + selectable.add(determine_jobs.MEMORY_IMPACT_FALLBACK_PLATFORM.value) + + assert selectable == set(_MEMORY_IMPACT_ELF_LAYOUTS) + + +def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: + """find_elf_path locates the ELF each memory impact platform produces.""" + from esphome.analyze_memory.toolchain import find_elf_path + + for platform, layout in _MEMORY_IMPACT_ELF_LAYOUTS.items(): + build_path = tmp_path / platform / ".esphome" / "build" / "mydevice" + elf = build_path / layout.format(name=build_path.name) + elf.parent.mkdir(parents=True) + elf.write_text("") + + assert find_elf_path(build_path) == elf, f"{platform} ELF not found" diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py index 34bcc4e714..06dc21a92e 100644 --- a/tests/script/test_docker_build.py +++ b/tests/script/test_docker_build.py @@ -71,10 +71,12 @@ def test_branch_manifest_targets_ghcr_only( ) assert commands == [ - "docker buildx imagetools create " - "--tag ghcr.io/esphome/esphome-hassio:my-branch " - "ghcr.io/esphome/esphome-hassio-amd64:my-branch " - "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ( + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ) ] diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 886d413ccf..077b6ef23e 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -79,6 +79,22 @@ def test_get_pr_number_from_github_env_event_file( assert result == "5678" +def test_get_github_event_data_decodes_utf8_regardless_of_locale( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + """The event payload is UTF-8; parsing must not depend on the platform + default encoding. On Windows the default is cp1252, which raised + UnicodeDecodeError as soon as a commit title carried non ASCII text.""" + event_file = tmp_path / "event.json" + event_data = {"head_commit": {"message": "Answer UNPAIR with Response… é"}} + event_file.write_bytes(json.dumps(event_data, ensure_ascii=False).encode("utf-8")) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + result = helpers._get_github_event_data() + + assert result == event_data + + def test_get_pr_number_from_github_env_no_pr( monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: @@ -1835,3 +1851,24 @@ def test_get_component_test_files_component_without_tests( ) def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None: assert helpers.is_validate_only_file(tmp_path / filename) is expected + + +@pytest.mark.parametrize( + ("files", "expected"), + [ + (["esphome/config.py"], True), + (["esphome/yaml_util.py"], True), + (["esphome/__main__.py"], True), + (["esphome/const.pyi"], True), + (["README.md", "esphome/helpers.py"], True), + (["esphome/core/config.py"], False), + (["esphome/components/sensor/__init__.py"], False), + (["esphome/dashboard/web_server.py"], False), + (["esphome/idf_component.yml"], False), + (["tests/unit_tests/test_config.py"], False), + ([], False), + ], +) +def test_base_python_changed(files: list[str], expected: bool) -> None: + """Only Python modules directly in esphome/ count as base Python changes.""" + assert helpers.base_python_changed(files) is expected diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py new file mode 100644 index 0000000000..74e150380c --- /dev/null +++ b/tests/script/test_test_build_components.py @@ -0,0 +1,238 @@ +"""Unit tests for script/test_build_components.py logging helpers.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import the module under test. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import test_build_components as tbc # noqa: E402 + + +class _FakeCompleted: + """Minimal stand-in for subprocess.CompletedProcess.""" + + def __init__(self, returncode: int) -> None: + self.returncode = returncode + + +@pytest.fixture +def _no_ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure GITHUB_ACTIONS is unset so group markers are suppressed.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + +@pytest.fixture +def _ci(monkeypatch: pytest.MonkeyPatch) -> None: + """Pretend we are running inside GitHub Actions.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + +def test_start_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "" + + +def test_end_log_group_outside_ci_is_silent( + _no_ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "" + + +def test_start_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.start_log_group("hello") + assert capsys.readouterr().out == "::group::hello\n" + + +def test_end_log_group_in_ci_emits_marker( + _ci: None, capsys: pytest.CaptureFixture[str] +) -> None: + tbc.end_log_group() + assert capsys.readouterr().out == "::endgroup::\n" + + +def _make_base_file(tmp_path: Path) -> Path: + base_file = tmp_path / "base.yaml" + base_file.write_text("esphome:\n name: $component_test_file\n") + return base_file + + +def test_run_esphome_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A passing single-component test is bracketed by group markers.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + result = tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[foo] [test] [esp32-idf]" in out + assert "::endgroup::" in out + # The header line is printed inside the group. + assert out.index("::group::") < out.index("> [foo]") < out.index("::endgroup::") + + +def test_run_esphome_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """On a fail-fast failure the group closes before the reproduce report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + # continue_on_fail=False makes the failure raise after printing the + # reproduce block, which is the path that must stay outside the group. + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert "::endgroup::" in out + assert "FAILED - Command to reproduce:" in out + # The group must be closed before the failure report is printed. + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_esphome_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the subprocess raises, the group is still closed (via finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + repo_root = Path(tbc.__file__).parent.parent + test_file = repo_root / "tests" / "components" / "foo" / "test.esp32-idf.yaml" + + with pytest.raises(OSError, match="boom"): + tbc.run_esphome_test( + component="foo", + test_file=test_file, + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out + + +def test_run_grouped_test_wraps_output_in_group( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A grouped test is bracketed by group markers listing its components.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(0)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + result = tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + out = capsys.readouterr().out + assert result.success is True + assert "::group::[GROUPED: foo, bar] [esp32-idf]" in out + assert out.index("::group::") < out.index("> [GROUPED") < out.index("::endgroup::") + + +def test_run_grouped_test_closes_group_before_failure_report( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A fail-fast grouped failure closes the group before the report.""" + monkeypatch.setattr(tbc.subprocess, "run", lambda *a, **k: _FakeCompleted(1)) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(tbc.subprocess.CalledProcessError): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=False, + ) + + out = capsys.readouterr().out + assert out.index("::endgroup::") < out.index("FAILED - Command to reproduce:") + + +def test_run_grouped_test_closes_group_when_subprocess_raises( + _ci: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If the grouped subprocess raises, the group is still closed (finally).""" + + def _boom(*a: object, **k: object) -> None: + raise OSError("boom") + + monkeypatch.setattr(tbc.subprocess, "run", _boom) + monkeypatch.setattr(tbc, "merge_component_configs", lambda **k: None) + + with pytest.raises(OSError, match="boom"): + tbc.run_grouped_test( + components=["foo", "bar"], + platform="esp32-idf", + platform_with_version="esp32-idf", + base_file=_make_base_file(tmp_path), + build_dir=tmp_path, + tests_dir=tmp_path, + esphome_command="config", + continue_on_fail=True, + ) + + assert "::endgroup::" in capsys.readouterr().out diff --git a/tests/test_build_components/build_components_base.rp2350-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml index 5df1670862..f76c5fc3f9 100644 --- a/tests/test_build_components/build_components_base.rp2350-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,8 +2,10 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name +# rpipico2w: superset of rpipico2 with the CYW43 radio, so wireless +# components (wifi, BLE) can share this target too. rp2: - board: rpipico2 + board: rpipico2w logger: level: VERY_VERBOSE diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -31,11 +31,14 @@ common/ │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml -├── modbus/ # Modbus (includes uart via packages) +├── modbus/ # Modbus client (includes uart via packages) │ ├── esp32-idf.yaml │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml +├── modbus_server/ # Modbus server (includes uart via packages) +│ ├── esp32-idf.yaml +│ └── esp8266-ard.yaml └── ble/ ├── esp32-idf.yaml ├── esp32-ard.yaml diff --git a/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml new file mode 100644 index 0000000000..386389ed77 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s2-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S2 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP32 IDF tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp32-idf.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP8266 Arduino tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp8266-ard.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/test_display/test_display.yaml b/tests/test_build_components/common/test_display/test_display.yaml new file mode 100644 index 0000000000..986ab45223 --- /dev/null +++ b/tests/test_build_components/common/test_display/test_display.yaml @@ -0,0 +1,26 @@ +# Shared "test display" package for component tests. +# +# Provides a no-op display (id: test_display_screen) that uses no pins and no +# bus, so tests that only need a display to exist -- touchscreens especially -- +# don't have to instantiate a real driver and fight it over GPIOs. Include it +# like a common bus package; the consuming test does NOT need to declare +# external_components itself: +# +# packages: +# test_display: !include ../../test_build_components/common/test_display/test_display.yaml +# +# then point the touchscreen (or other display consumer) at `test_display_screen`. +# +# The test_display platform lives at tests/components/test_display/components/ and +# is loaded via external_components. The source path is written relative to the +# build directory (tests/test_build_components/build/), which every test -- +# standalone or grouped -- is generated into, so this always resolves to the +# component under tests/components/test_display/. +external_components: + - source: ../../components/test_display/components + components: [test_display] + +display: + - platform: test_display + id: test_display_screen + dimensions: 240x320 diff --git a/tests/test_build_components/common/uart/esp32-h2-idf.yaml b/tests/test_build_components/common/uart/esp32-h2-idf.yaml new file mode 100644 index 0000000000..51d45fe6d5 --- /dev/null +++ b/tests/test_build_components/common/uart/esp32-h2-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-H2 IDF tests +# Provides a shared UART bus that components can use +# Components will auto-use this bus if they don't specify uart_id + +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO13 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 diff --git a/tests/unit_tests/analyze_memory/test_build_artifacts.py b/tests/unit_tests/analyze_memory/test_build_artifacts.py new file mode 100644 index 0000000000..d97ee94e1c --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_build_artifacts.py @@ -0,0 +1,157 @@ +"""Tests for locating build artifacts across the supported toolchain layouts.""" + +from pathlib import Path + +import pytest + +from esphome.analyze_memory.toolchain import ( + find_elf_path, + find_idedata_path, + idedata_candidates, +) +from esphome.espidf.idedata import _cc_path_from_cxx +from esphome.platformio.toolchain import IDEData + + +def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path: + """Create /.esphome/build/, mirroring a real data dir.""" + build_path = tmp_path / ".esphome" / "build" / name + build_path.mkdir(parents=True) + return build_path + + +def _touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + return path + + +def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None: + """The native ESP-IDF toolchain writes the ELF under build/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / "build" / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_platformio(tmp_path: Path) -> None: + """The PlatformIO toolchain writes the ELF under .pioenvs//.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf") + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_libretiny(tmp_path: Path) -> None: + """The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf") + + assert find_elf_path(build_path) == elf + + +@pytest.mark.parametrize( + "relative_elf", + [ + # SDK < 2.9.2 + "zephyr/zephyr.elf", + # SDK >= 2.9.2 nests the artifacts one level deeper + "zephyr/zephyr/zephyr.elf", + ], +) +def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None: + """Zephyr (nRF52) keeps the ELF under .pioenvs//zephyr/.""" + build_path = _make_build_dir(tmp_path) + elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf) + + assert find_elf_path(build_path) == elf + + +def test_find_elf_path_missing(tmp_path: Path) -> None: + """An unknown layout resolves to None rather than a bogus path.""" + assert find_elf_path(_make_build_dir(tmp_path)) is None + + +def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None: + """The idedata cache sits in the data dir that holds the build dir.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None: + """Test builds may keep idedata alongside the PlatformIO env.""" + build_path = _make_build_dir(tmp_path) + idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json") + + assert find_idedata_path(build_path) == idedata + + +def test_find_idedata_path_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing idedata resolves to None.""" + # Keep the cwd/home fallbacks from finding an unrelated file on this machine + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert find_idedata_path(_make_build_dir(tmp_path)) is None + + +def test_idedata_candidates_are_what_find_probes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every advertised candidate is one find_idedata_path actually accepts. + + The candidates are reported to the user when idedata is missing, so a list + that drifts from the lookup would send someone hunting in the wrong place. + """ + # Two candidates are relative to the cwd and to home; keep the test from + # writing into the real ones. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + build_path = _make_build_dir(tmp_path) + candidates = idedata_candidates(build_path) + + assert candidates, "no candidates advertised" + for candidate in candidates: + _touch(candidate) + assert find_idedata_path(build_path) == candidate + candidate.unlink() + + +@pytest.mark.parametrize( + ("cxx_path", "expected"), + [ + ("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"), + ("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"), + ( + r"C:\tools\bin\xtensa-esp32-elf-g++.exe", + r"C:\tools\bin\xtensa-esp32-elf-gcc.exe", + ), + # Nothing to rewrite; leave the path alone + ("/tools/bin/clang++", "/tools/bin/clang++"), + ], +) +def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None: + """cc_path is derived from the C++ compiler that compile_commands.json names.""" + assert _cc_path_from_cxx(cxx_path) == expected + + +def test_native_idedata_resolves_toolchain_tools() -> None: + """The binutils paths are derived from the native ESP-IDF cc_path. + + Without cc_path, IDEData.objdump_path raises EsphomeError and the + memory analysis silently degrades to no component or symbol detail. + """ + idedata = IDEData( + { + "cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"), + "cxx_path": "/tools/bin/xtensa-esp32-elf-g++", + } + ) + + assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump" + assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf" diff --git a/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py new file mode 100644 index 0000000000..73a1c63e1a --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ci_memory_impact_extract.py @@ -0,0 +1,57 @@ +"""Tests for script/ci_memory_impact_extract.py.""" + +import io +from pathlib import Path +import sys + +import pytest + +# Add script directory to path so we can import the module +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script")) + +from ci_memory_impact_extract import main # noqa: E402 + +_COMPILE_OUTPUT = ( + "RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + "Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" +) + + +@pytest.fixture(autouse=True) +def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + +def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int: + monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output)) + monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv]) + return main() + + +def test_missing_detailed_analysis_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A build with no usable ELF fails instead of posting a comment without detail.""" + build_dir = tmp_path / ".esphome" / "build" / "mydevice" + build_dir.mkdir(parents=True) + out_json = tmp_path / "analysis.json" + + rc = _run( + monkeypatch, + _COMPILE_OUTPUT, + ["--build-dir", str(build_dir), "--output-json", str(out_json)], + ) + + assert rc == 1 + # The totals are still written so the failure can be diagnosed from the artifact + assert out_json.is_file() + + +def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Compile output without a build path cannot be analyzed, so it fails.""" + assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1 + + +def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Output with no memory totals at all is still a failure.""" + assert _run(monkeypatch, "nothing useful here\n", []) == 1 diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py new file mode 100644 index 0000000000..35aa5ff529 --- /dev/null +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -0,0 +1,371 @@ +"""Invariant tests for esphome/components/api/api.proto and its generated code. + +These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition +(API 1.15) against regressions that protoc-based codegen would not catch on +its own, without requiring protoc to be installed at test time: + +* script/api_protobuf/api_protobuf.py skips any field marked + `[deprecated = true]` completely -- it generates no C++ for it at all, so + the device silently stops sending that value. Six DeviceInfoResponse fields + were superseded by DeviceCapabilitiesResponse but must keep being sent for + backward compatibility with clients older than API 1.15. If a future edit + "tidies up" by marking one of them deprecated, this file breaks that field + for every existing client with nothing else in CI noticing. +* Field numbers are the wire protocol, not the field names. Renaming a field + is harmless; renumbering it is a silent breaking change, because an old + client still decodes by number. This file pins the field number of each of + the six superseded DeviceInfoResponse fields and of every field on the new + DeviceCapabilitiesResponse/BluetoothProxyCapabilities/ + VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a + well-intentioned reshuffle of api.proto gets caught here instead of on a + device in the field. +* Message wire ids must be unique, and the new capabilities RPC must stay + authenticated-only. + +Group A below asserts on the checked-in generated files (api_pb2.h / +api_pb2.cpp), since "the field is present in the generated C++" is exactly +equivalent to "the device still sends it". Group B parses api.proto as plain +text (no protoc). Group C checks the advertised API minor version. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import esphome + +API_DIR = Path(esphome.__file__).parent / "components" / "api" + +PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") +HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") +CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") + +# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse +# as of API 1.15 but must still be generated (and therefore still sent) for +# backward compatibility with older clients. +SUPERSEDED_FIELDS: dict[str, int] = { + "bluetooth_proxy_feature_flags": 15, + "voice_assistant_feature_flags": 17, + "bluetooth_mac_address": 18, + "zwave_proxy_feature_flags": 23, + "zwave_home_id": 24, + "serial_proxies": 25, +} + +# Field numbers on the new capability messages. These are a frozen wire +# contract from the moment they ship: an old client decodes a sub-message +# field purely by number, so renumbering any of these -- even without +# touching a name -- silently corrupts what every already-deployed client +# reads. Keyed by message name so the next capability sub-message is a +# data-only addition here. +NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { + "DeviceCapabilitiesResponse": { + "bluetooth_proxy": 1, + "voice_assistant": 2, + "zwave_proxy": 3, + "serial_proxies": 4, + }, + "BluetoothProxyCapabilities": { + "feature_flags": 1, + "mac_address": 2, + }, + "VoiceAssistantCapabilities": { + "feature_flags": 1, + }, + "ZWaveProxyCapabilities": { + "feature_flags": 1, + "home_id": 2, + }, +} + +# Fields that are genuinely dead and are expected to carry `deprecated=true`. +# Used to prove the deprecated-detection logic below actually detects +# deprecation rather than trivially passing. +GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = ( + "legacy_bluetooth_proxy_version", + "legacy_voice_assistant_version", +) + +DEPRECATED_FIELD_TRAP = ( + "script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = " + "true]` completely, generating no C++ for them at all. Marking this field " + "deprecated would silently stop the device from ever sending it, breaking " + "every existing client that still reads it from DeviceInfoResponse." +) + + +def _extract_braced_region(text: str, anchor_pattern: str) -> str: + """Return the region of `text` starting at the first match of + `anchor_pattern` up to the matching closing brace (inclusive), using + brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop + inside a function body) don't cause a premature stop. + """ + anchor_match = re.search(anchor_pattern, text) + if anchor_match is None: + raise AssertionError(f"could not find a match for {anchor_pattern!r}") + start = anchor_match.start() + open_brace = text.index("{", start) + depth = 0 + for i in range(open_brace, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}") + + +def _extract_class_body(header_text: str, class_name: str) -> str: + """Return the body of a generated C++ class, scoped so a field name that + also happens to exist on some other class cannot satisfy the assertion. + """ + return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b") + + +def _extract_function_body(cpp_text: str, qualified_name: str) -> str: + """Return the body of a generated `Class::method(...)` definition.""" + return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(") + + +def _extract_proto_message(proto_text: str, message_name: str) -> str: + """Return the body of a top-level `message Name { ... }` block from the + .proto source. Proto message bodies here contain no nested `{`/`}` of + their own (options use parens, not braces), so a non-greedy match up to + the first line that is just `}` is sufficient and keeps the parsing + simple. + """ + match = re.search( + rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}", + proto_text, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `message {message_name}` in api.proto") + return match.group(1) + + +def _extract_rpc_body(proto_text: str, rpc_name: str) -> str: + """Return the option body of an `rpc name (...) returns (...) { ... }` + declaration from the APIConnection service, robust to it being written + on one line (`{}`) or spread across several with options inside. + """ + match = re.search( + rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}", + proto_text, + re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto") + return match.group(1) + + +def _field_declaration_line(message_body: str, field_name: str) -> str: + """Return the single source line declaring `field_name` inside a proto + message body (all fields here are declared on one line). + """ + for line in message_body.splitlines(): + if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line): + return line + raise AssertionError( + f"could not find a field declaration for {field_name!r} in the given message body" + ) + + +# ==================== Group A: generated files ==================== + + +def test_superseded_device_info_fields_still_declared_in_header() -> None: + """Each superseded field must still be a real member of DeviceInfoResponse + in api_pb2.h -- not merely present somewhere in the file. Several of these + names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an + unscoped substring search over the whole header would pass even if the + field were removed from DeviceInfoResponse. + """ + class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse") + for field_name in SUPERSEDED_FIELDS: + assert re.search(rf"\b{field_name}\b", class_body), ( + f"{field_name} is missing from the DeviceInfoResponse class body in " + f"api_pb2.h. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_superseded_device_info_fields_still_encoded_and_sized() -> None: + """Each superseded field must still be touched by DeviceInfoResponse's + generated encode() and calculate_size(), i.e. it is still put on the wire. + """ + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + for field_name in SUPERSEDED_FIELDS: + assert f"this->{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode() no longer references {field_name}. " + f"{DEPRECATED_FIELD_TRAP}" + ) + assert f"this->{field_name}" in size_body, ( + f"DeviceInfoResponse::calculate_size() no longer references " + f"{field_name}. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_new_capability_classes_present_in_header() -> None: + """The new response message and its capability sub-messages must exist as + generated classes. + """ + for class_name in ( + "DeviceCapabilitiesResponse", + "BluetoothProxyCapabilities", + "VoiceAssistantCapabilities", + "ZWaveProxyCapabilities", + ): + assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), ( + f"expected a generated class named {class_name} in api_pb2.h" + ) + + +# ==================== Group B: api.proto source text ==================== + + +def test_all_message_ids_are_unique() -> None: + """Every `option (id) = N;` in api.proto must be unique. Two messages + sharing a wire id would make the client and server misinterpret each + other's messages -- nothing else currently checks this. + """ + ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)] + assert ids, "did not find any `option (id) = N;` declarations in api.proto" + duplicates = sorted({value for value in ids if ids.count(value) > 1}) + assert not duplicates, ( + f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each " + "message must have a unique wire id." + ) + + +def test_device_capabilities_request_has_id_149() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`" + assert int(match.group(1)) == 149, ( + f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_device_capabilities_response_has_id_150() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`" + assert int(match.group(1)) == 150, ( + f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None: + """The six superseded fields must not carry `[deprecated = true]` in + api.proto, or the generator drops them and old clients stop receiving + them (see module docstring). The second half of this test proves the + deprecated-detection itself works: two genuinely dead fields + (legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must + still be detected as deprecated, so the first half isn't vacuously true. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name in SUPERSEDED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" not in line, ( + f"{field_name} in DeviceInfoResponse is marked deprecated in " + f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}" + ) + + for field_name in GENUINELY_DEPRECATED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" in line, ( + f"expected {field_name} to still carry `deprecated=true` in " + f"api.proto ({line.strip()!r}). If this fails, the deprecated " + "detection used above is broken, and the sibling assertion that " + "the superseded fields are NOT deprecated is not testing anything." + ) + + +def test_superseded_fields_keep_their_wire_numbers() -> None: + """Each superseded field must stay on the field number recorded in + SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field + number, so renumbering one of these -- even without touching its name -- + would make an old client read a completely different value out of the + wire, with nothing else in CI noticing. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name, field_number in SUPERSEDED_FIELDS.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} in DeviceInfoResponse is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field numbers " + "are the wire protocol -- renumbering this field silently breaks " + "every existing client that still decodes DeviceInfoResponse by " + "the old numbering." + ) + + +def test_capability_message_fields_keep_their_wire_numbers() -> None: + """Every field on DeviceCapabilitiesResponse and its three capability + sub-messages must stay on the field number recorded in + NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but + the moment a device ships with them, their field numbers are a frozen + wire contract -- a client decodes a sub-message field purely by number, + so a later "cleanup" that renumbers one of these would silently corrupt + what every already-deployed client reads, with nothing else in CI + noticing. + """ + for message_name, fields in NEW_CAPABILITY_FIELDS.items(): + body = _extract_proto_message(PROTO_TEXT, message_name) + for field_name, field_number in fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} on {message_name} is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field " + "numbers are the wire protocol -- renumbering this field " + "silently breaks every existing client that decodes this " + "message by the old numbering." + ) + + +def test_device_capabilities_rpc_requires_authentication() -> None: + """The `device_capabilities` RPC must not set + `option (needs_authentication) = false;` (or set it to anything at all). + Leaving it unset makes it inherit needs_authentication = true, keeping + capability data behind authentication (and encryption, when configured). + """ + body = _extract_rpc_body(PROTO_TEXT, "device_capabilities") + assert "needs_authentication" not in body, ( + "rpc device_capabilities sets a `needs_authentication` option in " + "api.proto. It must stay unset so it inherits needs_authentication = " + "true; otherwise device capability data could be requested over an " + "unauthenticated connection." + ) + + +# ==================== Group C: advertised API version ==================== + + +def test_api_version_minor_is_at_least_15() -> None: + """Clients gate sending DeviceCapabilitiesRequest on seeing + api_version >= 1.15 in HelloResponse. Regressing api_version_minor below + 15 would make every client believe capabilities are unsupported even + though the RPC exists, so this must never go backwards. Use >= rather + than == so the next unrelated minor-version bump doesn't need to touch + this test. + """ + match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT) + assert match is not None, ( + "could not find `resp.api_version_minor = N;` in api_connection.cpp" + ) + minor = int(match.group(1)) + assert minor >= 15, ( + f"api_version_minor is {minor}, but device_capabilities requires " + "clients to see api_version >= 1.15 in HelloResponse before they will " + "ever request it." + ) diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py new file mode 100644 index 0000000000..2a07cbd49c --- /dev/null +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -0,0 +1,93 @@ +"""Unit tests for script/api_protobuf/api_protobuf.py generator logic. + +ci-api-proto.yml only checks that the committed output matches what the +generator currently produces, so a semantic regression in the generator would +be committed and matched without anything failing. These tests pin the +semantics directly. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) + +from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from google.protobuf import descriptor_pb2 # noqa: E402 + + +def _file_with_messages( + *messages: tuple[str, int, bool], +) -> descriptor_pb2.FileDescriptorProto: + """Build a FileDescriptorProto with one single-field message per entry. + + Each entry is (message_name, field_type, deprecated). + """ + file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") + for name, field_type, deprecated in messages: + msg = file_desc.message_type.add(name=name) + field = msg.field.add(name="value", number=1, type=field_type) + field.options.deprecated = deprecated + return file_desc + + +UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 +SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 +UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 + + +def test_no_varint64_fields() -> None: + file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False)) + assert get_varint64_ifdef(file_desc, {}) == (False, None) + + +@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64]) +def test_single_guard_is_kept(field_type: int) -> None: + file_desc = _file_with_messages(("A", field_type, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X") + + +def test_two_guards_emit_the_union() -> None: + # The regression this pins: multiple guards used to collapse to + # unconditional, pulling 64-bit varint support into unrelated builds. + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + guards = {"A": "USE_X", "B": "USE_Y"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_union_is_sorted_for_deterministic_output() -> None: + file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False)) + guards = {"B": "USE_Y", "A": "USE_X"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_any_unconditional_message_wins() -> None: + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None) + + +def test_deprecated_fields_and_messages_are_ignored() -> None: + file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False)) + file_desc.message_type[1].options.deprecated = True + assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None) + + +def test_make_ifdef_line_simple_identifier() -> None: + assert _make_ifdef_line("USE_X") == "#ifdef USE_X" + + +def test_make_ifdef_line_union_wraps_each_identifier() -> None: + # The second half of the varint64 union guard: compound conditions must + # become #if defined(A) || defined(B), never #ifdef of the raw string. + assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)" + + +def test_make_ifdef_line_conjunction_and_negation() -> None: + assert ( + _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" + ) diff --git a/tests/unit_tests/components/api/test_client.py b/tests/unit_tests/components/api/test_client.py deleted file mode 100644 index 379705f534..0000000000 --- a/tests/unit_tests/components/api/test_client.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for esphome.components.api.client.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest - -from esphome.components import esp32 -from esphome.components.api import client as api_client -from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM -from esphome.core import CORE, EsphomeError - - -def test_decoder_swallows_esphome_error() -> None: - """A failing stack-trace decode must not propagate. - - on_log runs inside an asyncio protocol callback; if EsphomeError - escapes, the loop reports "Fatal error: protocol.data_received() - call failed.", tears the connection down, and ReconnectLogic loops - forever as the device replays the same crash trace on every - reconnect. - """ - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - - assert mock_process.called - assert processor.backtrace_state is False - - -def test_decoder_swallows_platform_handler_error() -> None: - """The same protection must apply to the platform-specific handler.""" - config = {"esphome": {"name": "test"}} - - def platform_handler(_config, _line, _state): - raise EsphomeError("no idedata") - - processor = api_client._LogLineProcessor(config, platform_handler) - processor.process_line("PC: 0x4010496e") - - assert processor.backtrace_state is False - - -def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None: - """_run_idedata raises EsphomeError with no message; the warning - must show a useful explanation rather than empty parens. - """ - config = {"esphome": {"name": "test"}} - - with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()): - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - - warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] - assert any("build artifacts not found locally" in m for m in warnings) - assert not any("()" in m for m in warnings) - - -def test_decoder_short_circuits_after_failure() -> None: - """After one failure, subsequent lines must not retry the decoder. - - _decode_pc shells out to PlatformIO; a crash dump can contain many - PC/BT lines and retrying the failing subprocess for each one would - stall log streaming. - """ - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=EsphomeError("no idedata") - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line("PC: 0x4010496e") - processor.process_line("BT0: 0x4010496e") - processor.process_line("BT1: 0x401049aa") - - assert mock_process.call_count == 1 - - -def test_decoder_threads_backtrace_state() -> None: - """When decoding succeeds, backtrace_state is threaded across calls.""" - config = {"esphome": {"name": "test"}} - - with patch.object( - esp32, "process_stacktrace", side_effect=[True, False] - ) as mock_process: - processor = api_client._LogLineProcessor(config, esp32.process_stacktrace) - processor.process_line(">>>stack>>>") - assert processor.backtrace_state is True - processor.process_line("<< None: - """The platform handler is preferred over the generic one.""" - config = {"esphome": {"name": "test"}} - calls: list[tuple[object, str, bool]] = [] - - def platform_handler(cfg, line, state): - calls.append((cfg, line, state)) - return True - - processor = api_client._LogLineProcessor(config, platform_handler) - - with patch.object(esp32, "process_stacktrace") as mock_generic: - processor.process_line("BT0: 0x4010496e") - - assert calls == [(config, "BT0: 0x4010496e", False)] - assert mock_generic.called is False - assert processor.backtrace_state is True - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("extra_config", "expected_deep_sleep"), - [({"deep_sleep": {}}, True), ({}, False)], -) -async def test_async_run_logs_passes_deep_sleep( - extra_config: dict, expected_deep_sleep: bool -) -> None: - """async_run_logs tells async_run whether the device deep sleeps, from the config.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} - config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} - # async_run blocks forever after connecting; raise to unwind async_run_logs - # once we have captured how it was called. - sentinel = RuntimeError("stop the wait") - - with ( - patch.object( - api_client, "async_run", AsyncMock(side_effect=sentinel) - ) as mock_run, - patch.object(api_client, "APIClient"), - pytest.raises(RuntimeError, match="stop the wait"), - ): - await api_client.async_run_logs(config, ["1.2.3.4"]) - - assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep diff --git a/tests/unit_tests/components/bme68x_bsec2/__init__.py b/tests/unit_tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/bme68x_bsec2/test_init.py b/tests/unit_tests/components/bme68x_bsec2/test_init.py new file mode 100644 index 0000000000..b34231a1aa --- /dev/null +++ b/tests/unit_tests/components/bme68x_bsec2/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the bme68x_bsec2 prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components import bme68x_bsec2 as bsec +from esphome.loader import get_component + + +def test_prefetch_applies_defaults(setup_core: Path) -> None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( assert mock_download_content_many.call_count == 2 manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) assert manifest_items == [ - (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + RemoteFile( + f"https://example.com/models/{name}.json", paths[name] / "manifest.json" + ) for name in names ] model_items = list(mock_download_content_many.call_args_list[1].args[0]) assert model_items == [ - (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/mqtt/__init__.py b/tests/unit_tests/components/mqtt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/mqtt/test_object_id_conflicts.py b/tests/unit_tests/components/mqtt/test_object_id_conflicts.py new file mode 100644 index 0000000000..0ac61b4dad --- /dev/null +++ b/tests/unit_tests/components/mqtt/test_object_id_conflicts.py @@ -0,0 +1,239 @@ +"""Tests for the MQTT object_id conflict filter. + +MQTT still builds default topics and discovery topics from the sanitized +object_id, so entity names that only differ in characters lost during +sanitizing conflict there; _topics_conflict() exempts entities that never +use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85 +""" + +from pathlib import Path + +import pytest + +from esphome.components.mqtt import ( + _COMMAND_TOPIC_PLATFORMS, + _SUB_TOPIC_PLATFORMS, + _topics_conflict, +) +from esphome.config_validation import Invalid +from esphome.const import ( + CONF_COMMAND_TOPIC, + CONF_DISCOVERY, + CONF_NAME, + CONF_STATE_TOPIC, + CONF_TOPIC_PREFIX, +) +from esphome.core import CORE +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + validate_no_object_id_conflicts, +) + +COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components" + +REASON = "mqtt builds default topics from the entity object_id" + + +# MQTT infrastructure sources, not entity components +_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"} +# The date, time and datetime MQTT components all belong to the datetime platform +_DATETIME_STEMS = {"date", "time", "datetime"} + + +def test_command_topic_platforms_in_sync() -> None: + """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. + + Drift silently reintroduces shared subscribe topics, so this derives the set + from the C++ components that actually call subscribe(); that also catches + platforms like text that subscribe a command topic without exposing a + command_topic key in their schema. + """ + expected: set[str] = set() + for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): + if path.stem in _NON_ENTITY_MQTT_SOURCES: + continue + if "this->subscribe" not in path.read_text(encoding="utf-8"): + continue + stem = path.stem.removeprefix("mqtt_") + expected.add("datetime" if stem in _DATETIME_STEMS else stem) + assert expected == _COMMAND_TOPIC_PLATFORMS + + +def test_sub_topic_platforms_in_sync() -> None: + """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. + + Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra + topics such as position/command from the object_id. + """ + expected = { + path.stem.removeprefix("mqtt_") + for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") + if path.stem != "mqtt_component" + and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") + } + assert expected == _SUB_TOPIC_PLATFORMS + + +def test_conflict_filter_exempts_custom_topics() -> None: + """Test that custom state topics with discovery off avoid the conflict.""" + validator = entity_duplicate_validator("sensor") + # Both entities have custom state topics and discovery disabled per entity, + # so no object_id-derived MQTT topic is used + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + assert component_validator(config) is config + + # Without the filter the same conflicts are fatal + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + validate_no_object_id_conflicts(REASON)({}) + + +def test_conflict_on_default_command_topic() -> None: + """Test that commandable platforms conflict through their default command topic. + + Custom state topics with discovery off are not enough for platforms that also + subscribe to an object_id-derived command topic. + """ + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + # Both switches share the default command topic: rejected + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator(mqtt_config) + + # With custom command topics as well, nothing derives from the object_id + CORE.reset() + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_COMMAND_TOPIC: "custom/cmd/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + assert component_validator(mqtt_config) is mqtt_config + + +def test_conflict_on_sub_topic_platforms() -> None: + """Test that platforms with extra object_id sub-topics always conflict. + + Covers derive topics like position/command from the object_id through their + own config keys, so custom state and command topics cannot exempt them. + """ + validator = entity_duplicate_validator("cover") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_COMMAND_TOPIC: "custom/cmd/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_STATE_TOPIC: "custom/topic/b", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) + + +def test_no_conflict_on_disjoint_default_topics() -> None: + """Test that entities whose default topics are disjoint do not conflict. + + One entity uses only the default command topic and the other only the default + state topic, so they never share a topic. + """ + validator = entity_duplicate_validator("switch") + validator( + { + CONF_NAME: "Датчик открытия", + CONF_STATE_TOPIC: "custom/topic/a", + CONF_DISCOVERY: False, + } + ) + validator( + { + CONF_NAME: "Датчик закрытия", + CONF_COMMAND_TOPIC: "custom/cmd/b", + CONF_DISCOVERY: False, + } + ) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} + assert component_validator(config) is config + + +def test_no_conflict_on_empty_topic_prefix() -> None: + """Test that an empty topic_prefix disables the default topic conflict. + + With topic_prefix set to null no default topics exist at runtime, so entities + without custom state topics cannot conflict; only discovery still matters. + """ + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + component_validator = validate_no_object_id_conflicts( + REASON, conflict_filter=_topics_conflict + ) + # No default topics and no discovery: valid + config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} + assert component_validator(config) is config + + # Discovery still uses object_id-derived config topics: rejected + with pytest.raises(Invalid, match=r"mqtt builds default topics"): + component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py new file mode 100644 index 0000000000..e2cb513e3b --- /dev/null +++ b/tests/unit_tests/components/test_esp32_rmt_led_strip.py @@ -0,0 +1,57 @@ +import pytest + +from esphome.components.esp32_rmt_led_strip.light import ( + CONF_IS_WRGB, + CONF_RGBW_ORDER, + _split_rgbw_order, + _validate_rgbw_order, + _validate_rgbw_order_exclusivity, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW + + +def test_validate_rgbw_order() -> None: + assert _validate_rgbw_order("rwgb") == "RWGB" + + +@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) +def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: + with pytest.raises(cv.Invalid, match="permutation of RGBW"): + _validate_rgbw_order(rgbw_order) + + +@pytest.mark.parametrize( + ("rgbw_order", "expected"), + [ + ("WRGB", ("RGB", 0)), + ("RWGB", ("RGB", 1)), + ("GWRB", ("GRB", 1)), + ("RGBW", ("RGB", 3)), + ], +) +def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: + assert _split_rgbw_order(rgbw_order) == expected + + +@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + _validate_rgbw_order_exclusivity( + { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: conflict == CONF_IS_RGBW, + CONF_IS_WRGB: conflict == CONF_IS_WRGB, + } + ) + + +@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) +def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: + config = { + CONF_RGBW_ORDER: "RGBW", + CONF_IS_RGBW: False, + CONF_IS_WRGB: False, + } + config[legacy_option] = False + assert _validate_rgbw_order_exclusivity(config) is config diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index f231ac5fb7..ed1b12029e 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -137,3 +137,77 @@ def test_process_stacktrace_esp32_crash_handler( state = process_stacktrace(config, line_bt1, False) mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC") assert state is False + + mock_esp32_decode_pc.reset_mock() + + # Reason line carries no address, must not trigger a decode + line_reason = "[E][esp32.crash:079]: Reason: Fault - LoadProhibited (cause 28)" + state = process_stacktrace(config, line_reason, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # EXCVADDR pointing at code (e.g. jumping through a corrupted pointer) decodes + line_excvaddr = "[E][esp32.crash:081]: EXCVADDR: 0x400D9ABC (faulting address)" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp32_decode_pc.assert_called_once_with(config, "400D9ABC") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # EXCVADDR pointing at data (heap/null) is not a code address, must be ignored + line_excvaddr_data = ( + "[E][esp32.crash:081]: EXCVADDR: 0x0000001C (faulting address)" + ) + state = process_stacktrace(config, line_excvaddr_data, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # RISC-V MTVAL pointing at code decodes + line_mtval = "[E][esp32.crash:081]: MTVAL: 0x42001234 (faulting address)" + state = process_stacktrace(config, line_mtval, False) + mock_esp32_decode_pc.assert_called_once_with(config, "42001234") + assert state is False + + mock_esp32_decode_pc.reset_mock() + + # RISC-V MTVAL pointing at data must be ignored + line_mtval_data = "[E][esp32.crash:081]: MTVAL: 0x3FC80123 (faulting address)" + state = process_stacktrace(config, line_mtval_data, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + +def test_process_stacktrace_esp32_foreign_crash( + setup_core: Path, mock_esp32_decode_pc: Mock +) -> None: + """Crash records from a different firmware build must not be decoded.""" + from esphome.components.esp32 import process_stacktrace + + config = {"name": "test"} + + line_note = ( + "[E][esp32.crash:390]: Captured by a different firmware build; " + "addresses belong to that build's ELF" + ) + state = process_stacktrace(config, line_note, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False + + # Lowercase labels are deliberately not matched by any decoder regex, + # since symbols would come from the wrong ELF + lines_addrs = [ + "[E][esp32.crash:391]: pc: 0x400D1234", + "[E][esp32.crash:392]: excvaddr: 0x400D5678", + "[E][esp32.crash:392]: mtval: 0x42001234", + "[E][esp32.crash:393]: bt0: 0x400F19A6", + "[E][esp32.crash:394]: other core (0):", + "[E][esp32.crash:395]: bt15: 0x42005ABC", + ] + for line in lines_addrs: + state = process_stacktrace(config, line, False) + mock_esp32_decode_pc.assert_not_called() + assert state is False diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py index ee00bdc180..de54fcc6ca 100644 --- a/tests/unit_tests/components/test_libretiny.py +++ b/tests/unit_tests/components/test_libretiny.py @@ -2,7 +2,8 @@ import pytest -from esphome.components.libretiny import _detect_variant +from esphome.components import bk72xx, ln882x, rtl87xx +from esphome.components.libretiny import BASE_SCHEMA, _detect_variant from esphome.components.libretiny.const import ( FAMILY_LN882H, KEY_COMPONENT_DATA, @@ -11,7 +12,7 @@ from esphome.components.libretiny.const import ( from esphome.components.ln882x import COMPONENT_DATA import esphome.config_validation as cv from esphome.const import CONF_BOARD, CONF_FAMILY -from esphome.core import CORE +from esphome.core import CORE, KEY_CORE @pytest.fixture @@ -50,3 +51,36 @@ def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> No """Ids outside the rename map keep the family-override error.""" with pytest.raises(cv.Invalid, match="This board is unknown"): _detect_variant({CONF_BOARD: "not-a-real-board"}) + + +def test_platform_schemas_are_isolated_instances() -> None: + """Each LibreTiny platform must own its CONFIG_SCHEMA instance. + + BASE_SCHEMA is shared; every platform prepends its own _set_core_data + extra. On the shared object, importing two platform modules in one process + made either platform's validation run both extras, so the wrong platform's + component data won and known boards failed to resolve. + """ + platforms = (bk72xx, ln882x, rtl87xx) + schemas = [platform.CONFIG_SCHEMA for platform in platforms] + assert len({id(schema) for schema in (BASE_SCHEMA, *schemas)}) == 4 + # The shared base must not have accumulated any platform's extra. + # prepend_extra wraps validators in _Schema, so unwrap before comparing. + base_extras = [extra.schema for extra in BASE_SCHEMA._extra_schemas] + for platform in platforms: + assert platform._set_core_data not in base_extras + + +def test_each_platform_resolves_its_own_boards() -> None: + """Validating one platform's config must leave that platform's component + data in CORE.data. On the shared schema, the last-imported platform's + _set_core_data won for every platform, so known boards failed to resolve + with "This board is unknown".""" + CORE.data[KEY_CORE] = {} # written by the schema's _update_core_data extra + for platform, board in ( + (ln882x, "generic-ln882h"), + (bk72xx, "generic-bk7252"), + (rtl87xx, "generic-rtl8720cf-2mb-896k"), + ): + platform.CONFIG_SCHEMA({CONF_BOARD: board}) + assert CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] is platform.COMPONENT_DATA diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py index 023d926dc4..cd92bc24fa 100644 --- a/tests/unit_tests/components/test_rp2.py +++ b/tests/unit_tests/components/test_rp2.py @@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered by the framework tests under ``tests/unit_tests/``. """ +from pathlib import Path +import re + +from esphome.components import rp2 + def test_board_id_has_wifi_for_known_wifi_board() -> None: """``rpipicow`` is the canonical Pico W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipicow") is True def test_board_id_has_wifi_for_known_non_wifi_board() -> None: """Plain ``rpipico`` has no CYW43 → False.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico") is False def test_board_id_has_wifi_for_rp2350_w_variant() -> None: """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico2w") is True @@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: block and any genuinely-unsupported config trips the existing "no CYW43" guard at compile time. """ - from esphome.components import rp2 - assert rp2.board_id_has_wifi("not-a-real-board-id") is True @@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None: opts in via ``ALIASES``; without this declaration the rename framework wouldn't route legacy configs. """ - from esphome.components import rp2 - assert "rp2040" in rp2.ALIASES assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" @@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: assert rp2040_boards is rp2_boards assert rp2040_generate is rp2_generate + + +def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None: + """The segment pool is global while the send queue is per-PCB. + + lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``, + which is the floor for a *single* connection: at equality one busy PCB can + drain the pool for every other PCB. Dropping back to that floor would + rebuild the starvation this sizing exists to prevent, and nothing in the + build would complain. + """ + assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN + + +def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None: + """``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on + ``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the + heap past that bound is a real option, but it should be a deliberate one + rather than a side effect of tuning. + """ + assert rp2.LWIP_MEM_SIZE <= 64000 + + +def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None: + """Pin the floor as well as the ceiling. + + The ceiling above is satisfied by arduino-pico's own 16 KB, which is the + value this change exists to move off, so on its own it would let a revert + through. Derive the floor from the sizing comment on the constant: with + TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block + (pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB), + a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's + max_connections on rp2 is 4. Room for three concurrent senders is the + minimum that makes the change worth making; 16 KB does not reach it. + """ + segments_per_full_send_buf = 4 + bytes_per_mss_block = 1536 + concurrent_senders = 3 + + assert ( + concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block + <= rp2.LWIP_MEM_SIZE + ) + + +def test_lwip_defines_carry_the_sizing_into_the_header() -> None: + """The constants above only matter if they reach the generated header. + + ``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it + rather than on the constants alone: dropping a key here would silently + fall back to arduino-pico's own value while every other assertion in this + file stayed green. + """ + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + + assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE) + assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG) + assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN) + # Socket-derived counts pass through untouched. + assert defines["MEMP_NUM_TCP_PCB"] == "8" + assert defines["MEMP_NUM_UDP_PCB"] == "6" + assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2" + + +def test_lwipopts_template_renders_every_sizing_value() -> None: + """Render the template the way _generate_lwipopts_h() does and check the + header that actually ships. + + Covers both directions. A ``#define`` block deleted from the template + leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB + heap this change exists to move off, and the loop below catches that. A + placeholder with no dict key would otherwise render empty and emit a bare + ``#define FOO``; StrictUndefined turns that into an error instead. + Matching on text also survives a filter or conditional appearing in the + template later, which a placeholder regex would not. + """ + from jinja2 import Environment, StrictUndefined + + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" + ) + rendered = ( + Environment(keep_trailing_newline=True, undefined=StrictUndefined) + .from_string(template_text) + .render(**defines) + ) + + for name, value in defines.items(): + assert re.search( + rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE + ), f"{name} did not reach the generated header as {value!r}" diff --git a/tests/unit_tests/components/test_rp2_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py index 68bbada59b..329248488c 100644 --- a/tests/unit_tests/components/test_rp2_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -8,7 +8,11 @@ import textwrap import pytest -from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import ( + generate, + load_boards, + parse_variant_pins, +) PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once @@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None: assert boards["rpipico"]["name"] == "Raspberry Pi Pico" assert boards["rpipico"]["mcu"] == "rp2040" assert boards["rpipico"]["max_pin"] == 29 + # The die key only applies to the RP2350, which ships as more than one die + assert "die" not in boards["rpipico"] assert "rpipico" in board_pins assert board_pins["rpipico"]["LED"] == 25 @@ -158,19 +164,195 @@ def test_load_basic_board(arduino_pico: Path) -> None: def test_load_rp2350_board(arduino_pico: Path) -> None: + """The Pico 2 uses the RP2350A die, which only exposes GPIO 0-29.""" _add_board( arduino_pico, "rpipico2", mcu="rp2350", vendor="Raspberry Pi", name="Pico 2", - pins_header=PICO_PINS_HEADER, + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, ) _, boards = load_boards(arduino_pico) assert boards["rpipico2"]["mcu"] == "rp2350" - assert boards["rpipico2"]["max_pin"] == 47 + assert boards["rpipico2"]["max_pin"] == 29 + assert boards["rpipico2"]["die"] == "A" + + +def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None: + """A variant without PICO_RP2350A cannot be classified; fail loudly.""" + _add_board( + arduino_pico, + "no_die_define", + mcu="rp2350", + pins_header=PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="no PICO_RP2350A define"): + load_boards(arduino_pico) + + +def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None: + """An unparseable PICO_RP2350A value must not silently widen to B-die.""" + _add_board( + arduino_pico, + "hex_die_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0x1\n" + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="unrecognized PICO_RP2350A value"): + load_boards(arduino_pico) + + +def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None: + """A third die breaks the "not A means B" reading, so stop rather than guess.""" + _add_board( + arduino_pico, + "future_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n" + + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="found a PICO_RP2350C define"): + load_boards(arduino_pico) + + +def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None: + """PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter.""" + _add_board( + arduino_pico, + "revision_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n" + + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["revision_define"]["die"] == "A" + + +def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: + """Literal forms like (1u) classify the same as bare 1.""" + _add_board( + arduino_pico, + "paren_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A (1u)\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["paren_die"]["max_pin"] == 29 + assert boards["paren_die"]["die"] == "A" + + +def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: + """A variant declaring the RP2350B die keeps the full GPIO 0-47 range. + + The define uses extra whitespace, matching real variant headers. + """ + _add_board( + arduino_pico, + "weact_rp2350b", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0 // RP2350B\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["weact_rp2350b"]["max_pin"] == 47 + assert boards["weact_rp2350b"]["die"] == "B" + + +def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None: + """Generic boards leave the die a build-time choice; stay permissive. + + The permissive range is a fallback, so the die must be recorded as unknown + rather than as the B die. + """ + _add_board( + arduino_pico, + "generic_rp2350", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["generic_rp2350"]["max_pin"] == 47 + assert boards["generic_rp2350"]["die"] is None + + +def test_generated_output_records_die(arduino_pico: Path) -> None: + """The rendered boards.py carries the die on every RP2350 entry.""" + _add_board( + arduino_pico, + "rpipico", + pins_header=PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "a_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "b_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "menu_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + namespace: dict = {} + exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace) + + boards = namespace["BOARDS"] + assert boards["a_die"]["die"] == "A" + assert boards["b_die"]["die"] == "B" + assert boards["menu_die"]["die"] is None + assert "die" not in boards["rpipico"] + + +def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None: + """Pin defines beyond the A-die range are dropped from the pin map.""" + header = textwrap.dedent("""\ + #define PICO_RP2350A 1 + #define PIN_LED (25u) + #define PIN_SPI0_MISO (40u) + """) + _add_board(arduino_pico, "a_die", mcu="rp2350", pins_header=header) + + board_pins, _ = load_boards(arduino_pico) + + assert board_pins["a_die"]["LED"] == 25 + assert "MISO" not in board_pins["a_die"] + + +def test_rp2350a_board_keeps_cyw43_virtual_pins(arduino_pico: Path) -> None: + """A-die narrowing must not filter CYW43 virtual pins (64-66).""" + _add_board( + arduino_pico, + "rpipico2w", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICOW_PINS_HEADER, + ) + + board_pins, boards = load_boards(arduino_pico) + + assert boards["rpipico2w"]["max_pin"] == 29 + assert boards["rpipico2w"]["max_virtual_pin"] == 64 + assert board_pins["rpipico2w"]["LED"] == 64 def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None: diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 13450b10f0..9de8f715ef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,6 +10,7 @@ not be part of a unit test suite. """ from collections.abc import Generator +import os from pathlib import Path import sys from unittest.mock import Mock, patch @@ -40,6 +41,19 @@ def fixture_path() -> Path: return here / "fixtures" +@pytest.fixture +def probe_env() -> dict[str, str]: + """Environment for running fixture probe scripts as subprocesses. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child. + """ + python_path = str(package_root) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + return os.environ | {"PYTHONPATH": python_path} + + @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py index daa429dc96..96fcc5b1c6 100644 --- a/tests/unit_tests/core/common.py +++ b/tests/unit_tests/core/common.py @@ -29,5 +29,5 @@ def load_config_from_fixture( ) -> Config | None: """Load configuration from a fixture file.""" fixture_path = fixtures_dir / fixture_name - yaml_content = fixture_path.read_text() + yaml_content = fixture_path.read_text(encoding="utf-8") return load_config_from_yaml(yaml_file, yaml_content) diff --git a/tests/unit_tests/core/conftest.py b/tests/unit_tests/core/conftest.py index 42e59c15e6..9ef31a82b9 100644 --- a/tests/unit_tests/core/conftest.py +++ b/tests/unit_tests/core/conftest.py @@ -12,7 +12,7 @@ def yaml_file(tmp_path: Path) -> Callable[[str], Path]: def _yaml_file(content: str) -> Path: yaml_path = tmp_path / "test.yaml" - yaml_path.write_text(content) + yaml_path.write_text(content, encoding="utf-8") return yaml_path return _yaml_file diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 0362c40bce..e09edd7f26 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1242,6 +1242,15 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: None, "https://github.com/esphome/noise-c.git", ), + # A local file:// source is routed to the repository, not a registry name + # -- including the fewer-than-two-slashes spelling. + ( + "TeslaBLE=file:///config/esphome/lib_dev", + "TeslaBLE", + None, + "file:///config/esphome/lib_dev", + ), + ("MyLib=file:lib_dev", "MyLib", None, "file:lib_dev"), ], ) def test_add_library_str( diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 3ac4ce27af..64400c4fd4 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Test get_base_entity_object_id function matches C++ behavior.""" +"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,16 +25,17 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_object_id, + get_base_entity_name, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, + validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1_hash_name, sanitize, snake_case from .common import load_config_from_fixture @@ -57,206 +58,26 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_with_entity_name() -> None: - """Test when entity has its own name - should use entity name.""" - # Simple name - assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" - assert ( - get_base_entity_object_id("Temperature Sensor", "Device Name") - == "temperature_sensor" - ) - # Even with device name, entity name takes precedence - assert ( - get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") - == "temperature_sensor" - ) - - # Name with special characters - assert ( - get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) - == "temp__________sensor" - ) - assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" - - # Already snake_case - assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" - - # Mixed case - assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" - assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" - - -def test_empty_name_with_device_name() -> None: - """Test when entity has empty name and is on a sub-device - should use device name.""" - # C++ behavior: when has_own_name is false and device is set, uses device->get_name() - assert ( - get_base_entity_object_id("", "Friendly Device", "Sub Device 1") - == "sub_device_1" - ) - assert ( - get_base_entity_object_id("", "Kitchen Controller", "controller_1") - == "controller_1" - ) - assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" - - -def test_empty_name_with_friendly_name() -> None: - """Test when entity has empty name and no device - should use friendly name.""" - # C++ behavior: when has_own_name is false, uses App.get_friendly_name() - assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" - assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" - assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" - - # Special characters in friendly name - assert get_base_entity_object_id("", "Device!@#$%") == "device_____" - - -def test_empty_name_no_friendly_name() -> None: - """Test when entity has empty name and no friendly name - should use device name.""" - # Test with CORE.name set - CORE.name = "device-name" - assert get_base_entity_object_id("", None) == "device-name" - - CORE.name = "Test Device" - assert get_base_entity_object_id("", None) == "test_device" - - -def test_edge_cases() -> None: - """Test edge cases.""" - # Only spaces - assert get_base_entity_object_id(" ", None) == "___" - - # Unicode characters (should be replaced) - assert get_base_entity_object_id("Température", None) == "temp_rature" - assert get_base_entity_object_id("测试", None) == "__" - - # Empty string with empty friendly name (empty friendly name is treated as None) - # Falls back to CORE.name - CORE.name = "device" - assert get_base_entity_object_id("", "") == "device" - - # Very long name (should work fine) - long_name = "a" * 100 + " " + "b" * 100 - expected = "a" * 100 + "_" + "b" * 100 - assert get_base_entity_object_id(long_name, None) == expected - - -@pytest.mark.parametrize( - ("name", "expected"), - [ - ("Temperature Sensor", "temperature_sensor"), - ("Living Room Light", "living_room_light"), - ("Test-Device_123", "test-device_123"), - ("Special!@#Chars", "special___chars"), - ("UPPERCASE NAME", "uppercase_name"), - ("lowercase name", "lowercase_name"), - ("Mixed Case Name", "mixed_case_name"), - (" Spaces ", "___spaces___"), - ], -) -def test_matches_cpp_helpers(name: str, expected: str) -> None: - """Test that the logic matches using snake_case and sanitize directly.""" - # For non-empty names, verify our function produces same result as direct snake_case + sanitize - assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) - assert get_base_entity_object_id(name, None) == expected - - -def test_empty_name_fallback() -> None: - """Test empty name handling which falls back to friendly_name or CORE.name.""" - # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) - # Instead it falls back to friendly_name or CORE.name - assert sanitize(snake_case("")) == "" # Direct conversion gives empty string - # But our function returns a fallback - CORE.name = "device" - assert get_base_entity_object_id("", None) == "device" # Uses device name - - -def test_name_add_mac_suffix_behavior() -> None: - """Test behavior related to name_add_mac_suffix. - - In C++, an entity's object_id is computed from its name_ via - write_object_id_to() (sanitized snake_case). When an entity has no name, - configure_entity_() sets name_ from the friendly name, with the MAC suffix - appended when name_add_mac_suffix is enabled. Our function always returns - the same result since we're calculating the base for duplicate tracking. - """ - # The function should always return the same result regardless of - # name_add_mac_suffix setting, as we're calculating the base object_id - assert get_base_entity_object_id("", "Test Device") == "test_device" - assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" - - -def test_priority_order() -> None: +def test_get_base_entity_name_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority + # 1. Entity name has highest priority and is used as-is, no transformations assert ( - get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") - == "entity_name" + get_base_entity_name("Entity Name", "Friendly Name", "Device Name") + == "Entity Name" ) + assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert ( - get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" - ) + assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" + assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" - # 4. CORE.name is last resort - assert get_base_entity_object_id("", None, None) == "core-device" - - -@pytest.mark.parametrize( - ("name", "friendly_name", "device_name", "expected"), - [ - # name, friendly_name, device_name, expected - ("Living Room Light", None, None, "living_room_light"), - ("", "Kitchen Controller", None, "kitchen_controller"), - ( - "", - "ESP32 Device", - "controller_1", - "controller_1", - ), # Device name takes precedence - ("GPIO2 Button", None, None, "gpio2_button"), - ("WiFi Signal", "My Device", None, "wifi_signal"), - ("", None, "esp32_node", "esp32_node"), - ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), - ], -) -def test_real_world_examples( - name: str, friendly_name: str | None, device_name: str | None, expected: str -) -> None: - """Test real-world entity naming scenarios.""" - result = get_base_entity_object_id(name, friendly_name, device_name) - assert result == expected - - -def test_issue_6953_scenarios() -> None: - """Test specific scenarios from issue #6953.""" - # Scenario 1: Multiple empty names on main device with name_add_mac_suffix - # The Python code calculates the base, C++ might append MAC suffix dynamically - CORE.name = "device-name" - CORE.friendly_name = "Friendly Device" - - # All empty names should resolve to same base - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" - - # Scenario 2: Empty names on sub-devices - assert ( - get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" - ) - assert ( - get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" - ) - - # Scenario 3: xyz duplicates - assert get_base_entity_object_id("xyz", None) == "xyz" - assert get_base_entity_object_id("xyz", "Device") == "xyz" + # 4. CORE.name is last resort; an empty friendly name falls through to it + assert get_base_entity_name("", None, None) == "core-device" + assert get_base_entity_name("", "") == "core-device" # Tests for setup_entity function @@ -515,9 +336,10 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("", "sensor", "temperature") in CORE.unique_ids + temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) + assert temperature_key in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[("", "sensor", "temperature")] + metadata = CORE.unique_ids[temperature_key] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -525,8 +347,9 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - assert ("", "sensor", "humidity") in CORE.unique_ids - metadata2 = CORE.unique_ids[("", "sensor", "humidity")] + humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) + assert humidity_key in CORE.unique_ids + metadata2 = CORE.unique_ids[humidity_key] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -547,18 +370,19 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass + name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", "temperature") in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")] + assert ("device1", "sensor", name_hash) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", "temperature") in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")] + assert ("device2", "sensor", name_hash) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -610,6 +434,33 @@ def test_entity_different_platforms_yaml_validation( assert result is not None +def test_object_id_conflict_mqtt_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that names sanitizing to the same object_id fail when mqtt is configured.""" + result = load_config_from_fixture( + yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "mqtt builds default topics and discovery topics from the entity object_id" + in captured.out + ) + + +def test_object_id_conflict_without_mqtt_yaml_validation( + yaml_file: Callable[[str], str], +) -> None: + """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" + result = load_config_from_fixture( + yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR + ) + # This should succeed + assert result is not None + + def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -668,7 +519,8 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - assert ("", "sensor", "temperature") in CORE.unique_ids + temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) + assert temperature_key in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -676,7 +528,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + count = sum(1 for k in CORE.unique_ids if k == temperature_key) assert count == 1 # Another internal entity with same name should also pass @@ -684,7 +536,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + count = sum(1 for k in CORE.unique_ids if k == temperature_key) assert count == 1 # Non-internal entity with same name should fail @@ -712,30 +564,148 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that non-ASCII names show helpful error messages.""" + """Test that distinct non-ASCII names no longer collide. + + These names used to be rejected because both sanitize to only underscores; + the entity key now hashes the raw name so they stay distinct. + """ # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # First Russian sensor should pass + # Both Russian sensors should pass even though they sanitize identically config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 - # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} + validated2 = validator(config2) + assert validated2 == config2 + + # An exact duplicate still fails + config3 = {CONF_NAME: "Датчик открытия основного крана"} + with pytest.raises( + Invalid, + match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", + ): + validator(config3) + + +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different names with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b + name_a = "Sensor m2CZ" + name_b = "Sensor qCaa" + assert name_a != name_b + assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" - r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" - r"Both convert to ASCII ID: '_______________________________'.*" - r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + rf"Duplicate sensor entity with name '{name_b}' found.*" + rf"The names '{name_b}' and '{name_a}' produce the.*" + r"same entity key hash \(0x0ee5ff7b\).*" + r"To fix: Rename one of the entities", re.DOTALL, ), ): validator(config2) +def test_object_id_conflicts_rejected_by_component_validator() -> None: + """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" + validator = entity_duplicate_validator("sensor") + + # Both names validate fine in general (distinct raw names, distinct keys) + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + # A component that addresses entities by object_id must reject the config + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + with pytest.raises( + Invalid, + match=re.compile( + r"mqtt builds default topics from the entity object_id.*" + r"sensor entities 'Датчик открытия', 'Датчик закрытия' " + r"share the object_id '_______________'.*" + r"To fix: Add unique ASCII characters", + re.DOTALL, + ), + ): + component_validator({}) + + +def test_object_id_conflicts_skipped_in_testing_mode() -> None: + """Test that testing_mode skips the conflict check, as used for grouped testing.""" + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Датчик открытия"}) + validator({CONF_NAME: "Датчик закрытия"}) + + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + CORE.testing_mode = True + try: + config: dict = {} + assert component_validator(config) is config + finally: + CORE.testing_mode = False + + +def test_object_id_conflicts_none_recorded() -> None: + """Test that distinct object_ids produce no conflicts.""" + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Temperature"}) + validator({CONF_NAME: "Humidity"}) + + component_validator = validate_no_object_id_conflicts( + "mqtt builds default topics from the entity object_id" + ) + config: dict = {} + assert component_validator(config) is config + + +def test_object_id_conflicts_device_scoped() -> None: + """Test that the object_id conflict check is scoped per device. + + Same-named entities on different sub-devices were accepted before entity keys + moved to raw names, so the check keeps that scope; conflicts within one device + are still reported with the device named in the message. + """ + validator = entity_duplicate_validator("sensor") + validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) + validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) + + component_validator = validate_no_object_id_conflicts( + "prometheus builds metric labels from the entity object_id" + ) + config: dict = {} + assert component_validator(config) is config + + # Two names sanitizing identically on the same sub-device still conflict + validator( + {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} + ) + validator( + {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} + ) + with pytest.raises( + Invalid, + match=re.compile( + r"prometheus builds metric labels.*on device 'device1'", re.DOTALL + ), + ): + component_validator({}) + + def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -793,7 +763,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -822,7 +792,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -852,7 +822,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 @pytest.mark.asyncio @@ -883,7 +853,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_object_id_hash") == 0 + assert config.get("_entity_key") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml new file mode 100644 index 0000000000..4a6f56f473 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml @@ -0,0 +1,22 @@ +esphome: + name: test-object-id-conflict + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +mqtt: + broker: test.mosquitto.org + +sensor: + # Distinct raw names are fine in general, but both sanitize to the same + # object_id, which MQTT still uses to build default topics - should fail + - platform: template + name: "Датчик открытия" + lambda: return 21.0; + - platform: template + name: "Датчик закрытия" + lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml new file mode 100644 index 0000000000..c0fbd5cbba --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml @@ -0,0 +1,15 @@ +esphome: + name: test-object-id-ok + +esp32: + board: esp32dev + +sensor: + # Distinct raw names that sanitize to the same object_id are allowed when no + # component addresses entities by object_id (no mqtt or prometheus configured) + - platform: template + name: "Датчик открытия" + lambda: return 21.0; + - platform: template + name: "Датчик закрытия" + lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py new file mode 100644 index 0000000000..a77d5c8f28 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/closing_probe.py @@ -0,0 +1,11 @@ +"""Leave a partial line behind and then close the stream under the runner. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining +cannot work here; the point is that the failure is reported rather than +raised out of the runner's cleanup, where it would bury the exit code. +""" + +import sys + +sys.stdout.write("partial before close") +sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py new file mode 100644 index 0000000000..bf434cc24e --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/crashing_probe.py @@ -0,0 +1,11 @@ +"""Die part way through a line, the way a build that blows up does. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +message has no trailing newline, so the runner's shim is holding it when +the process exits; nothing else will ever come to release it. +""" + +import sys + +sys.stdout.write("FATAL: ld returned 1 exit status") +sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py new file mode 100644 index 0000000000..04c2b2ed8c --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/filtering_probe.py @@ -0,0 +1,15 @@ +"""Write a mix of noisy and useful build lines, without flushing. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner's shim owns both the filtering and the flushing, so this script +only writes. +""" + +import sys + +sys.stdout.write("Project build complete.\n") +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("-- Component paths: /a /b /c\n") +sys.stdout.write("[2/9] Building C object\n") +# No terminator, so the shim has to hold this one back. +sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py new file mode 100644 index 0000000000..727cda25ce --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/formfeed_probe.py @@ -0,0 +1,12 @@ +"""Write a form feed part way through the output. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form +feed is not a line terminator here, so everything written must still come +out, including the complete lines that follow it. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("page one\x0cpage two\n") +sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py new file mode 100644 index 0000000000..9c81f8eb7b --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py @@ -0,0 +1,10 @@ +"""End on an unterminated line that the filter is supposed to drop. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to +check that releasing a held-back line still applies the filter. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py new file mode 100644 index 0000000000..c05741e311 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/streaming_probe.py @@ -0,0 +1,14 @@ +"""Print one line, then stay alive so the caller can prove it streamed. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner wraps stdout in its filtering shim, so this script deliberately +does not flush: the shim has to do it. The long sleep keeps the process +running, so anything the caller reads must have arrived while the build +was still going rather than at exit. +""" + +import sys +import time + +sys.stdout.write("Compiling main.cpp\n") +time.sleep(60) diff --git a/tests/unit_tests/fixtures/lazy_imports/_leak_report.py b/tests/unit_tests/fixtures/lazy_imports/_leak_report.py new file mode 100644 index 0000000000..00d387cd04 --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/_leak_report.py @@ -0,0 +1,19 @@ +"""Shared tail for the lazy-import fixture scripts.""" + +import sys + + +def print_leaked_modules() -> None: + """Report argv-listed heavy modules (plus any component package) loaded. + + Any component package counts as a leak, not just the ones on the + watch list: executing one drags in codegen/validation machinery by + design. + """ + leaked = [module for module in sys.argv[1:] if module in sys.modules] + leaked += [ + module + for module in sys.modules + if module.startswith("esphome.components.") and module not in leaked + ] + print(",".join(leaked)) diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py new file mode 100644 index 0000000000..969528304b --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -0,0 +1,27 @@ +"""Shared storage-sidecar factory for the lazy-import fixture scripts.""" + +from esphome.storage_json import StorageJSON + + +def make_storage() -> StorageJSON: + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + return StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2026.1.0", + src_version=1, + address="1.2.3.4", + web_port=None, + target_platform="ESP32S3", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="esp-idf", + core_platform="esp32", + area=None, + framework_version="5.3.1", + ) diff --git a/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py new file mode 100644 index 0000000000..e622948aed --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/esptool_upload_fast_path.py @@ -0,0 +1,45 @@ +"""Run the esptool serial-upload path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +The variant reaches the esptool command line from CORE.data directly; if +someone re-adds the esp32 package import for it, this reports the leak. +""" + +import os +import sys +from unittest.mock import patch + +from _leak_report import print_leaked_modules + +from esphome.__main__ import upload_using_esptool +from esphome.const import ( + CONF_ESPHOME, + KEY_CORE, + KEY_ESP32, + KEY_TARGET_PLATFORM, + KEY_VARIANT, +) +from esphome.core import CORE + +# An ambient ESPHOME_USE_SUBPROCESS would route past the patched +# run_external_command into run_external_process and confuse the checks. +os.environ.pop("ESPHOME_USE_SUBPROCESS", None) + +CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} +CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + +with patch("esphome.__main__.run_external_command", return_value=0) as mock_run: + rc = upload_using_esptool( + {CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", "firmware.bin", None + ) + +# Fail loudly if the upload path stopped doing its work; otherwise an +# empty leak list could just mean nothing ran. +if rc != 0: + sys.exit(f"upload_using_esptool returned {rc}") +cmd = list(mock_run.call_args[0][1:]) +if cmd[cmd.index("--chip") + 1] != "esp32s3": + sys.exit(f"variant did not reach the esptool command line: {cmd}") + +print_leaked_modules() diff --git a/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py new file mode 100644 index 0000000000..1e34bc90a1 --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/storage_json_fast_path.py @@ -0,0 +1,26 @@ +"""Run the esp32 storage fast path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +""" + +import sys + +from _leak_report import print_leaked_modules +from _storage import make_storage + +from esphome.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT +from esphome.core import CORE, Version + +make_storage().apply_to_core() + +# Fail loudly if the esp32 fast path stopped doing its work; otherwise an +# empty leak list could just mean nothing ran. Explicit exits rather than +# asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. +esp32_data = CORE.data.get(KEY_ESP32, {}) +if esp32_data.get(KEY_VARIANT) != "ESP32S3": + sys.exit(f"apply_to_core did not record the variant: {esp32_data!r}") +if esp32_data.get(KEY_IDF_VERSION) != Version(5, 3, 1): + sys.exit(f"apply_to_core did not parse the framework version: {esp32_data!r}") + +print_leaked_modules() diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py new file mode 100644 index 0000000000..f70a3f85ac --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -0,0 +1,100 @@ +"""Run the upload command dispatch path and report which heavy modules loaded. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +Covers three fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the +validated-config cache parse stays voluptuous free, and the JSON cache +(lambda sentinel included) resolves without pyyaml or esphome.yaml_util. +""" + +import json +import os +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch + +from _leak_report import print_leaked_modules +from _storage import make_storage + +# Everything imported past this point is the code under test; the pop +# below must only drop what the setup itself preloaded, or it would +# hide modules the dispatch chain pulls in (tarfile has no other guard). +_FIXTURE_PRELOADED = frozenset(sys.modules) + +from esphome import __main__ as main_mod # noqa: E402 +from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402 + +CONFIG_TEXT = "esphome:\n name: t\n" +LAMBDA_BODY = 'ESP_LOGD("t", "x");' + +# An ambient data-dir override would relocate the storage tree away +# from the tmp config dir this fixture builds. +os.environ.pop("ESPHOME_DATA_DIR", None) +os.environ.pop("ESPHOME_IS_HA_ADDON", None) + +with tempfile.TemporaryDirectory() as _td: + tmp = Path(_td) + conf_path = tmp / "test.yaml" + conf_path.write_text(CONFIG_TEXT) + + storage_dir = tmp / ".esphome" / "storage" + storage_dir.mkdir(parents=True) + # The cache carries a lambda sentinel so loading revives a real Lambda + # on the fast path. The sidecar is written to the layout + # ext_storage_path resolves once run_esphome sets CORE.config_path; + # going through CORE here would be circular. + cache_path = storage_dir / "test.yaml.validated.json" + cache_path.write_text( + json.dumps( + { + "v": 1, + "esphome": ESPHOME_VERSION, + "config": { + "esphome": {"name": "t"}, + "script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}], + }, + } + ) + ) + os.utime(cache_path) # keep the cache at least as fresh as the source + make_storage().save(storage_dir / "test.yaml.json") + + dispatched = {} + + def fake_upload(args, config): + dispatched["config"] = config + return 0 + + # This setup pre-imports some watched stdlib modules (tempfile above, + # write_file inside make_storage().save(), unittest.mock -> asyncio -> + # subprocess). Drop exactly those so only a genuine dispatch-time + # re-import is reported; live objects keep their references, so + # cleanup still works. Module-level re-imports are out of reach here + # (esphome.__main__ is already loaded) — the bare-import check in + # test_lazy_imports owns that contract. + for module in sys.argv[1:]: + if module in _FIXTURE_PRELOADED: + sys.modules.pop(module, None) + + with patch.dict(main_mod.POST_CONFIG_ACTIONS, {"upload": fake_upload}): + exit_code = main_mod.run_esphome( + ["esphome", "upload", str(conf_path), "--device", "192.0.2.1"] + ) + + # Fail loudly if the fast path didn't do its work; otherwise an empty + # leak list could just mean nothing ran. Explicit exits rather than + # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. + if exit_code != 0: + sys.exit(f"run_esphome exited {exit_code} before dispatching upload") + config = dispatched.get("config") + if config is None or config.get("esphome") != {"name": "t"}: + sys.exit(f"cache did not resolve through the fast path: {dispatched!r}") + from esphome.core import Lambda + + revived = config["script"][0]["lambda"] + if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY: + sys.exit(f"lambda sentinel did not revive: {revived!r}") + + print_leaked_modules() diff --git a/tests/unit_tests/fixtures/log/setup_log_probe.py b/tests/unit_tests/fixtures/log/setup_log_probe.py new file mode 100644 index 0000000000..b9e2e02a8c --- /dev/null +++ b/tests/unit_tests/fixtures/log/setup_log_probe.py @@ -0,0 +1,21 @@ +"""Report whether setup_log() pulled in colorama, then print a colored line. + +Executed as a subprocess by test_log.py because module imports are +process-global: the parent prints ``colorama_loaded=True/False`` plus an +ANSI colored line so the caller can observe whether the codes survive to +the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run. +""" + +import sys + +from esphome.core import CORE +from esphome.log import setup_log + +if "--dashboard" in sys.argv: + CORE.dashboard = True + +setup_log() + +print(f"colorama_loaded={'colorama' in sys.modules}") +print("\033[31mred\033[0m end") +sys.stdout.flush() diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py new file mode 100644 index 0000000000..19ed83abe1 --- /dev/null +++ b/tests/unit_tests/test_api_client.py @@ -0,0 +1,165 @@ +"""Tests for esphome.api_client.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome import api_client +from esphome.const import ( + CONF_ENCRYPTION, + CONF_KEY, + CONF_PORT, + KEY_CORE, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE + + +def test_component_shim_reexports_runtime_client() -> None: + """The old import paths must keep working for external code.""" + from esphome.components import api + from esphome.components.api import client as shim + + assert shim.run_logs is api_client.run_logs + assert shim.async_run_logs is api_client.async_run_logs + assert api.CONF_ENCRYPTION is CONF_ENCRYPTION + + +@pytest.mark.asyncio +async def test_async_run_logs_full_flow(caplog) -> None: + """Drive async_run_logs end to end with a fake connection. + + Covers the encryption key extraction, the multi-address banner, the + registry-miss unavailable notice at session start, the on_log + handler, and the stop() cleanup in the finally block. + """ + caplog.set_level("INFO", logger="esphome.api_client") + caplog.set_level("INFO", logger="esphome.platform_hooks") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"} + config = { + "esphome": {"name": "test"}, + "api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}}, + } + + stop = AsyncMock() + run_started = asyncio.Event() + + async def fake_async_run(*args, **kwargs): + run_started.set() + return stop + + mock_run = AsyncMock(side_effect=fake_async_run) + printed: list[str] = [] + + with ( + patch.object(api_client, "async_run", mock_run), + patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "safe_print", printed.append), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"]) + ) + # Let the task run up to the forever-wait; the timeout fails the + # test instead of hanging it if the task dies early. + async with asyncio.timeout(1): + await run_started.wait() + on_log = mock_run.call_args.args[1] + on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960")) + # Cancellation is the real termination path; stop() must still run. + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Both addresses reach APIClient, along with the noise key. + assert mock_client.call_args.kwargs["noise_psk"] == "psk123" + assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"] + assert "1.2.3.4 or 5.6.7.8" in caplog.text + # host has no stacktrace analyzer; the notice fires at session start. + assert "Stacktrace analysis is unavailable" in caplog.text + # The log message was printed with a timestamp prefix. + assert any("hello world" in line for line in printed) + # stop() ran in the finally block despite the cancellation. + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_never_resolves_without_crash_lines() -> None: + """The headline claim: an ordinary session imports no platform code.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + run_started = asyncio.Event() + + async def fake_async_run(*args, **kwargs): + run_started.set() + return stop + + mock_run = AsyncMock(side_effect=fake_async_run) + + with ( + patch.object(api_client, "async_run", mock_run), + patch.object(api_client, "APIClient"), + patch.object(api_client, "safe_print"), + patch("esphome.platform_hooks.get_stacktrace_handler") as mock_resolve, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"]) + ) + async with asyncio.timeout(1): + await run_started.wait() + on_log = mock_run.call_args.args[1] + on_log(Mock(message=b"[I][app:100] hello\n[C][wifi:200] connected")) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_resolve.assert_not_called() + + +def test_run_logs_suppresses_keyboard_interrupt() -> None: + """Ctrl-C during log streaming exits cleanly instead of tracebacking.""" + with patch.object( + api_client, + "async_run_logs", + AsyncMock(side_effect=KeyboardInterrupt), + ) as mock_run: + api_client.run_logs( + {"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False + ) + + assert mock_run.call_args.kwargs["subscribe_states"] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("extra_config", "expected_deep_sleep"), + [({"deep_sleep": {}}, True), ({}, False)], +) +async def test_async_run_logs_passes_deep_sleep( + extra_config: dict, expected_deep_sleep: bool +) -> None: + """async_run_logs tells async_run whether the device deep sleeps. + + That flag is the only thing capping reconnect backoff for a device + that is only briefly awake; dropping it means missed wake windows. + """ + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config} + # async_run blocks forever after connecting; raise to unwind + # async_run_logs once we have captured how it was called. + sentinel = RuntimeError("stop the wait") + + with ( + patch.object( + api_client, "async_run", AsyncMock(side_effect=sentinel) + ) as mock_run, + patch.object(api_client, "APIClient"), + pytest.raises(RuntimeError, match="stop the wait"), + ): + await api_client.async_run_logs(config, ["1.2.3.4"]) + + assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep diff --git a/tests/unit_tests/test_async_thread.py b/tests/unit_tests/test_async_thread.py new file mode 100644 index 0000000000..a64be2f7c7 --- /dev/null +++ b/tests/unit_tests/test_async_thread.py @@ -0,0 +1,316 @@ +"""Tests for the async thread helpers.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any +from unittest.mock import patch + +import pytest + +from esphome.async_thread import AsyncDispatchTimeout, AsyncThreadRunner, run_async + + +def _cleanup_threads() -> set[threading.Thread]: + """Return the currently live orphan-cleanup threads.""" + return {t for t in threading.enumerate() if t.name == "async-orphan-cleanup"} + + +def _join_new_cleanup_threads(before: set[threading.Thread]) -> None: + """Wait for cleanup threads spawned since ``before`` to finish.""" + for thread in _cleanup_threads() - before: + thread.join(5) + assert not thread.is_alive() + + +def test_run_async_returns_result() -> None: + """The coroutine's result is returned to the sync caller.""" + + async def coro() -> int: + await asyncio.sleep(0) + return 42 + + assert run_async(coro) == 42 + + +def test_run_async_propagates_exception() -> None: + """Exceptions raised by the coroutine surface in the caller.""" + + async def coro() -> None: + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + run_async(coro) + + +def test_run_async_propagates_base_exception() -> None: + """A BaseException from the coroutine surfaces instead of a None result.""" + + class Boom(BaseException): + pass + + async def coro() -> None: + raise Boom + + with pytest.raises(Boom): + run_async(coro) + + +def test_run_async_timeout() -> None: + """A coroutine that does not finish in time raises TimeoutError.""" + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.05) + + # Unblock the abandoned runner so its cleanup thread exits promptly. + release.set() + _join_new_cleanup_threads(before) + + +def test_run_async_surfaces_loop_startup_failure() -> None: + """A failure before the coroutine runs raises instead of hanging.""" + + def failing_run(main: Any) -> None: + # Close the never-awaited coroutine so the test does not leave a + # RuntimeWarning attributed to whatever module GC runs in later. + main.close() + raise OSError("no fds for the event loop") + + with ( + patch("esphome.async_thread.asyncio.run", side_effect=failing_run), + pytest.raises(OSError, match="no fds"), + ): + run_async(lambda: asyncio.sleep(0), timeout=5) + + +def test_run_preserves_result_when_cleanup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + """A loop-cleanup failure after success is logged, not raised.""" + + async def coro() -> str: + return "ok" + + runner: AsyncThreadRunner[str] = AsyncThreadRunner(coro) + + def fake_run(main: Any) -> None: + main.close() + # Emulate _runner delivering the result before cleanup raised. A + # None result must count as delivered too, hence the completed flag. + runner.result = "ok" + runner.completed = True + raise KeyboardInterrupt + + with ( + caplog.at_level("DEBUG", logger="esphome.async_thread"), + patch("esphome.async_thread.asyncio.run", side_effect=fake_run), + ): + runner.run() + + assert runner.event.is_set() + assert runner.exception is None + assert runner.result == "ok" + assert "teardown failed after outcome recorded" in caplog.text + + +def test_run_async_none_result_is_success() -> None: + """A coroutine legitimately returning None is not treated as a failure.""" + + async def coro() -> None: + return None + + assert run_async(coro) is None + + +def test_run_async_on_orphan_skips_none_result() -> None: + """A late None result completes cleanly without invoking on_orphan.""" + orphaned: list[Any] = [] + finished = threading.Event() + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + finished.set() + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + release.set() + assert finished.wait(5) + _join_new_cleanup_threads(before) + assert not orphaned + + +def test_late_failure_without_on_orphan_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """An abandoned thread's real error leaves a visible trace.""" + release = threading.Event() + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + raise ValueError("the real cause") + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01) + + release.set() + _join_new_cleanup_threads(before) + assert "Abandoned async operation failed" in caplog.text + assert "the real cause" in caplog.text + + +def test_run_async_on_orphan_failure_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """An on_orphan callback that raises is logged, not propagated.""" + released = threading.Event() + release = threading.Event() + + def on_orphan(result: str) -> None: + released.set() + raise OSError("close failed") + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "late result" + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=on_orphan) + + release.set() + assert released.wait(5) + _join_new_cleanup_threads(before) + assert "Error releasing orphaned result" in caplog.text + + +def test_run_async_on_orphan_releases_late_result() -> None: + """A result produced after the timeout is handed to on_orphan.""" + orphaned: list[Any] = [] + delivered = threading.Event() + release = threading.Event() + + def on_orphan(result: str) -> None: + orphaned.append(result) + delivered.set() + + async def coro() -> str: + # Block until the test has observed the timeout, so the result is + # guaranteed to arrive late no matter how slowly the runner is + # scheduled. + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "late result" + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=on_orphan) + + release.set() + assert delivered.wait(5) + _join_new_cleanup_threads(before) + assert orphaned == ["late result"] + + +def test_run_async_on_orphan_skips_late_failure() -> None: + """A late failure after the timeout is not handed to on_orphan.""" + orphaned: list[Any] = [] + failed = threading.Event() + release = threading.Event() + + async def coro() -> str: + # Block until the test has observed the timeout, so the failure is + # guaranteed to arrive late. + await asyncio.get_running_loop().run_in_executor(None, release.wait) + failed.set() + raise ValueError("late failure") + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + release.set() + assert failed.wait(5) + _join_new_cleanup_threads(before) + assert not orphaned + + +def test_run_async_detects_missing_outcome() -> None: + """A run that records neither result nor exception raises loudly.""" + + def fake_run(main: Any) -> None: + # Simulate a loop that silently dropped the coroutine. + main.close() + + with ( + patch("esphome.async_thread.asyncio.run", side_effect=fake_run), + pytest.raises(RuntimeError, match="without a result"), + ): + run_async(lambda: asyncio.sleep(0), timeout=5) + + +def test_run_async_raises_distinguishable_timeout() -> None: + """The dispatcher's own expiry is a distinct TimeoutError subclass.""" + release = threading.Event() + + async def coro() -> None: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + + before = _cleanup_threads() + with pytest.raises(AsyncDispatchTimeout): + run_async(coro, timeout=0.01) + release.set() + _join_new_cleanup_threads(before) + + +def test_orphan_watcher_gives_up_on_a_hung_coroutine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The watcher exits after its bound instead of parking forever.""" + from esphome import async_thread + + monkeypatch.setattr(async_thread, "ORPHAN_WAIT_TIMEOUT", 0.01) + release = threading.Event() + orphaned: list[Any] = [] + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "too late" + + before = _cleanup_threads() + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01, on_orphan=orphaned.append) + + _join_new_cleanup_threads(before) + assert not orphaned + release.set() + + +def test_late_real_result_without_handler_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """A genuinely dropped late result leaves the discard trace.""" + release = threading.Event() + + async def coro() -> str: + await asyncio.get_running_loop().run_in_executor(None, release.wait) + return "dropped" + + before = _cleanup_threads() + with caplog.at_level("DEBUG", logger="esphome.async_thread"): + with pytest.raises(TimeoutError): + run_async(coro, timeout=0.01) + + release.set() + _join_new_cleanup_threads(before) + assert "Discarding late result" in caplog.text diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index f0abcc74c6..29e917fe44 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -23,8 +23,8 @@ from esphome.bundle import ( _default_target_dir, _find_used_secret_keys, add_bundle_file, + add_secret_scan_dir, extract_bundle, - is_bundle_path, prepare_bundle_for_compile, read_bundle_manifest, remap_bundle_path, @@ -98,26 +98,6 @@ def _setup_config_dir( return config_dir -# --------------------------------------------------------------------------- -# is_bundle_path -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - ("filename", "expected"), - [ - (f"my_device{BUNDLE_EXTENSION}", True), - (f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True), - ("my_device.yaml", False), - ("my_device.tar.gz", False), - ("my_device.zip", False), - ("", False), - ], -) -def test_is_bundle_path(filename: str, expected: bool) -> None: - assert is_bundle_path(Path(filename)) is expected - - # --------------------------------------------------------------------------- # _default_target_dir # --------------------------------------------------------------------------- @@ -1248,7 +1228,8 @@ def test_discover_files_deeply_nested_include(tmp_path: Path) -> None: def test_discover_files_nested_include_unresolved_substitution( tmp_path: Path, ) -> None: - """!include with substitution vars in path cannot be resolved; skipped gracefully.""" + """!include with substitution vars in path but no candidate files on disk + (the glob's only match is the config itself) is skipped gracefully.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include ${platform}.yaml\n" @@ -1262,6 +1243,62 @@ def test_discover_files_nested_include_unresolved_substitution( assert "test.yaml" in paths +def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None: + """The issue-17650 layout: templated package includes chain through a glob + candidate into a Jinja conditional whose ``../`` branch is bundled.""" + config_dir = _setup_config_dir( + tmp_path, + files={ + "includes/esp-basics.yaml": ( + "packages:\n" + " - !include boards/${board}.yaml\n" + " - !include keys/${system_name}.yaml\n" + ), + "includes/boards/wemos-d1-mini.yaml": ( + 'packages:\n - !include ${ "NO BT.yaml" if bt else "../empty.yaml" }\n' + ), + "includes/keys/device-a.yaml": "api:\n", + "includes/keys/device-b.yaml": "api:\n", + "includes/empty.yaml": "{}\n", + }, + ) + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\npackages:\n - !include includes/esp-basics.yaml\n" + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert "includes/esp-basics.yaml" in paths + assert "includes/boards/wemos-d1-mini.yaml" in paths + assert "includes/keys/device-a.yaml" in paths + assert "includes/keys/device-b.yaml" in paths + assert "includes/empty.yaml" in paths + + +def test_discover_files_candidate_outside_config_dir_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A candidate branch resolving above the config dir is not bundled.""" + config_dir = _setup_config_dir(tmp_path) + (tmp_path / "outside.yaml").write_text("api:\n") + (config_dir / "test.yaml").write_text( + "esphome:\n name: test\n" + 'wifi: !include ${ "a.yaml" if x else "../outside.yaml" }\n' + ) + + creator = ConfigBundleCreator({}) + files = creator.discover_files() + + paths = [f.path for f in files] + assert not any("outside" in p for p in paths) + assert any( + "outside config directory" in r.message and "outside.yaml" in r.message + for r in caplog.records + ) + + def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -1594,6 +1631,44 @@ def test_create_bundle_filters_secrets_quoted(tmp_path: Path) -> None: assert "unused" not in secrets_data +def test_create_bundle_scans_remote_package_files_for_secrets(tmp_path: Path) -> None: + """Secrets referenced only by git-fetched package files must be shipped + in the filtered secrets.yaml (regression test for issue 18023).""" + config_dir = _setup_config_dir(tmp_path) + + secrets = config_dir / "secrets.yaml" + secrets.write_text("ota_password: hunter2\nunused: should_not_appear\n") + + # Simulate a git-fetched package checkout referencing a secret + repo_dir = config_dir / ".esphome" / "packages" / "6bcd6aa8" + package_dir = repo_dir / "packages" + package_dir.mkdir(parents=True) + (package_dir / "base.yml").write_text( + "ota:\n - platform: esphome\n password: !secret ota_password\n" + ) + # References inside hidden directories such as .git must not be scanned + hidden_dir = repo_dir / ".git" + hidden_dir.mkdir() + (hidden_dir / "leak.yaml").write_text("password: !secret unused\n") + add_secret_scan_dir(repo_dir) + + creator = ConfigBundleCreator({}) + result = creator.create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is True + + buf = io.BytesIO(result.data) + with tarfile.open(fileobj=buf, mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + names = tar.getnames() + + assert "ota_password" in secrets_data + assert "hunter2" in secrets_data + assert "unused" not in secrets_data + # The package checkout itself must not be bundled + assert not any("base.yml" in name for name in names) + + def test_create_bundle_no_secrets(tmp_path: Path) -> None: _setup_config_dir(tmp_path) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index e12107152b..b3c2170c3f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,15 +2,20 @@ from __future__ import annotations +from ipaddress import IPv4Address, IPv4Network import json import os from pathlib import Path +from typing import Any from unittest.mock import patch +from uuid import UUID import pytest +from esphome import const, yaml_util from esphome.__main__ import run_esphome from esphome.compiled_config import ( + _LAMBDA_KEY, compiled_config_path, load_compiled_config, save_compiled_config, @@ -20,32 +25,30 @@ from esphome.const import ( CONF_ESPHOME, CONF_NAME, KEY_CORE, + KEY_ESP32, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, + Toolchain, ) -from esphome.core import CORE +from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.util import OrderedDict -_VALIDATED_CONFIG_YAML = """\ -esphome: - name: lite_test - friendly_name: Lite Test Device -esp32: - board: nodemcu-32s -logger: - baud_rate: 115200 -api: - port: 6053 - encryption: - key: 6dGhpcyBpcyBhIHRlc3Q= -ota: - - platform: esphome - port: 3232 - password: secret -wifi: - ssid: ssid - use_address: 192.168.1.42 -""" +_VALIDATED_CONFIG = { + "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"}, + "esp32": {"board": "nodemcu-32s"}, + "logger": {"baud_rate": 115200}, + "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}}, + "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}], + "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"}, +} + + +def _cache_body(config: dict | None = None) -> str: + """Render the JSON envelope the production save writes.""" + return json.dumps( + {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG} + ) def _write_storage( @@ -74,13 +77,13 @@ def _write_storage( "framework": "arduino", "core_platform": core_platform, } - storage_path.write_text(json.dumps(data)) + storage_path.write_text(json.dumps(data), encoding="utf-8") -def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: +def _write_cache(cache_path: Path, body: str | None = None) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body) + cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8") return cache_path @@ -94,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture -def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + StorageJSON + cache, all consistent and fresh.""" +def primed_storage(tmp_path: Path) -> Path: + """YAML + StorageJSON sidecar, no cache yet.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") - _set_cache_mtime(cache, yaml_path, offset=5) - + _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json") return yaml_path +@pytest.fixture +def fresh_cache_files(primed_storage: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") + _set_cache_mtime(cache, primed_storage, offset=5) + return primed_storage + + def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" path = compiled_config_path("device.yaml") - assert path.name == "device.yaml.validated.yaml" + assert path.name == "device.yaml.validated.json" assert path.parent.name == "storage" @@ -124,28 +131,27 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" + # The fast path loads plain scalars; no per-node source ranges exist. + assert type(config[CONF_ESPHOME][CONF_NAME]) is str + # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" assert CORE.build_path == Path("/build/lite_test") assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32" assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino" # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32]. - from esphome.components.esp32.const import KEY_ESP32 - assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32" def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: """ESP32 variants survive the cache fast path so esptool gets the right --chip.""" - from esphome.components.esp32.const import KEY_ESP32 - yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -156,8 +162,6 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( tmp_path: Path, ) -> None: """Non-esp32 targets shouldn't fabricate an esp32 data block.""" - from esphome.components.esp32.const import KEY_ESP32 - yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path @@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( esp_platform="ESP8266", core_platform="esp8266", ) - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path = storage_dir / "lite_test.yaml.validated.json" sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": @@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: elif scenario == "corrupt_cache": _write_storage(sidecar_path) _set_cache_mtime( - _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5 ) elif scenario == "missing_sidecar": # Cache fresh + parseable, but no StorageJSON → can't populate CORE. @@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: assert load_compiled_config(yaml_path) is None +@pytest.mark.parametrize( + "body", + [ + pytest.param( + json.dumps( + {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}} + ), + id="wrong_version", + ), + pytest.param( + json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}), + id="missing_version", + ), + pytest.param( + json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}), + id="other_esphome_version", + ), + pytest.param( + json.dumps({"v": 1, "config": {"esphome": {}}}), + id="missing_esphome_version", + ), + pytest.param( + json.dumps( + { + "v": 1, + "esphome": const.__version__, + "config": ["not", "a", "dict"], + } + ), + id="non_dict_config", + ), + pytest.param( + json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config" + ), + pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"), + ], +) +def test_load_compiled_config_rejects_bad_envelope( + primed_storage: Path, body: str +) -> None: + """A foreign or future cache shape falls back instead of half-loading.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body) + _set_cache_mtime(cache, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None: + """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + legacy = _write_cache( + storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n" + ) + _set_cache_mtime(legacy, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None: + """A successful save leaves only the JSON cache behind.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert compiled_config_path("lite_test.yaml").is_file() + assert not legacy.exists() + + +def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None: + """The secret-bearing legacy cache goes away regardless of write outcome.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert not legacy.exists() + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_save_warns_when_legacy_cache_unremovable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A secret-bearing legacy file that won't unlink warns; the write proceeds.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.mkdir() # unlink() on a directory raises OSError + + with caplog.at_level("WARNING", logger="esphome.compiled_config"): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert "legacy validated-config cache" in caplog.text + assert compiled_config_path("lite_test.yaml").is_file() + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, @@ -220,7 +326,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh( with ( caplog.at_level("INFO", logger="esphome.__main__"), - patch("esphome.__main__.read_config") as mock_read, + patch("esphome.config.read_config") as mock_read, patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}), ): assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0 @@ -242,7 +348,7 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( yaml_path.write_text("esphome:\n name: lite_test\n") with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( ) -> None: """Without a StorageJSON sidecar (no compile has run), the fallback skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) YAML would be inert and + so writing the rendered (secret-resolved) config would be inert and leak secrets to disk for nothing.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -266,7 +372,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( with ( patch( - "esphome.__main__.read_config", + "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, ), patch("esphome.compiled_config.save_compiled_config") as mock_save, @@ -293,13 +399,13 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=-60) # stale fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} with ( - patch("esphome.__main__.read_config", return_value=fresh_config), + patch("esphome.config.read_config", return_value=fresh_config), patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, @@ -322,7 +428,7 @@ def test_run_esphome_upload_with_substitution_does_not_refresh_cache( """`-s` substitutions skip the cache on both read and write -- saving here would clobber the cache with a substitution-specific config.""" with ( - patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.config.read_config", return_value={"esphome": {}}), patch("esphome.compiled_config.save_compiled_config") as mock_save, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", @@ -341,7 +447,7 @@ def test_run_esphome_compile_does_not_refresh_cache_via_fallback( upload/logs fallback path -- the fallback save would skip the storage_should_clean check.""" with ( - patch("esphome.__main__.read_config", return_value={"esphome": {}}), + patch("esphome.config.read_config", return_value={"esphome": {}}), patch("esphome.compiled_config.save_compiled_config") as mock_save, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", @@ -360,7 +466,7 @@ def test_run_esphome_upload_with_substitution_skips_cache( against the prior substitution set, so reusing it would silently ignore the override.""" with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {"upload": lambda args, config: 0}, @@ -374,7 +480,7 @@ def test_run_esphome_upload_with_substitution_skips_cache( def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None: """The compile subcommand always re-validates -- it's what writes the cache.""" with ( - patch("esphome.__main__.read_config", return_value=None) as mock_read, + patch("esphome.config.read_config", return_value=None) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {"compile": lambda args, config: 0}, @@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: - """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + """`save_compiled_config` writes the JSON envelope next to the sidecar.""" CORE.config_path = tmp_path / "lite_test.yaml" save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) cache_path = compiled_config_path("lite_test.yaml") assert cache_path.is_file() - body = cache_path.read_text() - assert "name: lite_test" in body - assert "logger:" in body + envelope = json.loads(cache_path.read_text()) + assert envelope["v"] == 1 + assert envelope["esphome"] == const.__version__ + assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}} -def test_save_compiled_config_swallows_dump_errors( +def test_save_compiled_config_swallows_write_errors( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Failures during the dump are non-fatal -- a bad cache just means + """Failures during the write are non-fatal -- a bad cache just means the next fast path falls back to read_config().""" CORE.config_path = tmp_path / "lite_test.yaml" - with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): save_compiled_config({"esphome": {"name": "lite_test"}}) assert not compiled_config_path("lite_test.yaml").exists() +def test_save_stringifies_unknown_values(tmp_path: Path) -> None: + """A type with no dedicated encoding stores its string form.""" + + class Weird: + def __str__(self) -> str: + return "weird-str" + + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}}) + envelope = json.loads(compiled_config_path("lite_test.yaml").read_text()) + assert envelope["config"]["esphome"]["weird"] == "weird-str" + + +def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None: + """A non-basic dict key aborts the write; the fast path falls back.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {("a", "b"): "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def _normalize(value: Any) -> Any: + """Make Lambda comparable; everything else compares by value already.""" + if isinstance(value, Lambda): + return ("__lambda__", value.value) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _round_trip_config() -> OrderedDict: + """A post-validation shaped config exercising every representer type.""" + return OrderedDict( + { + "esphome": OrderedDict( + { + "name": "lite_test", + "build_path": Path("/build/lite_test"), + "on_boot": [ + OrderedDict( + { + "trigger_id": ID("trigger_1", type="Trigger"), + "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}], + } + ) + ], + } + ), + "wifi": OrderedDict( + { + "id": ID("wifi_id", type="WiFiComponent"), + "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000), + "use_address": IPv4Address("192.168.1.42"), + "subnet": IPv4Network("192.168.1.0/24"), + "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01), + } + ), + "misc": OrderedDict( + { + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "toolchain": Toolchain.PLATFORMIO, + "hex": HexInt(0x1234), + "levels": (1, 2.5, True, None), + "empty": {}, + } + ), + } + ) + + +def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None: + """The JSON cache loads the same tree the YAML cache used to.""" + config = _round_trip_config() + save_compiled_config(config) + from_json = load_compiled_config(primed_storage) + assert from_json is not None + + yaml_cache = primed_storage.parent / "dumped.yaml" + yaml_cache.write_text(yaml_util.dump(config, show_secrets=True)) + from_yaml = yaml_util.load_yaml( + yaml_cache, clear_secrets=False, track_document_range=False + ) + + assert _normalize(from_json) == _normalize(from_yaml) + + +def test_lambda_sentinel_round_trips(primed_storage: Path) -> None: + """A !lambda body comes back as a Lambda with the same source.""" + body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";' + save_compiled_config( + { + "esphome": {"name": "lite_test"}, + "script": [{"then": [{"lambda": Lambda(body)}]}], + } + ) + + config = load_compiled_config(primed_storage) + assert config is not None + revived = config["script"][0]["then"][0]["lambda"] + assert isinstance(revived, Lambda) + assert revived.value == body + + +def test_object_hook_requires_exact_shape(primed_storage: Path) -> None: + """Only the exact one-key string-valued sentinel revives a Lambda.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + config = { + "esphome": {"name": "lite_test"}, + "extra_key": {_LAMBDA_KEY: "x", "y": 1}, + "non_str": {_LAMBDA_KEY: 5}, + } + cache = _write_cache( + storage_dir / "lite_test.yaml.validated.json", _cache_body(config) + ) + _set_cache_mtime(cache, primed_storage, offset=5) + + loaded = load_compiled_config(primed_storage) + assert loaded is not None + assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1} + assert loaded["non_str"] == {_LAMBDA_KEY: 5} + + +def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: + """Non-str basic keys stringify; validated configs only use string keys.""" + save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}}) + + config = load_compiled_config(primed_storage) + assert config is not None + assert config["table"] == {"1": "a", "2": "b"} + + def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: """A wizard-only sidecar (no compile -- no core_platform / target_platform) can't drive upload/logs, so the fast path falls back.""" @@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' '"framework": null, "core_platform": null}' ) - cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) assert load_compiled_config(yaml_path) is None diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 1c850e3759..88913c0f23 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -3,7 +3,13 @@ from collections.abc import Callable from unittest.mock import patch -from esphome.config_helpers import filter_source_files_from_platform, get_logger_level +import pytest + +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, + get_logger_level, +) from esphome.const import ( CONF_LEVEL, CONF_LOGGER, @@ -133,3 +139,12 @@ def test_get_logger_level() -> None: mock_config = {CONF_LOGGER: {}} with patch("esphome.config_helpers.CORE.config", mock_config): assert get_logger_level() == "DEBUG" + + +def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: + assert frameworks_for_platforms(["esp32"]) == { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + } + with pytest.raises(ValueError, match="unknown platform"): + frameworks_for_platforms(["esp32", "not_a_platform"]) diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 1da3d5593a..7627ef9273 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,6 +1,8 @@ import json +import logging from pathlib import Path import string +from unittest.mock import patch from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text @@ -930,7 +932,7 @@ def test_string_no_slash__slash_replaced_with_warning( actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text - assert "will become an error in ESPHome 2026.7.0" in caplog.text + assert "will become an error in ESPHome 2027.7.0" in caplog.text def test_string_no_slash__long_string_allowed() -> None: @@ -2427,6 +2429,58 @@ def test_one_of_string_and_space() -> None: assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" +def test_one_of_string_and_underscore() -> None: + assert cv.one_of("a-b", string=True, underscore="-")("a_b") == "a-b" + assert cv.one_of("a-b", string=True, underscore="-")("a-b") == "a-b" + + +def test_one_of_string_lower_space_and_underscore() -> None: + validator = cv.one_of("output-mode", lower=True, space="-", underscore="-") + assert validator("output_mode") == "output-mode" + assert validator("OUTPUT_MODE") == "output-mode" + assert validator("output mode") == "output-mode" + assert validator("output-mode") == "output-mode" + + +def test_one_of_string_underscore_unknown() -> None: + with pytest.raises(Invalid): + cv.one_of("a-b", string=True, underscore="-")("c_d") + + +def test_one_of_string_underscore_default_unchanged() -> None: + with pytest.raises(Invalid): + cv.one_of("a-b", string=True)("a_b") + + +def test_one_of_string_and_hyphen() -> None: + assert cv.one_of("a_b", string=True, hyphen="_")("a-b") == "a_b" + assert cv.one_of("a_b", string=True, hyphen="_")("a_b") == "a_b" + + +def test_one_of_string_lower_space_and_hyphen() -> None: + validator = cv.one_of("output_mode", lower=True, space="_", hyphen="_") + assert validator("output-mode") == "output_mode" + assert validator("OUTPUT-MODE") == "output_mode" + assert validator("output mode") == "output_mode" + assert validator("output_mode") == "output_mode" + + +def test_one_of_string_hyphen_unknown() -> None: + with pytest.raises(Invalid): + cv.one_of("a_b", string=True, hyphen="_")("c-d") + + +def test_one_of_string_hyphen_default_unchanged() -> None: + with pytest.raises(Invalid): + cv.one_of("a_b", string=True)("a-b") + + +def test_one_of_string_underscore_hyphen_swap_no_cascade() -> None: + validator = cv.one_of("a-b", "a_b", string=True, underscore="-", hyphen="_") + assert validator("a_b") == "a-b" + assert validator("a-b") == "a_b" + + def test_one_of_int() -> None: assert cv.one_of(1, 2, int=True)("2") == 2 @@ -2465,6 +2519,20 @@ def test_enum_valid() -> None: assert result.enum_value == 10 +def test_enum_valid_with_underscore() -> None: + mapping = {"a-b": 1} + result = cv.enum(mapping, string=True, underscore="-")("a_b") + assert result == "a-b" + assert result.enum_value == 1 + + +def test_enum_valid_with_hyphen() -> None: + mapping = {"a_b": 1} + result = cv.enum(mapping, string=True, hyphen="_")("a-b") + assert result == "a_b" + assert result.enum_value == 1 + + # --------------------------------------------------------------------------- # lambda_ / returning_lambda # --------------------------------------------------------------------------- @@ -2859,11 +2927,46 @@ def test_require_esphome_version_ok() -> None: assert cv.require_esphome_version(1, 0, 0)("test") == "test" +def test_require_esphome_version_accepts_version_object() -> None: + """The Version form matches require_framework_version's style.""" + assert cv.require_esphome_version(cv.Version(1, 0, 0))("test") == "test" + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(cv.Version(9999, 0, 0))("test") + + +def test_require_esphome_version_partial_ints_fail_at_call_site() -> None: + """Missing ints raise immediately instead of a TypeError inside the validator.""" + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026, 8) + with pytest.raises(ValueError, match="needs a Version or"): + cv.require_esphome_version(2026) + + def test_require_esphome_version_too_old() -> None: with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): cv.require_esphome_version(9999, 0, 0)("test") +@pytest.mark.parametrize("current", ["2026.8.0", "2026.8.0b1", "2026.8.0-dev20260801"]) +def test_require_esphome_version_prerelease_of_required_passes(current: str) -> None: + """A dev or beta build of the required version satisfies it. + + Pins the behavior of the old tuple comparison that dropped the + suffix, now expressed through Version ordering where the extra field + only breaks ties upward. + """ + with patch.object(cv, "ESPHOME_VERSION", current): + assert cv.require_esphome_version(2026, 8, 0)("test") == "test" + + +def test_require_esphome_version_older_prerelease_fails() -> None: + with ( + patch.object(cv, "ESPHOME_VERSION", "2026.7.0-dev20260701"), + pytest.raises(Invalid, match="at least ESPHome version 2026.8.0"), + ): + cv.require_esphome_version(2026, 8, 0)("test") + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- @@ -2915,6 +3018,62 @@ def test_rename_key_absent() -> None: assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} +def test_rename_key_no_removed_in_is_silent( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + assert not caplog.records + + +def test_rename_key_removed_in_renames_and_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5}) + assert result == {"new": 5} + assert "'old' is deprecated, use 'new'. Will be removed in 2026.8.0" in caplog.text + + +def test_rename_key_removed_in_absent_key_no_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key("old", "new", removed_in="2026.8.0")({"other": 5}) + assert result == {"other": 5} + assert not caplog.records + + +def test_rename_key_removed_in_with_component_prefixes_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + result = cv.rename_key( + "old", "new", removed_in="2026.8.0", component="my_component" + )({"old": 5}) + assert result == {"new": 5} + assert ( + "[my_component] 'old' is deprecated, use 'new'. Will be removed in 2026.8.0" + in caplog.text + ) + + +def test_rename_key_both_keys_rejected() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one of"): + cv.rename_key("old", "new")({"old": 5, "new": 6}) + + +def test_rename_key_both_keys_rejected_with_removed_in( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="Cannot specify more than one of"), + ): + cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5, "new": 6}) + assert not caplog.records + + def test_file__existing_relative_path(setup_core: Path) -> None: (setup_core / "partitions.csv").write_text("csv\n") diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0cb0c1f62d..7f00d00ef7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,5 +1,7 @@ import os from pathlib import Path +import subprocess +import sys from unittest.mock import patch from hypothesis import given @@ -213,6 +215,31 @@ class TestLambda: assert str(target) is value.value + def test_init__expression_initializer(self): + from esphome.cpp_generator import RawExpression + + target = core.Lambda(RawExpression("foo()")) + + assert target.value == "foo();" + + def test_init__other_initializer(self): + target = core.Lambda(123) + + assert target.value == 123 + + def test_init_from_str_does_not_import_codegen(self): + """The validated-config cache revives Lambdas on the upload fast path.""" + # sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it. + check = ( + "import sys; from esphome.core import Lambda; " + "Lambda('return 1;'); " + "sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", check], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + def test_parts(self): target = core.Lambda(SAMPLE_LAMBDA.strip()) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index e389b56ada..1c0e0d0a93 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest from esphome import const, cpp_helpers as ch +from esphome.core import CoroPriority, coroutine_with_priority from esphome.cpp_helpers import ComponentSourcePool, register_component_source @@ -167,3 +168,53 @@ def test_register_component_source_overflow_suppressed_in_testing_mode( idx = register_component_source("overflow_component") assert idx == 0 assert "Too many unique component source names" not in caplog.text + + +def _define_value(name: str) -> str | None: + for define in ch.CORE.defines: + if define.name == name: + # Values are codegen expressions (IntLiteral); compare rendered. + return str(define.value) + return None + + +def test_slot_counter_emits_requested_count() -> None: + """Each request bumps the count; the self-scheduled FINAL job emits it.""" + request = ch.slot_counter("TEST_SLOT_COUNT") + request() + request() + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT") == "2" + + +def test_slot_counter_without_requests_emits_nothing() -> None: + """No requests, no job, no define — the guarded storage compiles out.""" + ch.slot_counter("TEST_SLOT_COUNT_UNUSED") + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_UNUSED") is None + + +def test_slot_counter_request_from_final_job_still_emits() -> None: + """The FIRST request for a define may come from a FINAL job: its emit job + is scheduled mid-drain and flush_tasks() loops until the heap is empty. + Later requests do not get this guarantee — see the companion test.""" + request = ch.slot_counter("TEST_SLOT_COUNT_LATE") + + @coroutine_with_priority(CoroPriority.FINAL) + async def late_requester() -> None: + request() + + ch.CORE.add_job(late_requester) + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_LATE") == "1" + + +def test_slot_counter_request_after_emit_raises() -> None: + """The boundary of FINAL-time requests: once the define was emitted, a + further request would silently undersize the storage, so it fails loudly.""" + request = ch.slot_counter("TEST_SLOT_COUNT_TOO_LATE") + request() + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1" + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"): + request() diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 879d98c0a7..f9e048f6f4 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -155,6 +155,62 @@ def test_generate_cmakelists_txt_basic(tmp_component): assert "main.c" in content +def test_generate_cmakelists_txt_external_source_uses_absolute_paths( + tmp_component, tmp_path +): + # A local library's sources live outside the component dir (source_path), + # so SRCS and INCLUDE_DIRS must be emitted as absolute paths into it. + source = tmp_path / "user_lib" + (source / "src").mkdir(parents=True) + (source / "include").mkdir() + (source / "src" / "thing.cpp").write_text("int t;") + tmp_component.source_path = source + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + + abs_src = str((source / "src" / "thing.cpp").resolve()).replace("\\", "/") + abs_inc = str((source / "include").resolve()).replace("\\", "/") + assert abs_src in content + assert abs_inc in content + # Nothing was copied into the component dir. + assert not (tmp_component.path / "src").exists() + + +def test_generate_cmakelists_txt_external_source_absolutises_link_dirs( + tmp_component, tmp_path +): + # A local library's relative -L path must be made absolute against its own + # directory so it resolves from the component cache dir. + source = tmp_path / "user_lib" + (source / "src").mkdir(parents=True) + (source / "src" / "thing.cpp").write_text("int t;") + (source / "libs").mkdir() + tmp_component.source_path = source + tmp_component.data = {"build": {"flags": ["-Llibs"]}} + + content = generate_cmakelists_txt(tmp_component) + + abs_lib = str((source / "libs").resolve()).replace("\\", "/") + assert "target_link_directories" in content + assert abs_lib in content + + +def test_generate_cmakelists_txt_external_source_root_srcdir(tmp_component, tmp_path): + # An external source with files at its root (no src/ or include/ dir): + # the src-dir search falls through to "." and the missing include dirs are + # filtered out. + source = tmp_path / "flat_lib" + source.mkdir() + (source / "thing.cpp").write_text("int t;") + tmp_component.source_path = source + tmp_component.data = {} + + content = generate_cmakelists_txt(tmp_component) + + assert str((source / "thing.cpp").resolve()).replace("\\", "/") in content + + def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path): src_dir = tmp_component.path / "src" src_dir.mkdir() @@ -462,70 +518,66 @@ empty= def test_node_key_git_with_ref(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "name", None, "https://github.com/foo/bar.git#v1.2.3" ) assert key == "foo/bar" - assert is_git is True + assert kind == "git" assert locator == ("https://github.com/foo/bar.git", "v1.2.3") def test_node_key_git_branch_ref(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "name", None, "https://github.com/foo/bar.git#some-branch" ) - assert (key, is_git, locator[1]) == ("foo/bar", True, "some-branch") + assert (key, kind, locator[1]) == ("foo/bar", "git", "some-branch") def test_node_key_git_no_ref(): - _key, is_git, locator = _node_key("name", None, "https://github.com/foo/bar.git") - assert is_git is True + _key, kind, locator = _node_key("name", None, "https://github.com/foo/bar.git") + assert kind == "git" assert locator == ("https://github.com/foo/bar.git", None) def test_node_key_url_in_name_is_git(): # add_library("https://github.com/x/y", None): PlatformIO accepted a bare # git URL as the library name, so the converter must too. - key, is_git, locator = _node_key( - "https://github.com/pstolarz/OneWireNg", None, None - ) + key, kind, locator = _node_key("https://github.com/pstolarz/OneWireNg", None, None) assert key == "pstolarz/OneWireNg" - assert is_git is True + assert kind == "git" assert locator == ("https://github.com/pstolarz/OneWireNg", None) def test_node_key_url_in_name_with_ref(): - key, is_git, locator = _node_key( - "https://github.com/foo/bar.git#v1.2.3", None, None - ) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("https://github.com/foo/bar.git#v1.2.3", None, None) + assert (key, kind, locator) == ( "foo/bar", - True, + "git", ("https://github.com/foo/bar.git", "v1.2.3"), ) def test_node_key_url_in_name_git_plus_prefix(): - key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("git+https://github.com/foo/bar", None, None) + assert (key, kind, locator) == ( "foo/bar", - True, + "git", ("https://github.com/foo/bar", None), ) def test_node_key_git_plus_prefix_in_repository(): - _key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar") - assert (is_git, locator) == (True, ("https://github.com/foo/bar", None)) + _key, kind, locator = _node_key("name", None, "git+https://github.com/foo/bar") + assert (kind, locator) == ("git", ("https://github.com/foo/bar", None)) def test_node_key_custom_name_equals_url_is_git(): - key, is_git, locator = _node_key( + key, kind, locator = _node_key( "OneWireNg=https://github.com/pstolarz/OneWireNg", None, None ) - assert (key, is_git, locator) == ( + assert (key, kind, locator) == ( "pstolarz/OneWireNg", - True, + "git", ("https://github.com/pstolarz/OneWireNg", None), ) @@ -533,14 +585,70 @@ def test_node_key_custom_name_equals_url_is_git(): def test_node_key_url_in_name_with_query_containing_equals(): # A bare URL whose query string contains ``=`` must not be split by the # CustomName=URL handling. - key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None) - assert (key, is_git, locator) == ( + key, kind, locator = _node_key("https://host/x/y.git?ref=main", None, None) + assert (key, kind, locator) == ( "x/y", - True, + "git", ("https://host/x/y.git?ref=main", None), ) +def test_node_key_file_url_in_repository_is_local(): + # A plain file:// entry (PlatformIO's spelling for a local library folder) + # resolves as a local directory, keeping the custom name as the key. The + # path is the OS-native form of the URL (backslashes on Windows). + key, kind, (path, ref) = _node_key( + "TeslaBLE", None, "file:///config/esphome/lib_dev" + ) + assert (key, kind, ref) == ("TeslaBLE", "local", None) + assert Path(path) == Path("/config/esphome/lib_dev") + + +def test_node_key_bare_file_url_is_local_named_for_dir(): + # Without a custom name the directory's own name becomes the key. + key, kind, (path, ref) = _node_key(None, None, "file:///opt/mylib") + assert (key, kind, ref) == ("mylib", "local", None) + assert Path(path) == Path("/opt/mylib") + + +def test_node_key_custom_name_equals_file_url_is_local(): + key, kind, (path, ref) = _node_key("Foo=file:///opt/mylib", None, None) + assert (key, kind, ref) == ("Foo", "local", None) + assert Path(path) == Path("/opt/mylib") + + +def test_node_key_file_url_localhost_host_is_local(): + # A localhost host is ignored; only the path identifies the directory. + key, kind, (path, ref) = _node_key(None, None, "file://localhost/opt/mylib") + assert (key, kind, ref) == ("mylib", "local", None) + assert Path(path) == Path("/opt/mylib") + + +@pytest.mark.parametrize( + "url", ["file://server/share/lib", "file://lib_dev", "file://../mylib"] +) +def test_node_key_file_url_with_host_rejected(url: str) -> None: + # A real host, or a relative path whose first segment parses as the host, + # is rejected rather than silently resolved to the wrong directory. + with pytest.raises(RuntimeError, match="Unsupported host in file://"): + _node_key(None, None, url) + + +@pytest.mark.parametrize("url", ["file:lib_dev", "file:./lib", "file:///"]) +def test_node_key_file_url_must_be_absolute(url: str) -> None: + # A relative path (no host, e.g. file:lib_dev) or a bare root (file:///) + # is rejected rather than resolved against the cwd or yielding an empty name. + with pytest.raises(RuntimeError, match="must be an absolute"): + _node_key(None, None, url) + + +def test_node_key_git_plus_file_url_stays_git(): + # git+file:// is an explicit local git repo, not a plain directory. + _key, kind, locator = _node_key("X", None, "git+file:///srv/foo.git") + assert kind == "git" + assert locator == ("file:///srv/foo.git", None) + + @pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"]) def test_node_key_malformed_url_in_name_raises(name: str) -> None: # A name that was clearly meant to be a URL but does not parse must fail @@ -550,25 +658,25 @@ def test_node_key_malformed_url_in_name_raises(name: str) -> None: def test_node_key_name_with_equals_but_no_url_is_registry(): - key, is_git, locator = _node_key("FOO=BAR", "1.0", None) - assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR")) + key, kind, locator = _node_key("FOO=BAR", "1.0", None) + assert (key, kind, locator) == ("FOO=BAR", "registry", (None, "FOO=BAR")) def test_node_key_version_url_still_ignored_when_name_plain(): # A version that is a URL is handled by the dependency walk, not here; # a plain name must stay a registry spec regardless of version shape. - key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None) - assert (key, is_git) == ("bar", False) + key, kind, _locator = _node_key("bar", "https://github.com/foo/bar", None) + assert (key, kind) == ("bar", "registry") def test_node_key_registry_owner_name(): - key, is_git, locator = _node_key("foo/bar", "^1.0.0", None) - assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar")) + key, kind, locator = _node_key("foo/bar", "^1.0.0", None) + assert (key, kind, locator) == ("foo/bar", "registry", ("foo", "bar")) def test_node_key_registry_bare_name(): - key, is_git, locator = _node_key("bar", "1.0", None) - assert (key, is_git, locator) == ("bar", False, (None, "bar")) + key, kind, locator = _node_key("bar", "1.0", None) + assert (key, kind, locator) == ("bar", "registry", (None, "bar")) def test_normalize_dependencies_none(): diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index cbc9fe2cda..d8e7738569 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -19,7 +19,10 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + ESPHOME_STAMP_FILE, + STAMP_SCHEMA_VERSION, _ccache_env, + _check_esphome_idf_framework_install, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, @@ -29,9 +32,11 @@ from esphome.espidf.framework import ( _get_python_env_path, _get_python_version, _parse_git_source, - _patch_tools_json_demote_openocd, + _patch_tools_json_demote_unused_tools, _patch_tools_json_for_linux_arm64, _prefetch_idf_tool_archives, + _read_stamp, + _stamp_covers, _windows_long_paths_enabled, _write_idf_version_txt, _write_stamp, @@ -173,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] assert not any(c[1] == "fetch" for c in calls) assert not any(c[1] == "reset" for c in calls) + # The clone must retry transient network failures and clean up a + # partial destination between attempts + clone_kwargs = run_git_command_mock.call_args_list[0].kwargs + assert clone_kwargs["network"] is True + assert clone_kwargs["retry_cleanup"] == framework_path def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: @@ -200,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: ] assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + # Clone and fetch talk to the network and must carry the retry flag; + # the local reset must not + kwargs = [c.kwargs for c in run_git_command_mock.call_args_list] + assert kwargs[0]["network"] is True + assert kwargs[0]["retry_cleanup"] == framework_path + assert kwargs[1]["network"] is True + assert "network" not in kwargs[2] def test_clone_idf_with_submodules_raises_when_tree_missing( @@ -367,7 +384,7 @@ def espidf_mocks(setup_core: Path): # extracted-marker touch writes into. _get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True) with ( - patch("esphome.espidf.framework.rmdir"), + patch("esphome.espidf.framework.rmdir") as rmdir_mock, patch( "esphome.espidf.framework.download_from_mirrors", side_effect=_fake_download_from_mirrors, @@ -381,10 +398,11 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), - patch("esphome.espidf.framework._patch_tools_json_demote_openocd"), + patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"), patch("esphome.espidf.framework._prefetch_idf_tool_archives"), patch("esphome.espidf.framework._write_stamp"), patch("esphome.espidf.framework._check_stamp", return_value=True), + patch("esphome.espidf.framework._stamp_covers", return_value=True), patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION), patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"), patch("esphome.espidf.framework.get_system_python_path", return_value="python"), @@ -396,6 +414,7 @@ def espidf_mocks(setup_core: Path): run_ok=run_ok, tool_paths=tool_paths, clone=clone, + rmdir=rmdir_mock, ) @@ -410,6 +429,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None: espidf_mocks.extract.assert_called_once() espidf_mocks.venv.assert_called_once() espidf_mocks.clone.assert_not_called() + # the tool download cache (/dist) is pruned after install + espidf_mocks.rmdir.assert_any_call( + get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache" + ) + + +def test_check_esp_idf_install_dist_prune_failure_ignored( + espidf_mocks: SimpleNamespace, +) -> None: + """A failure to prune the tool download cache must not fail the install.""" + tools_dist = get_idf_tools_path() / "dist" + + def rmdir_side_effect(directory: Path, msg: str | None = None) -> None: + if directory == tools_dist: + raise RuntimeError("cannot remove dist") + + espidf_mocks.rmdir.side_effect = rmdir_side_effect + + # install still succeeds despite the failed prune + framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True) + assert framework_path == _get_framework_path(_IDF_VERSION) def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None: @@ -492,13 +532,17 @@ def _mark_installed() -> None: def test_check_esp_idf_install_stamp_mismatch_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A stamp mismatch reinstalls tools (marker present, so no re-extract).""" + """A stamp mismatch reinstalls tools (marker present, so no re-extract). + + The python env is left alone: it depends on the framework version and + features, not on which toolchains are installed. + """ _mark_installed() - with patch("esphome.espidf.framework._check_stamp", return_value=False): + with patch("esphome.espidf.framework._stamp_covers", return_value=False): check_esp_idf_install(_IDF_VERSION) espidf_mocks.extract.assert_not_called() # marker present -> no re-extract - espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt + espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_check_command_failure_reinstalls( @@ -511,7 +555,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls( check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() - espidf_mocks.venv.assert_called_once() + espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept def test_check_esp_idf_install_unknown_python_version_reinstalls( @@ -531,8 +575,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( ) -> None: """Framework stamp matches but the python-env stamp does not -> venv rebuilt.""" - # _check_stamp passes for the framework (no python_version key) and fails - # for the python env (carries python_version), so only the venv rebuilds. + # _check_stamp only guards the python env now (the framework uses + # _stamp_covers, patched True by the fixture); failing it rebuilds the venv. def stamp_ok(_stamp_file, info: dict) -> bool: return "python_version" not in info @@ -544,6 +588,146 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv( espidf_mocks.venv.assert_called_once() +def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict: + return { + "schema_version": STAMP_SCHEMA_VERSION, + "targets": targets, + "tools": tools or ["required"], + } + + +@pytest.mark.parametrize( + ("stored", "targets", "expected"), + [ + # a stored "all" covers any target + (_requested_stamp(["all"]), ["esp32"], True), + # exact match and superset both cover + (_requested_stamp(["esp32"]), ["esp32"], True), + (_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True), + # a new target is not covered + (_requested_stamp(["esp32"]), ["esp32c3"], False), + # tools and schema_version must match exactly + (_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False), + (_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False), + # an unknown extra field participates in invalidation by default + (_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False), + # missing/corrupt stamps never cover + (None, ["esp32"], False), + ( + {"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]}, + ["esp32"], + False, + ), + ], +) +def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None: + assert _stamp_covers(stored, _requested_stamp(targets)) is expected + + +@contextmanager +def _framework_install_patches(): + """Patches for calling _check_esphome_idf_framework_install directly with + real stamp files (unlike espidf_mocks, which stubs the stamp layer).""" + with ( + patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + patch("esphome.espidf.framework.get_system_python_path", return_value="python"), + patch("esphome.espidf.framework.rmdir"), + ): + yield run_ok + + +def _extracted_framework_with_stamp(stamp: dict) -> Path: + framework_path = _get_framework_path(_IDF_VERSION) + framework_path.mkdir(parents=True, exist_ok=True) + (framework_path / ".esphome_extracted").touch() + _write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp) + return framework_path + + +def test_framework_install_target_subset_skips_install() -> None: + """A stamp holding a superset of the requested targets skips the installer.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"])) + + with _framework_install_patches() as run_ok: + _, fresh_extract = _check_esphome_idf_framework_install( + _IDF_VERSION, ["esp32"], ["required"] + ) + + run_ok.assert_not_called() + assert fresh_extract is False + # the stamp is untouched + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["all"] + + +def test_framework_install_new_target_installs_and_merges_stamp() -> None: + """A new target runs the installer for just that target and the stamp + records the union of everything installed so far.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) + + with _framework_install_patches() as run_ok: + _, fresh_extract = _check_esphome_idf_framework_install( + _IDF_VERSION, ["esp32c3"], ["required"] + ) + + assert fresh_extract is False + assert "--targets=esp32c3" in run_ok.call_args[0][0] + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["esp32", "esp32c3"] + + +def test_check_esp_idf_install_env_targets_override_wins( + espidf_mocks: SimpleNamespace, +) -> None: + """An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets.""" + with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True): + check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) + + install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] + assert "--targets=all" in install_cmd + + +def test_check_esp_idf_install_uses_requested_targets( + espidf_mocks: SimpleNamespace, +) -> None: + """Without the env override, the caller's per-variant targets are installed.""" + check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"]) + + install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0] + assert "--targets=esp32" in install_cmd + + +def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None: + """Requesting "all" over a per-variant stamp merges and collapses to + ["all"], not ["all", "esp32"], so the stamp shape stays canonical.""" + framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"])) + + with _framework_install_patches() as run_ok: + _check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"]) + + run_ok.assert_called_once() + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["all"] + + +def test_framework_install_tools_change_resets_stamp_targets() -> None: + """A reinstall triggered by a tools change must not carry the old stamp's + targets forward: the installer only ran for this build's targets, so a + merged stamp would let other variants skip the reinstall they need.""" + framework_path = _extracted_framework_with_stamp( + _requested_stamp(["all"], tools=["cmake", "required"]) + ) + + with _framework_install_patches() as run_ok: + _check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"]) + + run_ok.assert_called_once() + stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text()) + assert stamp["targets"] == ["esp32"] + assert stamp["tools"] == ["required"] + + @pytest.mark.parametrize( ("lib", "expect_hint"), [ @@ -959,28 +1143,97 @@ def test_get_tool_downloads_inprocess_explicit_tool_specs( # --------------------------------------------------------------------------- -# _patch_tools_json_demote_openocd (openocd-esp32 made optional) +# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional) # --------------------------------------------------------------------------- -def test_demote_openocd_patches_install_type(tmp_path: Path) -> None: +def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None: tools_json = _write_tools_json( tmp_path, { "tools": [ {"name": "openocd-esp32", "install": "always"}, - {"name": "cmake", "install": "always"}, + {"name": "xtensa-esp-elf-gdb", "install": "always"}, + {"name": "riscv32-esp-elf-gdb", "install": "always"}, + {"name": "esp32ulp-elf", "install": "always"}, + {"name": "xtensa-esp-elf", "install": "always"}, + {"name": "esp-rom-elfs", "install": "always"}, ] }, ) - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + install_types = {t["name"]: t["install"] for t in data["tools"]} + assert install_types == { + "openocd-esp32": "on_request", + "xtensa-esp-elf-gdb": "on_request", + "riscv32-esp-elf-gdb": "on_request", + "esp32ulp-elf": "on_request", + # the compiler toolchain and ROM ELFs stay required + "xtensa-esp-elf": "always", + "esp-rom-elfs": "always", + } + + +def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None: + """riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome + never builds) but keeps its RISC-V targets; other tools are untouched.""" + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"], + }, + { + "name": "xtensa-esp-elf", + "install": "always", + "supported_targets": ["esp32", "esp32s2", "esp32s3"], + }, + ] + }, + ) + _patch_tools_json_demote_unused_tools(tmp_path) + + data = json.loads(tools_json.read_text(encoding="utf-8")) + riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") + xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf") + assert riscv["supported_targets"] == ["esp32c3", "esp32p4"] + assert riscv["install"] == "always" + assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"] + + +def test_demote_unused_tools_bad_supported_targets_type_still_demotes( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A non-list supported_targets on riscv32-esp-elf must not abort the + other demotions; the targets patch is best-effort and logs the skip so a + silently resumed riscv download is diagnosable.""" + tools_json = _write_tools_json( + tmp_path, + { + "tools": [ + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": None, + }, + {"name": "openocd-esp32", "install": "always"}, + ] + }, + ) + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + _patch_tools_json_demote_unused_tools(tmp_path) data = json.loads(tools_json.read_text(encoding="utf-8")) openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32") - cmake = next(t for t in data["tools"] if t["name"] == "cmake") + riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf") assert openocd["install"] == "on_request" - # other tools are left untouched - assert cmake["install"] == "always" + assert riscv["supported_targets"] is None + assert "Unexpected supported_targets" in caplog.text def test_patch_tools_json_unexpected_structure_warns_and_skips( @@ -992,16 +1245,29 @@ def test_patch_tools_json_unexpected_structure_warns_and_skips( tools_json = tools_dir / "tools.json" tools_json.write_text('["not", "a", "dict"]', encoding="utf-8") before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip + _patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip assert tools_json.read_text(encoding="utf-8") == before -def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None: +def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None: tools_json = _write_tools_json( - tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]} + tmp_path, + { + "tools": [ + {"name": "openocd-esp32", "install": "on_request"}, + {"name": "xtensa-esp-elf-gdb", "install": "on_request"}, + {"name": "riscv32-esp-elf-gdb", "install": "on_request"}, + {"name": "esp32ulp-elf", "install": "on_request"}, + { + "name": "riscv32-esp-elf", + "install": "always", + "supported_targets": ["esp32c3", "esp32p4"], + }, + ] + }, ) before = tools_json.read_text(encoding="utf-8") - _patch_tools_json_demote_openocd(tmp_path) + _patch_tools_json_demote_unused_tools(tmp_path) assert tools_json.read_text(encoding="utf-8") == before @@ -1231,6 +1497,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None: assert _check_stamp(f, {"a": "1"}) is False +def test_read_stamp_corrupt_file_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # A corrupt stamp forces a full reinstall on every build, so it warns + # where the normal missing-file case stays silent. + f = tmp_path / "s.json" + f.write_text("{ not json", encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + assert _read_stamp(f) is None + assert "Ignoring corrupt stamp file" in caplog.text + + +def test_read_stamp_unreadable_file_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # An I/O fault (permissions, disk error) is distinguished from a simply + # missing stamp with a warning before falling back to reinstall. + f = tmp_path / "s.json" + f.write_text(json.dumps({"a": "1"}), encoding="utf-8") + with ( + patch.object(Path, "open", side_effect=PermissionError("denied")), + caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"), + ): + assert _read_stamp(f) is None + assert "Could not read stamp file" in caplog.text + + +def test_read_stamp_non_dict_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Well-formed JSON that is not an object is a fault, not a first install; + # it must leave a trace before forcing reinstalls. + f = tmp_path / "s.json" + f.write_text("null", encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"): + assert _read_stamp(f) is None + assert "unexpected type NoneType" in caplog.text + + +def test_read_stamp_missing_file_is_silent( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Missing stamps are the normal first-install case and must not log. + with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"): + assert _read_stamp(tmp_path / "nope.json") is None + assert "stamp file" not in caplog.text + + def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None: _write_idf_version_txt(tmp_path, "5.1.2") assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n" diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py new file mode 100644 index 0000000000..e4cc6e137e --- /dev/null +++ b/tests/unit_tests/test_espidf_runner.py @@ -0,0 +1,211 @@ +"""Tests for esphome.espidf.runner.""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +import subprocess +import sys +import threading + +import pytest + +from esphome.espidf import runner + +# A flushing runner delivers the first line in well under a second; this is +# only ever waited out when the shim has gone back to buffering, so keep it +# just long enough to cover interpreter startup on a loaded CI machine. +FIRST_LINE_TIMEOUT = 10.0 + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Point ``runner.main()`` at *probe* with a buffered fake stdout. + + ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and + ``os.get_terminal_size``; every one of those is monkeypatched so it is + put back afterwards. The fake stdout is block buffered like a pipe, so + the caller can tell whether the shim flushed. The wrapper comes back with + the buffer because dropping it would close the buffer underneath us. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + + return buf, stream + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` against *probe* and expect a clean exit.""" + buf, stream = _prepare_main(monkeypatch, probe, *args) + assert runner.main() == 0 + return buf, stream + + +def test_main_filters_noise_and_flushes_each_write( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Useful lines reach the stream right away; noisy ones are dropped.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py" + ) + + # Read before any flush of our own: the shim has to have flushed. + output = buf.getvalue().decode("utf-8") + + assert "Compiling main.cpp\n" in output + assert "[2/9] Building C object\n" in output + # Matched by FILTER_IDF_LINES, so they never leave the runner. + assert "Project build complete." not in output + assert "-- Component paths:" not in output + # Held back until the end because no terminator arrived. + assert output.endswith("still going\n") + + +def test_main_keeps_output_after_a_form_feed( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A form feed is text, not a line break, so nothing after it is lost.""" + buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") + + assert buf.getvalue().decode("utf-8") == ( + "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" + ) + + +def test_main_drains_a_partial_line_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A build that stops mid line must still show that line. + + This is the whole point of draining: the message explaining why the + build failed is exactly the one most likely to arrive without a + trailing newline. + """ + buf, _stream = _prepare_main( + monkeypatch, fixture_path / "espidf" / "crashing_probe.py" + ) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 2 + assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" + + +def test_main_reports_rather_than_raises_when_draining_fails( + monkeypatch: pytest.MonkeyPatch, + fixture_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + """A stream that closed under us must not crash the runner's cleanup. + + The drain runs from a ``finally``, so an exception there would replace + whatever exit code the build was carrying back. + """ + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + reported = capfd.readouterr().err + assert "Could not write out remaining output" in reported + # The held line has to come along; the stream it was meant for is gone. + assert "partial before close" in reported + + +def test_main_survives_a_drain_failure_with_nowhere_to_report_it( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """With no real stderr to report to, cleanup still must not raise. + + ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` + falls back to ``sys.stdout``, which here is the shim wrapping the stream + that just failed. + """ + monkeypatch.setattr(sys, "__stderr__", None) + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" + ) + + assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" + + +def test_main_keeps_everything_in_verbose_mode( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """``-v`` turns the filter off so the noisy lines survive.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" + ) + + output = buf.getvalue().decode("utf-8") + + assert "Project build complete.\n" in output + assert "-- Component paths: /a /b /c\n" in output + # With no filter there is no line buffering, so the partial line goes + # straight through as well. + assert output.endswith("still going") + + +def test_runner_streams_output_before_the_build_finishes( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """The runner must flush, or a dashboard build looks frozen. + + ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and + hands it a pipe when esphome itself is running under the dashboard. A + pipe is block buffered, so without a flush in the shim's ``write()`` the + output sits in the child until 8 KiB piles up or the build ends. + """ + runner_py = Path(runner.__file__) + probe = fixture_path / "espidf" / "streaming_probe.py" + + with subprocess.Popen( + [sys.executable, str(runner_py), str(probe)], + stdout=subprocess.PIPE, + # Keep stderr: if the runner dies on startup, its traceback is the + # only clue about why no line showed up. + stderr=subprocess.PIPE, + env=probe_env, + text=True, + ) as proc: + assert proc.stdout is not None + assert proc.stderr is not None + first_line: list[str] = [] + reader = threading.Thread( + target=lambda: first_line.append(proc.stdout.readline()), daemon=True + ) + try: + reader.start() + reader.join(FIRST_LINE_TIMEOUT) + still_running = proc.poll() is None + + # The probe sleeps for a minute after writing, so reaching us at + # all means the line was flushed rather than released at exit. + assert first_line == ["Compiling main.cpp\n"], ( + f"runner stderr: {'' if still_running else proc.stderr.read()}" + ) + assert still_running + finally: + proc.kill() + proc.wait() + # Join before leaving the block, so the reader is done rather than + # racing ``Popen`` closing the pipe under it. + reader.join(1.0) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index f98cc70428..56f358a24c 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -5,15 +5,19 @@ import json import os from pathlib import Path +import subprocess from unittest.mock import patch +import pytest + +from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, CONF_FRAMEWORK, CONF_SOURCE, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.espidf import toolchain @@ -52,7 +56,7 @@ def test_get_esphome_esp_idf_paths_forwards_source_override(): toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") - mock_install.assert_called_once_with("5.5.4", source_url=url) + mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url) def test_get_esphome_esp_idf_paths_no_override(): @@ -63,7 +67,28 @@ def test_get_esphome_esp_idf_paths_no_override(): toolchain, "check_esp_idf_install", return_value=("/fw", "/penv") ) as mock_install: toolchain._get_esphome_esp_idf_paths("5.5.4") - mock_install.assert_called_once_with("5.5.4", source_url=None) + mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None) + + +def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch): + """The configured variant restricts the toolchain install to its target.""" + monkeypatch.delenv("CI", raising=False) + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + assert toolchain._get_configured_targets() == ["esp32s3"] + + +def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch): + """No stored variant (e.g. tooling outside a build) keeps the default.""" + monkeypatch.delenv("CI", raising=False) + CORE.data.pop(KEY_ESP32, None) + assert toolchain._get_configured_targets() is None + + +def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch): + """CI installs every target so the shared cache covers all variants.""" + monkeypatch.setenv("CI", "true") + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + assert toolchain._get_configured_targets() is None def _setup_build(setup_core: Path) -> tuple[Path, Path]: @@ -105,7 +130,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: compile_commands.parent.mkdir(parents=True, exist_ok=True) compile_commands.write_text("[]") cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text('{"cxx_path": "cached"}') + cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}') cc_mtime = compile_commands.stat().st_mtime os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) @@ -113,7 +138,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_not_called() - assert result == {"cxx_path": "cached"} + assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"} + + +def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None: + """A cache predating cc_path is rebuilt even though it is newer. + + Such a cache stays newer than the compile DB forever, so consumers that + derive the binutils paths from cc_path would keep failing on it. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text('{"cxx_path": "cached"}') + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert result["cc_path"] == "gcc" def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None: @@ -136,6 +185,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} +@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"]) +def test_get_idedata_regenerates_on_non_dict_cache( + setup_core: Path, cached: str +) -> None: + """A newer cache holding valid JSON that is not an object is regenerated. + + A bare string would otherwise pass the cc_path check by substring and be + handed to consumers expecting a dict. + """ + compile_commands, cache = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(cached) + cc_mtime = compile_commands.stat().st_mtime + os.utime(cache, (cc_mtime + 1, cc_mtime + 1)) + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cc_path": "gcc", "cxx_path": "g++"}, + ) as mock_transform: + result = toolchain.get_idedata() + + mock_transform.assert_called_once() + assert isinstance(result, dict) + + def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: """An unparseable (but newer) cache falls back to regeneration.""" compile_commands, cache = _setup_build(setup_core) @@ -189,6 +265,77 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: + """A build dir that was never created raises EsphomeError. + + Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which + the log stack-trace decoder doesn't recognise as a decode failure. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + assert not build_dir.exists() + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None: + """A build dir that exists but was never configured raises EsphomeError.""" + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + + with pytest.raises(EsphomeError, match="No ESP-IDF build found"): + toolchain._get_cmake_output(build_dir) + + +def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: + """A configured build still runs cmake and caches the output. + + The missing-build guard must not get in the way of a real build. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + build_dir.mkdir(parents=True) + (build_dir / "CMakeCache.txt").write_text("") + + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n" + ) + with ( + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run, + ): + assert toolchain._get_cmake_output(build_dir) == completed.stdout + # Second call is served from the cache rather than re-running cmake. + assert toolchain._get_cmake_output(build_dir) == completed.stdout + + mock_run.assert_called_once() + assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line") + + +def test_get_cmake_output_missing_build_does_not_resolve_idf_env( + setup_core: Path, +) -> None: + """The build check runs before the env is resolved. + + Resolving the env calls check_esp_idf_install(), which can download and + extract the whole framework. A doomed call must never start that. + """ + _setup_build(setup_core) + build_dir = CORE.relative_build_path("build") + + with ( + patch.object(toolchain, "_get_idf_env") as mock_env, + patch.object(toolchain.subprocess, "run") as mock_run, + pytest.raises(EsphomeError), + ): + toolchain._get_cmake_output(build_dir) + + mock_env.assert_not_called() + mock_run.assert_not_called() + + def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: """The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS.""" _setup_build(setup_core) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @pytest.fixture def mock_requests_head() -> MagicMock: - """Patch `external_files.requests.head` so the conditional HEAD-request - validator can be tested without doing real HTTP. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("requests.head") as m: yield m @pytest.fixture def mock_requests_get() -> MagicMock: - """Patch `external_files.requests.get` so the download path can be - tested without doing real HTTP. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index b8aa19d6ae..08751879c2 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -1433,8 +1433,10 @@ def test_importing_framework_helpers_does_not_import_requests() -> None: [ sys.executable, "-c", - "import sys\nimport esphome.framework_helpers\n" - "print('\\n'.join(sys.modules))", + ( + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))" + ), ], capture_output=True, text=True, diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 13283fc067..e296d48a46 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -15,6 +15,7 @@ from filelock import FileLock import pytest from esphome import git +import esphome.config_validation as cv from esphome.core import CORE, EsphomeError, TimePeriodSeconds from esphome.git import GitCommandError @@ -246,6 +247,347 @@ def test_run_git_command_strips_fatal_prefix( assert "repository not found" in str(exc_info.value) +def _git_failure(stderr: bytes, returncode: int = 128) -> Mock: + """Build a failed subprocess.run result with the given stderr.""" + return Mock(returncode=returncode, stdout=b"", stderr=stderr) + + +_GIT_OK = Mock(returncode=0, stdout=b"ok", stderr=b"") + + +def test_run_git_command_network_retries_transient_then_succeeds( + mock_subprocess_run: Mock, +) -> None: + """A transient network failure is retried and the retry's result returned.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep") as mock_sleep: + result = git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + mock_sleep.assert_called_once_with(2) + + +def test_run_git_command_network_gives_up_after_max_attempts( + mock_subprocess_run: Mock, +) -> None: + """A persistent transient-looking failure raises after the final attempt.""" + mock_subprocess_run.side_effect = lambda *args, **kwargs: _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"server certificate verification failed. CAfile: none CRLfile: none\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="certificate verification failed"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 3 + assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4] + + +@pytest.mark.parametrize( + ("stderr", "transient"), + [ + # Transient: DNS, TLS, dropped connections, server-side errors + ("unable to access 'https://x/': The requested URL returned error: 502", True), + ("unable to access 'https://x/': Could not resolve host: github.com", True), + ("unable to access 'https://x/': Failed to connect: Timed out", True), + ("unable to access 'https://x/': Recv failure: Connection reset", True), + ("unable to access 'https://x/': Connection refused", True), + ("fatal: early EOF\nfatal: fetch-pack: invalid index-pack output", True), + ( + ( + "error: RPC failed; HTTP 500 curl 22 The requested URL returned " + "error: 500\nfatal: expected flush after ref listing" + ), + True, + ), + ( + ( + "unable to access 'https://x/': server certificate verification " + "failed. CAfile: none CRLfile: none" + ), + True, + ), + ( + ( + "error: RPC failed; curl 56 GnuTLS recv error (-110)\n" + "fatal: the remote end hung up unexpectedly" + ), + True, + ), + ( + ( + "fetch-pack: unexpected disconnect while reading sideband packet\n" + "fatal: early EOF" + ), + True, + ), + # 429 rate limiting is the one retryable 4xx, in both curl forms + ("unable to access 'https://x/': The requested URL returned error: 429", True), + ("error: RPC failed; HTTP 429 curl 22\nfatal: expected flush", True), + ( + ( + "unable to access 'https://x/': OpenSSL SSL_read: error:0A000126:" + "SSL routines::unexpected eof while reading, errno 0" + ), + True, + ), + # Permanent: missing repo, auth, bad ref, other 4xx + ("fatal: repository 'https://github.com/test/repo/' not found", False), + ( + ( + "fatal: could not read Username for 'https://github.com': " + "terminal prompts disabled" + ), + False, + ), + ("fatal: couldn't find remote ref refs/heads/nope", False), + ( + ( + "unable to access 'https://github.com/org/private.git/': " + "The requested URL returned error: 403" + ), + False, + ), + ("fatal: Authentication failed for 'https://github.com/test/repo/'", False), + # Smart-HTTP (HTTP/2) 4xx form has no "returned error:" text and + # mixes in transient-looking wording; still permanent + ( + ( + "error: RPC failed; HTTP 403 curl 92 HTTP/2 stream 5 was not " + "closed cleanly: CANCEL (err 8)\nfatal: expected flush after " + "ref listing" + ), + False, + ), + ( + ( + "error: RPC failed; HTTP 404 curl 22\n" + "fatal: the remote end hung up unexpectedly" + ), + False, + ), + ( + ( + "fatal: unable to access 'https://x/': gnutls_handshake() " + "failed: The TLS connection was non-properly terminated." + ), + True, + ), + # Transient-looking tokens in the URL must not classify as transient + ("fatal: repository 'https://github.com/x/esp32_ssl_reader/' not found", False), + ("fatal: repository 'https://gitlab.com/gnutls/gnutls.git/' not found", False), + ("", False), + ], +) +def test_is_transient_git_error(stderr: str, transient: bool) -> None: + """Real-world stderr outputs classify correctly as transient or permanent.""" + assert git._is_transient_git_error(stderr) is transient + + +def test_run_git_command_network_no_retry_on_permanent_error( + mock_subprocess_run: Mock, +) -> None: + """Permanent failures (missing repo, auth, bad ref) fail on the first try.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repository 'https://github.com/test/repo/' not found\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_no_retry_when_git_missing( + mock_subprocess_run: Mock, +) -> None: + """A missing git binary is not transient and must not be retried.""" + from esphome.git import GitNotInstalledError + + mock_subprocess_run.side_effect = FileNotFoundError("git not found") + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitNotInstalledError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_by_default(mock_subprocess_run: Mock) -> None: + """Without network=True even a transient-looking failure is not retried.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "status"]) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_retry_matches_full_stderr_not_last_line( + mock_subprocess_run: Mock, +) -> None: + """The transient marker often sits above the final fatal line; the retry + decision must look at the full stderr, not just the extracted message.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"error: RPC failed; curl 56 GnuTLS recv error (-54)\n" + b"fatal: fetch-pack: invalid index-pack output\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + + +def test_run_git_command_retry_warning_redacts_credentials( + mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """The retry warning embeds the git error, which embeds the URL; embedded + credentials must be redacted since warnings end up in pasted logs.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://user:hunter2@github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with ( + patch("esphome.git.time.sleep"), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + +def test_run_git_command_clone_retry_removes_leftover_destination( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A partial clone destination left by a failed attempt is removed before + the retry, so the retry cannot fail on 'destination path already exists'.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + (dest / "partial").write_text("x") + + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command( + [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/test/repo", + str(dest), + ], + network=True, + retry_cleanup=dest, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + assert not dest.exists() + + +def test_run_git_command_cleanup_failure_reraises_original_error( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """When the pre-retry cleanup fails, the git error stays the reported + cause instead of being replaced by the cleanup OSError.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.rmtree", side_effect=OSError("locked")), + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="Could not resolve host"), + ): + git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + retry_cleanup=dest, + ) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_on_empty_stderr_failure( + mock_subprocess_run: Mock, +) -> None: + """A failure with no stderr (e.g. git killed by a signal) is not retried.""" + mock_subprocess_run.return_value = _git_failure(b"", returncode=1) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="git exited with code 1"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_non_utf8_stderr_does_not_crash( + mock_subprocess_run: Mock, +) -> None: + """Locale-encoded (non-UTF-8) stderr must not raise UnicodeDecodeError.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repositorio no encontrado \xe9\xff\n" + ) + + with pytest.raises(GitCommandError, match="repositorio no encontrado"): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + + def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: """Test that run_git_command works without git_dir (clone case).""" # Configure mock to return success @@ -488,7 +830,7 @@ def test_clone_or_update_with_refresh_updates_old_repo( def test_clone_or_update_with_refresh_skips_fresh_repo( - tmp_path: Path, mock_run_git_command: Mock + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test that refresh doesn't update fresh repos.""" # Set up CORE.config_path so data_dir uses tmp_path @@ -513,20 +855,74 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Set modification time to 1 hour ago os.utime(fetch_head, (recent_time, recent_time)) + # Freeze the clock at 1 hour (plus a margin larger than any filesystem + # mtime rounding) after the mtime so the logged countdown is deterministic + frozen_now = fetch_head.stat().st_mtime + 3600.5 + # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) - result_dir, revert = git.clone_or_update( - url=url, - ref=ref, - refresh=refresh, - domain=domain, - ) + with ( + patch("esphome.git.time.time", return_value=frozen_now), + caplog.at_level(logging.INFO, logger="esphome.git"), + ): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) # Should NOT call git fetch since repo is fresh mock_run_git_command.assert_not_called() assert result_dir == repo_dir assert revert is None + # Should tell the user the update was skipped and when the next refresh is + assert f"Skipping update for {url}@{ref}" in caplog.text + assert "will refresh on the next run after 22h 59min" in caplog.text + assert "(refresh: 1d)" in caplog.text + + +def test_clone_or_update_with_refresh_never_logs_refresh_disabled( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test that a config-level refresh: never skips without a countdown log.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = None + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + _mark_clone_complete(repo_dir) + + # Create FETCH_HEAD file with current timestamp + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + + # refresh: never validates to 365250 days, not the NEVER_REFRESH sentinel + refresh = cv.source_refresh("never") + with caplog.at_level(logging.DEBUG, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + # Should log refresh disabled at debug level, not a countdown + assert f"Skipping update for {url}@{ref} (refresh disabled)" in caplog.text + assert "will refresh on the next run" not in caplog.text + def test_clone_or_update_clones_missing_repo( tmp_path: Path, mock_run_git_command: Mock @@ -622,10 +1018,10 @@ def test_clone_or_update_with_none_refresh_always_updates( "ambiguous argument 'HEAD': unknown revision or path not in the working tree.", ), ("stash", "fatal: unable to write new index file"), - ( - "fetch", - "fatal: unable to access 'https://github.com/test/repo/': Could not resolve host", - ), + # The fetch failure must be non-transient: a transient one (e.g. + # "Could not resolve host") now keeps the existing clone instead of + # triggering recovery. + ("fetch", "fatal: couldn't find remote ref main"), ("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"), ], ) @@ -692,6 +1088,236 @@ def test_clone_or_update_recovers_from_git_failures( assert result_dir == repo_dir +@pytest.mark.parametrize("fetch_head_preexists", [True, False]) +def test_clone_or_update_transient_fetch_keeps_existing_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + fetch_head_preexists: bool, +) -> None: + """A transient network failure while refreshing a verified clone falls back + to the existing clone instead of destroying it with a recovery re-clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + if not fetch_head_preexists: + # First-ever refresh: age comes from HEAD, FETCH_HEAD absent + (repo_dir / ".git" / "FETCH_HEAD").unlink() + head = repo_dir / ".git" / "HEAD" + head.write_text("test") + old_time = time.time() - 2 * 86400 + os.utime(head, (old_time, old_time)) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch": + # A failed fetch still freshens FETCH_HEAD, like real git + (repo_dir / ".git" / "FETCH_HEAD").touch() + stderr = ( + "fatal: unable to access " + "'https://user:hunter2@github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with caplog.at_level(logging.WARNING, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # The existing clone is returned, not removed or re-cloned + assert result_dir == repo_dir + assert repo_dir.is_dir() + assert revert is None + assert not any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + # The completion marker must be restored, or the next run treats the + # entry as an incomplete clone and removes it + assert _marker_path(repo_dir).is_file() + # The warning must say what the build will actually use and how stale it is + assert "using the existing clone at abc123" in caplog.text + assert "ago" in caplog.text + # Credentials embedded in the URL must not reach the warning log + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + # The FETCH_HEAD the failed fetch freshened must not survive, or the + # refresh window would suppress retrying the update on subsequent runs + fetch_head = repo_dir / ".git" / "FETCH_HEAD" + if fetch_head_preexists: + assert time.time() - fetch_head.stat().st_mtime > refresh.total_seconds + else: + assert not fetch_head.exists() + + +def test_clone_or_update_timestamp_restore_failure_routes_to_recovery( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """If the FETCH_HEAD restore fails, the fallback cannot stay honest, so + the git error must route through recovery instead of a raw OSError.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with ( + patch("esphome.git.os.utime", side_effect=OSError("read-only")), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + assert result_dir == repo_dir + assert "Could not restore the refresh timestamp" in caplog.text + # Recovery re-cloned rather than surfacing the OSError + assert call_counts.get("clone", 0) == 1 + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_clone_or_update_network_commands_carry_retry_flag( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """clone/fetch/submodule opt into transient-failure retry; local commands + (rev-parse, stash, reset) must not, so a refactor cannot silently drop or + widen the retry wiring.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + seen: set[str] = set() + for call in mock_run_git_command.call_args_list: + cmd_type = _get_git_command_type(call.args[0]) + seen.add(cmd_type) + if cmd_type in ("clone", "fetch", "submodule"): + assert call.kwargs.get("network") is True, cmd_type + else: + assert "network" not in call.kwargs, cmd_type + if cmd_type == "clone": + assert call.kwargs.get("retry_cleanup") == repo_dir + + expected = {"fetch", "reset", "submodule"} + expected |= {"clone"} if refresh is None else {"rev-parse", "stash"} + assert expected <= seen + + +def test_clone_or_update_transient_submodule_failure_still_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A transient failure after the reset (submodules) leaves a half-updated + tree, so it must route through recovery instead of keeping the clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "submodule" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/sub/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert result_dir == repo_dir + # The half-updated tree must be recovered via re-clone, not kept + assert call_counts.get("clone", 0) == 1 + + def test_clone_or_update_fails_when_recovery_also_fails( tmp_path: Path, mock_run_git_command: Mock ) -> None: diff --git a/tests/unit_tests/test_happy_eyeballs.py b/tests/unit_tests/test_happy_eyeballs.py new file mode 100644 index 0000000000..3335a8a3e3 --- /dev/null +++ b/tests/unit_tests/test_happy_eyeballs.py @@ -0,0 +1,325 @@ +"""Tests for the Happy Eyeballs urllib3 shim.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +import socket +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs + + +def _addr_info(host: str, port: int) -> tuple[Any, ...]: + """Build a getaddrinfo-style result tuple for an IPv4 address.""" + return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port)) + + +@pytest.fixture +def create_connection() -> Any: + """A freshly built Happy Eyeballs create_connection replacement.""" + return _make_create_connection() + + +@pytest.fixture +def listener() -> Generator[tuple[str, int]]: + """A listening TCP socket on localhost; yields its address.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(5) + yield server.getsockname() + server.close() + + +@pytest.fixture +def mock_gai(listener: tuple[str, int]) -> Generator[Any]: + """Resolve every host to two copies of the listener's address.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock: + yield mock + + +def test_ensure_happy_eyeballs_patches_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shim replaces urllib3's create_connection exactly once.""" + import urllib3.util.connection + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + + ensure_happy_eyeballs() + patched = urllib3.util.connection.create_connection + assert patched is not stock + assert patched._esphome_patched + + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is patched + + +def test_connects_and_restores_socket_state( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The winning socket comes back blocking, with timeout and options set.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + ) + + try: + assert sock.getpeername() == listener + assert sock.gettimeout() == 5 + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + finally: + sock.close() + + +def test_single_address_connects( + create_connection: Any, listener: tuple[str, int] +) -> None: + """A host resolving to one address connects through the same path.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]): + sock = create_connection(("example.com", listener[1]), timeout=5) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_falls_back_to_working_address( + create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable first address does not block the working one.""" + from esphome import happy_eyeballs + + # 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the + # network; either way the second address must win well within the + # timeout instead of waiting out the first. A short stagger keeps the + # test's duration network independent. + monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01) + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)] + + with patch("socket.getaddrinfo", return_value=addr_infos): + sock = create_connection(("example.com", listener[1]), timeout=10) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_bracketed_ipv6_host_is_stripped( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """A bracketed IPv6 literal is unbracketed before resolution.""" + sock = create_connection(("[::1]", listener[1]), timeout=5) + + try: + assert mock_gai.call_args[0][0] == "::1" + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_source_address_is_bound( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The socket binds to the requested source address before connecting.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + source_address=("127.0.0.1", 0), + ) + + try: + assert sock.getsockname()[0] == "127.0.0.1" + finally: + sock.close() + + +def test_socket_factory_failure_closes_socket( + listener: tuple[str, int], mock_gai: Any +) -> None: + """A socket-option failure fails the connect instead of leaking sockets. + + Instrumented at ``_set_socket_options`` (which the factory calls with + the just-created socket) rather than by patching ``socket.socket``, + which is platform dependent: the event loop's internal socketpair use + differs between platforms. + """ + created: list[socket.socket] = [] + + def failing_set_options(sock: socket.socket, options: Any) -> None: + created.append(sock) + raise OSError("bad socket option") + + # Patch before building the closure; it binds _set_socket_options at + # creation time. + with patch("urllib3.util.connection._set_socket_options", new=failing_set_options): + create_connection = _make_create_connection() + with pytest.raises(OSError): + create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(999999, 999999, 1)], + ) + + assert created, "socket factory never ran" + assert all(sock.fileno() == -1 for sock in created), "socket leaked open" + + +def test_default_timeout_yields_blocking_socket( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """Without an explicit timeout the socket follows the global default.""" + sock = create_connection(("example.com", listener[1])) + + try: + assert sock.gettimeout() is socket.getdefaulttimeout() + finally: + sock.close() + + +def test_settimeout_failure_closes_socket( + create_connection: Any, mock_gai: Any +) -> None: + """A failure restoring socket state closes the winner instead of leaking.""" + bad_sock = Mock() + bad_sock.settimeout.side_effect = OSError("bad timeout") + + with ( + patch("esphome.async_thread.run_async", return_value=bad_sock), + pytest.raises(OSError, match="bad timeout"), + ): + create_connection(("example.com", 80), timeout=5) + + bad_sock.close.assert_called_once() + + +def test_connect_timeout_raises() -> None: + """A connect that never completes raises within the timeout.""" + + async def never(*args: Any, **kwargs: Any) -> None: + await asyncio.sleep(60) + + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)] + + # Patch before building the closure; it binds start_connection at + # creation time. + with patch("aiohappyeyeballs.start_connection", new=never): + create_connection = _make_create_connection() + with ( + patch("socket.getaddrinfo", return_value=addr_infos), + pytest.raises(TimeoutError), + ): + create_connection(("example.com", 80), timeout=0.1) + + +def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None: + """Hostnames urllib3 would reject are still rejected.""" + from urllib3.exceptions import LocationParseError + + with pytest.raises(LocationParseError): + create_connection(("a" * 300, 80)) + + +def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None: + """An empty resolution matches stock urllib3's OSError, not ValueError.""" + with ( + patch("socket.getaddrinfo", return_value=[]), + pytest.raises(OSError, match="empty"), + ): + create_connection(("example.com", 80), timeout=5) + + +def test_ensure_falls_back_to_stock_when_internals_move( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """If urllib3 private names disappear, downloads keep the stock connect + and the warning is latched to fire once, not per download.""" + import urllib3.util.connection + + from esphome import happy_eyeballs + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + factory = Mock(side_effect=ImportError("gone")) + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory) + + ensure_happy_eyeballs() + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is stock + assert factory.call_count == 1 + assert caplog.text.count("Happy Eyeballs unavailable") == 1 + + +def test_ensure_survives_missing_urllib3( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unimportable urllib3 degrades with a warning instead of raising.""" + import sys + + with patch.dict(sys.modules, {"urllib3.util.connection": None}): + ensure_happy_eyeballs() + assert "Happy Eyeballs unavailable" in caplog.text + + +def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None: + """Patching urllib3's create_connection actually reroutes requests.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + + import requests + import urllib3.util.connection + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address + + calls: list[Any] = [] + shim = _make_create_connection() + + def counting(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return shim(*args, **kwargs) + + counting._esphome_patched = True + monkeypatch.setattr(urllib3.util.connection, "create_connection", counting) + + real_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any: + if h == "shim-test.invalid": + return [_addr_info(host, port), _addr_info(host, port)] + return real_getaddrinfo(h, p, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5) + assert resp.status_code == 200 + assert resp.content == b"ok" + assert calls, "requests did not go through the patched create_connection" + finally: + server.shutdown() + server.server_close() diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index fad249b0bb..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): @@ -1074,3 +1090,21 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None: bar = ProgressBar("Uploading", stream=stream) assert bar.enabled is True + + +@pytest.mark.parametrize( + ("seconds", "expected"), + [ + (0, "0s"), + (42, "42s"), + (60, "1min"), + (3661, "1h 1min"), + (86400, "1d"), + (90000, "1d 1h"), + (86700, "1d 5min"), + (-5, "0s"), + ], +) +def test_format_duration(seconds: float, expected: str) -> None: + """Test that durations are rendered as short human-readable strings.""" + assert helpers.format_duration(seconds) == expected diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py new file mode 100644 index 0000000000..b6878c33a2 --- /dev/null +++ b/tests/unit_tests/test_lazy_imports.py @@ -0,0 +1,290 @@ +"""Guard the lazy-import contract of ``esphome.__main__``. + +Every ``esphome`` invocation pays for whatever ``esphome.__main__`` +imports at module level before the requested command runs. The +dashboard and device-builder spawn one ``esphome upload`` subprocess +per device, so keeping validation/codegen machinery out of the +top-level import directly lowers the RAM cost of each concurrent +upload (the upload/logs fast path in ``esphome.compiled_config`` +never needs them). + +``script/check_import_time.py`` budgets import *time* in CI; this +test pins down *which* heavy modules must stay out entirely. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import subprocess +import sys + +# Modules that must only load for the commands that actually use them +# (compile/config validation, shell completion), never from a bare +# ``import esphome.__main__``. +HEAVY_MODULES = ( + "argcomplete", + "esphome.codegen", + "esphome.config", + "esphome.config_validation", + "esphome.cpp_generator", + "esphome.loader", + "voluptuous", +) + +# Everything the storage fast path must keep out of sys.modules; the +# existence guard and the leak check must watch the same list. +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) + +# Heavy only for modules that must not know about the API transport; +# in the existence guard so a rename can't silently no-op its check. +API_HEAVY_MODULES = ("aioesphomeapi",) + +# Heavy only for the single-config dispatch path: the bundle suffix +# check reads BUNDLE_EXTENSION from esphome.const so an ordinary run +# never pays for the bundle machinery and its tarfile chain. +BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") + +# Heavy only for a cache-hit upload/logs run: the JSON cache parse must +# not resolve pyyaml or the yaml_util chain (the read_config fallback +# still uses both). +CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml") + +# Stdlib modules deferred out of the dispatch fast path: a cache-hit +# upload/logs run never writes a file (tempfile), spawns a process +# (subprocess), parses a URL (urllib.parse), or prints a serial +# permission hint (getpass). shutil is deferred too but unwatchable: +# argparse imports it from every add_argument on py3.14. urllib.parse +# is only watchable on 3.13+ where pathlib stopped importing it. +STDLIB_FAST_PATH_MODULES = ( + "tempfile", + "subprocess", + "getpass", + "datetime", + *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), +) + + +def _leaked_heavy_modules(module: str, extra: tuple[str, ...] = ()) -> str: + """Import ``module`` in a subprocess and report the heavy modules it pulled. + + Any ``esphome.components.*`` package counts as heavy: executing a + component package drags in codegen/validation machinery by design. + ``extra`` adds modules that are heavy for this caller specifically. + """ + check = ( + f"import sys; import {module}; " + f"leaked = [m for m in {HEAVY_MODULES + extra!r} if m in sys.modules]; " + "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " + "print(','.join(leaked))" + ) + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def test_main_module_does_not_import_heavy_modules() -> None: + """A bare ``import esphome.__main__`` must not drag in validation/codegen. + + The stdlib watch list rides along here because this check runs in a + clean subprocess: a module-level re-import anywhere on the chain is + caught, which the dispatch fixture (whose setup pre-imports them and + pops before dispatch) structurally cannot do. + """ + leaked = _leaked_heavy_modules("esphome.__main__", extra=STDLIB_FAST_PATH_MODULES) + assert not leaked, ( + f"esphome.__main__ imports heavy modules at top level: {leaked}. " + "Import them lazily inside the command that needs them instead; " + "every esphome invocation (including each parallel dashboard " + "upload subprocess) pays for top-level imports." + ) + + +def test_watched_heavy_modules_exist() -> None: + """A renamed heavy module would silently disable the leak checks.""" + for module in ( + FAST_PATH_HEAVY_MODULES + + API_HEAVY_MODULES + + BUNDLE_HEAVY_MODULES + + CACHE_HIT_HEAVY_MODULES + + STDLIB_FAST_PATH_MODULES + ): + assert importlib.util.find_spec(module) is not None, ( + f"{module} no longer resolves; update the heavy-module lists" + ) + + +def _leaked_from_fixture( + fixture_path: Path, + env: dict[str, str], + script_name: str, + extra: tuple[str, ...] = (), +) -> str: + """Run a fixture script with the watched modules on argv. + + ``env`` comes from the ``probe_env`` fixture so the child can import + the repo checkout; a non-zero exit surfaces the child's stderr. + """ + script = fixture_path / "lazy_imports" / script_name + result = subprocess.run( + [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], + capture_output=True, + text=True, + env=env, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def test_storage_json_fast_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """``apply_to_core`` runs on the upload/logs fast path for every + platform; parsing the stored framework version must not drag in the + validation stack or the esp32 component package. + """ + leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py") + assert not leaked, ( + f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) + + +def test_esptool_upload_fast_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """The esptool serial upload reads the esp32 variant from CORE.data; + resolving it must not drag in the esp32 component package or the + validation stack. + """ + leaked = _leaked_from_fixture( + fixture_path, probe_env, "esptool_upload_fast_path.py" + ) + assert not leaked, ( + f"upload_using_esptool pulls in heavy modules: {leaked}. " + "The upload fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + +def test_api_client_does_not_import_heavy_modules() -> None: + """``esphome.api_client`` is on the logs fast path and must stay light. + + Importing it must not execute any component package (the api package + pulls the whole validation stack: logger, esp32, writer, config, + jinja2, voluptuous). + """ + leaked = _leaked_heavy_modules("esphome.api_client") + assert not leaked, ( + f"esphome.api_client imports heavy modules at top level: {leaked}. " + "The logs fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + +def test_stacktrace_does_not_import_heavy_modules() -> None: + """``esphome.stacktrace`` guards its own docstring's contract. + + Both log paths construct a LogLineProcessor before streaming + starts; importing the module must not pull in aioesphomeapi or + any platform package. + """ + leaked = _leaked_heavy_modules("esphome.stacktrace", extra=API_HEAVY_MODULES) + assert not leaked, ( + f"esphome.stacktrace imports heavy modules at top level: {leaked}. " + "The logs fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + +def test_espidf_toolchain_does_not_import_heavy_modules() -> None: + """The esp-idf upload path must not pull the esp32 package back in. + + upload_using_esptool reaches espidf.toolchain for esp-idf builds; + its keys and the variant mapping live in esphome.const and + esphome.espidf precisely so this import stays light. + """ + leaked = _leaked_heavy_modules("esphome.espidf.toolchain") + assert not leaked, ( + f"esphome.espidf.toolchain imports heavy modules: {leaked}. " + "The upload fast path skips validation; importing the validation " + "stack anyway defeats the validated-config cache." + ) + + +def test_has_mqtt_ip_lookup_does_not_import_mqtt() -> None: + """``has_mqtt_ip_lookup`` runs on the upload/logs fast path for mqtt + configs; reading ``CONF_DISCOVER_IP`` must not drag in the mqtt + component and, with it, the validation stack. + + Runs in a subprocess because this session's other tests import the + mqtt component; the fast path itself must not. + """ + check = ( + "import sys; from esphome.__main__ import has_mqtt_ip_lookup; " + "from esphome.core import CORE; from esphome.const import CONF_MQTT; " + "CORE.config = {CONF_MQTT: {}}; " + "assert has_mqtt_ip_lookup() is True, 'mqtt IP lookup default broke'; " + f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; " + "leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; " + "print(','.join(leaked))" + ) + # check=False keeps the child's stderr (its assertion message or an + # import traceback) visible on failure. + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + leaked = result.stdout.strip() + assert not leaked, ( + f"has_mqtt_ip_lookup pulls in heavy modules: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) + + +def test_yaml_util_does_not_import_heavy_modules() -> None: + """``esphome.yaml_util`` parses the validated-config cache on the + upload/logs fast path; importing it must not pull in voluptuous. + """ + leaked = _leaked_heavy_modules("esphome.yaml_util") + assert not leaked, ( + f"esphome.yaml_util imports heavy modules at top level: {leaked}. " + "The upload/logs fast path skips validation; importing the " + "validation stack anyway defeats the validated-config cache." + ) + + +def test_upload_command_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """The single-config dispatch path checks the bundle suffix on every + run; reading it from esphome.const must not drag in esphome.bundle + and its tarfile chain. + """ + leaked = _leaked_from_fixture( + fixture_path, + probe_env, + "upload_command_fast_path.py", + extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, + ) + assert not leaked, ( + f"the upload dispatch path pulls in heavy modules: {leaked}. " + "An ordinary run only needs the bundle suffix constant, and the " + "JSON cache parse must not resolve voluptuous or pyyaml; keep the " + "esphome.bundle import inside the branch that extracts one, the " + "yaml_util imports inside the read_config fallback, and the " + "deferred stdlib imports inside the write/spawn/serial helpers." + ) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 02798f1029..194b38209b 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,6 +1,44 @@ +from collections.abc import Generator +import errno +import io +import logging +import os +from pathlib import Path +import select +import subprocess +import sys +import time + import pytest -from esphome.log import AnsiFore, AnsiStyle, color +from esphome.core import CORE +from esphome.log import AnsiFore, AnsiStyle, color, setup_log + + +class _FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def restore_logging_state() -> Generator[None, None, None]: + """Undo the global logging changes setup_log() makes.""" + root = logging.getLogger() + handlers = root.handlers[:] + formatters = [handler.formatter for handler in handlers] + level = root.level + urllib3_level = logging.getLogger("urllib3").level + yield + root.handlers[:] = handlers + for handler, formatter in zip(handlers, formatters, strict=True): + handler.setFormatter(formatter) + root.setLevel(level) + logging.getLogger("urllib3").setLevel(urllib3_level) + + +def _probe_command(fixture_path: Path, *args: str) -> list[str]: + """Build the command line for the setup_log probe fixture script.""" + return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args] def test_color_keep_returns_unchanged_message() -> None: @@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None: assert bool(AnsiFore.KEEP) is True # But the value itself is still an empty string assert AnsiFore.KEEP.value == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_output_strips_ansi( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A redirected run must keep colorama so ANSI codes are stripped.""" + result = subprocess.run( + _probe_command(fixture_path), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=True" in result.stdout + assert "red end" in result.stdout + assert "\033" not in result.stdout + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """Dashboard runs escape their color codes, so colorama must not load.""" + result = subprocess.run( + _probe_command(fixture_path, "--dashboard"), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=False" in result.stdout + # Codes pass through untouched for the dashboard to handle. + assert "\033[31mred\033[0m end" in result.stdout + + +def _run_probe_on_pty( + fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool +) -> str: + """Run the probe with stdout on a pty and return the decoded pty output. + + With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the + mixed tty/redirect stream combination while keeping any traceback + available for the exit assertion. + """ + # Unix-only; a module-level import would break test collection on + # Windows, where all the callers are skipped anyway. + import pty + + controller, follower = pty.openpty() + proc = None + output = b"" + deadline = time.monotonic() + 60 + try: + try: + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + finally: + os.close(follower) + while True: + timeout = deadline - time.monotonic() + if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: + pytest.fail(f"pty probe produced no EOF in time; got {output!r}") + try: + chunk = os.read(controller, 1024) + except OSError as err: + # macOS raises EIO once the child closes its end of the pty; + # anything else is a real failure, not end-of-stream. + if err.errno != errno.EIO: + raise + break + if not chunk: + break + output += chunk + stderr_text = "" + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + proc.stderr.close() + assert proc.wait(60) == 0, stderr_text + finally: + os.close(controller) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + return output.decode() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_tty_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A terminal run must skip colorama and keep ANSI codes intact.""" + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True) + assert "colorama_loaded=False" in text + assert "\033[31mred\033[0m end" in text + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_mixed_streams_init_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A tty stdout with a redirected stderr must still initialize colorama. + + The guard requires both streams to be a tty; collapsing it to a + single-stream check would stop stripping ANSI from a redirected + stderr while stdout is a terminal. + """ + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False) + assert "colorama_loaded=True" in text + # stdout is a tty, so colorama leaves its codes alone. + assert "\033[31mred\033[0m end" in text + + +@pytest.fixture +def colorama_probe( + monkeypatch: pytest.MonkeyPatch, restore_logging_state: None +) -> Generator[None, None, None]: + """Shared preamble for the in-process guard-branch tests. + + Clears colorama from sys.modules so the assertions prove what + setup_log() itself did, and snapshots CORE.verbose/quiet, which is + not a no-op: CORE.reset() does not restore them, so without the + snapshot setup_log()'s log-level side effects would leak into later + tests. + """ + monkeypatch.delitem(sys.modules, "colorama", raising=False) + monkeypatch.setattr(CORE, "verbose", CORE.verbose) + monkeypatch.setattr(CORE, "quiet", CORE.quiet) + yield + # init() rebinds sys.stdout/stderr; restore them before monkeypatch + # puts the originals back. + if (colorama := sys.modules.get("colorama")) is not None: + colorama.deinit() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The dashboard side of the guard must not import colorama.""" + monkeypatch.setattr(CORE, "dashboard", True) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_tty_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The tty side of the guard must not import colorama.""" + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_branch_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """Redirected streams must keep importing and initializing colorama.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + setup_log() + assert "colorama" in sys.modules + + +@pytest.mark.parametrize("broken", ["missing", "closed"]) +def test_setup_log_broken_streams_import_colorama( + broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """A missing or closed stream counts as a redirect and must not crash. + + colorama tolerates both, so setup_log() has to reach its init rather + than raise inside the tty probe. + """ + if broken == "missing": + stream = None + else: + stream = io.StringIO() + stream.close() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + setup_log() + assert "colorama" in sys.modules + + +def test_setup_log_win32_always_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The Windows clause must init colorama even when both streams are ttys. + + Old Windows consoles need colorama to translate ANSI escapes, so the + platform check has to win over the tty check. colorama itself keys + off os.name, so on a POSIX host its init/deinit pair is a + passthrough. + """ + monkeypatch.setattr(sys, "platform", "win32") + # Both streams are ttys: without the platform clause this combination + # would skip colorama. + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" in sys.modules diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 7de11d0568..23bfdbcd69 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,6 +18,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange +from esphome import __main__ as main from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -27,6 +28,7 @@ from esphome.__main__ import ( _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, + check_permissions, choose_upload_log_host, command_analyze_memory, command_bundle, @@ -50,6 +52,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -63,8 +66,13 @@ from esphome.__main__ import ( ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult -from esphome.components import esp32 -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 +from esphome.components import esp32, esp8266 +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_VARIANT, + VARIANT_ESP32, + get_esp32_variant, +) from esphome.const import ( CONF_API, CONF_AUTH, @@ -73,6 +81,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -87,6 +96,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -94,6 +104,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_NRF52, PLATFORM_RP2, Toolchain, ) @@ -618,7 +629,7 @@ def test_command_config__no_defaults_skips_strip_default_ids( validated.user_config = {"sensor": [{"name": "x"}]} with patch( - "esphome.__main__.strip_default_ids", side_effect=AssertionError + "esphome.config.strip_default_ids", side_effect=AssertionError ) as mock_strip: result = command_config(args, validated) @@ -808,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -847,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -1621,6 +1667,12 @@ def test_upload_using_esptool_path_conversion( assert isinstance(partitions_path, str) assert partitions_path.endswith("partitions.bin") + # The chip argument must track get_esp32_variant: upload_using_esptool + # reads CORE.data directly to avoid the esp32 package import, and the + # two resolutions must not drift. + chip = cmd_list[cmd_list.index("--chip") + 1] + assert chip == get_esp32_variant().lower() + def test_upload_using_esptool_skips_missing_extra_flash_images( tmp_path: Path, @@ -2520,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -2862,18 +2938,17 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert "MQTT IP discovery failed" in caplog.text -@patch("esphome.__main__.importlib.import_module") +@patch("esphome.platform_hooks.get_platform_hook") def test_upload_program_platform_specific_handler( - mock_import: Mock, + mock_get_hook: Mock, mock_get_port_type: Mock, ) -> None: """Test upload_program with platform-specific upload handler.""" - setup_core(platform="custom_platform") + setup_core(platform=PLATFORM_NRF52) mock_get_port_type.return_value = "CUSTOM" - mock_module = MagicMock() - mock_module.upload_program.return_value = True - mock_import.return_value = mock_module + platform_upload = MagicMock(return_value=True) + mock_get_hook.return_value = platform_upload config = {} args = MockArgs() @@ -2883,8 +2958,8 @@ def test_upload_program_platform_specific_handler( assert exit_code == 0 assert host == "custom_device" - mock_import.assert_called_once_with("esphome.components.custom_platform") - mock_module.upload_program.assert_called_once_with(config, args, "custom_device") + mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "upload_program") + platform_upload.assert_called_once_with(config, args, "custom_device") def test_show_logs_serial( @@ -2918,7 +2993,7 @@ def test_show_logs_no_logger() -> None: show_logs(CORE.config, args, devices) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api( mock_run_logs: Mock, ) -> None: @@ -2944,7 +3019,7 @@ def test_show_logs_api( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_no_states( mock_run_logs: Mock, ) -> None: @@ -2971,7 +3046,7 @@ def test_show_logs_api_no_states( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_with_fqdn_mdns_disabled( mock_run_logs: Mock, ) -> None: @@ -2998,7 +3073,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -3089,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( @@ -3108,16 +3254,15 @@ def test_show_logs_no_method_configured() -> None: show_logs(CORE.config, args, devices) -@patch("esphome.__main__.importlib.import_module") +@patch("esphome.platform_hooks.get_platform_hook") def test_show_logs_platform_specific_handler( - mock_import: Mock, + mock_get_hook: Mock, ) -> None: """Test show_logs with platform-specific logs handler.""" - setup_core(platform="custom_platform", config={"logger": {}}) + setup_core(platform=PLATFORM_NRF52, config={"logger": {}}) - mock_module = MagicMock() - mock_module.show_logs.return_value = True - mock_import.return_value = mock_module + platform_show_logs = MagicMock(return_value=True) + mock_get_hook.return_value = platform_show_logs config = {"logger": {}} args = MockArgs() @@ -3126,8 +3271,8 @@ def test_show_logs_platform_specific_handler( result = show_logs(config, args, devices) assert result == 0 - mock_import.assert_called_once_with("esphome.components.custom_platform") - mock_module.show_logs.assert_called_once_with(config, args, devices) + mock_get_hook.assert_called_once_with(PLATFORM_NRF52, "show_logs") + platform_show_logs.assert_called_once_with(config, args, devices) def test_has_mqtt_logging_no_log_topic() -> None: @@ -3247,6 +3392,14 @@ def test_get_port_type() -> None: assert get_port_type("BOOTSEL") == "BOOTSEL" +def test_mqtt_reexports_discover_ip() -> None: + """The old import path must keep working for external code.""" + from esphome.components import mqtt + from esphome.const import CONF_DISCOVER_IP + + assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP + + def test_has_mqtt_ip_lookup() -> None: """Test has_mqtt_ip_lookup function.""" @@ -4974,7 +5127,7 @@ def test_upload_program_ota_mqttip_deduplication( assert "192.168.1.100" in call_args[0] -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_static_ip_with_mqttip( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -5013,7 +5166,7 @@ def test_show_logs_api_static_ip_with_mqttip( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -5096,7 +5249,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, @@ -5717,6 +5870,65 @@ def test_run_miniterm_batches_lines_with_same_timestamp( ) +def test_run_miniterm_analyzer_import_failure_keeps_streaming( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken platform import must not stop serial log streaming. + + The decoder resolves lazily, so a crash-shaped line has to arrive + before the import is attempted at all. + """ + mock_serial = MockSerial([b"PC: 0x40104960\r\n", MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + with ( + caplog.at_level("INFO", logger="esphome.platform_hooks"), + patch("serial.Serial", return_value=mock_serial), + patch( + "esphome.platform_hooks.get_platform_hook", + side_effect=ImportError("broken platform package"), + ), + ): + result = run_miniterm(config, "/dev/ttyUSB0", args) + + assert result == 0 + # A broken package is distinguishable from a plain capability gap. + assert "failed to import: broken platform package" in caplog.text + + +def test_run_miniterm_no_stacktrace_analyzer( + caplog: pytest.LogCaptureFixture, +) -> None: + """Platforms without a stacktrace analyzer log an info and stream anyway.""" + mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_BK72XX} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + with ( + caplog.at_level("INFO", logger="esphome.platform_hooks"), + patch("serial.Serial", return_value=mock_serial), + ): + result = run_miniterm(config, "/dev/ttyUSB0", args) + + assert result == 0 + assert "Stacktrace analysis is unavailable" in caplog.text + + def test_run_miniterm_different_chunks_different_timestamps( capfd: CaptureFixture[str], ) -> None: @@ -5797,7 +6009,9 @@ def test_run_miniterm_backtrace_state_maintained() -> None: mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END]) - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + # An esp8266 dump on an esp8266 session; the platform-scoped gate + # would rightly never resolve esp32's decoder for these lines. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -5823,7 +6037,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None: with ( patch("serial.Serial", return_value=mock_serial), patch.object( - esp32, + esp8266, "process_stacktrace", side_effect=track_backtrace_state, ), @@ -5850,6 +6064,38 @@ def test_run_miniterm_backtrace_state_maintained() -> None: assert backtrace_states[3][1] is True +def test_run_miniterm_decoder_failure_keeps_streaming( + caplog: pytest.LogCaptureFixture, +) -> None: + """A decoder exception must not kill serial streaming. + + This is the serial path's gain from sharing LogLineProcessor: before + the lift a decoder exception propagated out of the read loop. + """ + chunk = b"PC: 0x4010496e\r\nBT0: 0x4010496e\r\nstill streaming\r\n" + mock_serial = MockSerial([chunk, MOCK_SERIAL_END]) + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + config = { + CONF_LOGGER: { + CONF_BAUD_RATE: 115200, + "deassert_rts_dtr": False, + } + } + args = MockArgs() + + decoder = Mock(side_effect=EsphomeError("no idedata")) + with ( + patch("serial.Serial", return_value=mock_serial), + patch.object(esp32, "process_stacktrace", decoder), + ): + run_miniterm(config, "/dev/ttyUSB0", args) + + # The failure is contained and latched; streaming continued to EOF. + assert decoder.call_count == 1 + assert "Crash trace decoding unavailable" in caplog.text + + def test_run_miniterm_handles_empty_reads( capfd: CaptureFixture[str], ) -> None: @@ -6196,16 +6442,14 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None: extracted_yaml = tmp_path / "extracted" / "device.yaml" with ( - patch("esphome.bundle.is_bundle_path", return_value=True) as mock_is_bundle, patch( "esphome.bundle.prepare_bundle_for_compile", return_value=extracted_yaml, ) as mock_prepare, - patch("esphome.__main__.read_config", return_value=None), + patch("esphome.config.read_config", return_value=None), ): result = run_esphome(["esphome", "compile", str(bundle_path)]) - mock_is_bundle.assert_called_once() mock_prepare.assert_called_once_with(bundle_path) # read_config returns None → exit code 2 assert result == 2 @@ -6217,13 +6461,11 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None: yaml_file.write_text("esphome:\n name: test\n") with ( - patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle, patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare, - patch("esphome.__main__.read_config", return_value=None), + patch("esphome.config.read_config", return_value=None), ): result = run_esphome(["esphome", "compile", str(yaml_file)]) - mock_is_bundle.assert_called_once() mock_prepare.assert_not_called() assert result == 2 @@ -6247,13 +6489,33 @@ def test_run_esphome_skip_external_update_per_command( yaml_file = tmp_path / "device.yaml" yaml_file.write_text("esphome:\n name: test\n") - with patch("esphome.__main__.read_config", return_value=None) as mock_read: + with patch("esphome.config.read_config", return_value=None) as mock_read: run_esphome(["esphome", command, str(yaml_file)]) mock_read.assert_called_once() assert mock_read.call_args.kwargs["skip_external_update"] is expected_skip +@pytest.mark.parametrize( + ("argv_extra", "expected"), + [(["--no-defaults"], True), ([], False)], +) +def test_run_esphome_snapshot_user_config_only_for_no_defaults( + tmp_path: Path, argv_extra: list[str], expected: bool +) -> None: + """read_config is invoked with snapshot_user_config=True only when the + config command is run with --no-defaults; otherwise the expensive deep + copy is skipped.""" + yaml_file = tmp_path / "device.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + with patch("esphome.config.read_config", return_value=None) as mock_read: + run_esphome(["esphome", "config", str(yaml_file), *argv_extra]) + + mock_read.assert_called_once() + assert mock_read.call_args.kwargs["snapshot_user_config"] is expected + + def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" @@ -6405,6 +6667,23 @@ def test_parse_args_logs_states() -> None: assert args.states is True +def test_parse_args_argcomplete_only_runs_when_completing() -> None: + """Only import and invoke argcomplete when _ARGCOMPLETE is set. + + The shell-completion machinery sets _ARGCOMPLETE when it invokes the + CLI; a normal invocation must skip the import entirely so every + esphome subprocess (e.g. parallel dashboard uploads) avoids paying + for it. + """ + fake_argcomplete = MagicMock() + with ( + patch.dict(os.environ, {"_ARGCOMPLETE": "1"}), + patch.dict(sys.modules, {"argcomplete": fake_argcomplete}), + ): + parse_args(["esphome", "version"]) + fake_argcomplete.autocomplete.assert_called_once() + + def test_should_subscribe_states_default() -> None: """Test that states are shown by default when nothing is set.""" from esphome.__main__ import _should_subscribe_states @@ -6451,7 +6730,7 @@ def test_should_subscribe_states_no_flag_overrides_env() -> None: assert _should_subscribe_states(args) is False -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_command_run_passes_no_states_to_show_logs( mock_run_logs: Mock, ) -> None: @@ -6489,7 +6768,7 @@ def test_command_run_passes_no_states_to_show_logs( ) -@patch("esphome.components.api.client.run_logs") +@patch("esphome.api_client.run_logs") def test_command_run_defaults_subscribe_states_true( mock_run_logs: Mock, ) -> None: @@ -6591,3 +6870,168 @@ def test_command_idedata_esp_idf_no_build_errors() -> None: result = command_idedata(MagicMock(), CORE.config) assert result == 1 + + +@pytest.mark.skipif( + os.name != "posix", reason="serial permission checks are posix-only" +) +def test_check_permissions_missing_port() -> None: + """A nonexistent serial port raises the does-not-exist guidance.""" + with ( + patch("os.access", return_value=False), + pytest.raises(EsphomeError, match="serial port does not exist"), + ): + check_permissions("/dev/ttyUSB99") + + +@pytest.mark.skipif( + os.name != "posix", reason="serial permission checks are posix-only" +) +def test_check_permissions_unreadable_port() -> None: + """An existing but unreadable serial port raises the dialout guidance.""" + with ( + patch("os.access", side_effect=lambda _path, mode: mode == os.F_OK), + pytest.raises(EsphomeError, match="read or write permission"), + ): + check_permissions("/dev/ttyUSB99") + + +def _make_checkout(root: Path) -> Path: + """Create a directory that looks like an esphome checkout.""" + (root / "esphome").mkdir(parents=True) + (root / "esphome" / "__main__.py").write_text("", encoding="utf-8") + return root + + +def test_warn_source_tree_mismatch_warns_for_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in a checkout other than the one being run warns.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + assert "main" in caplog.text + + +def test_warn_source_tree_mismatch_silent_in_same_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in the tree that is running is the normal case and is silent.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_outside_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An ordinary install run from a config directory never warns.""" + running = _make_checkout(tmp_path / "main") + config_dir = tmp_path / "configs" + config_dir.mkdir() + monkeypatch.chdir(config_dir) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_in_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A subdirectory of the running tree resolves to that tree, so no warning.""" + tree = _make_checkout(tmp_path / "main") + subdir = tree / "esphome" / "components" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The samefile() fallback must still warn when the trees really differ.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + + +def test_warn_source_tree_mismatch_silent_when_cwd_is_gone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A deleted working directory must not turn the diagnostic into a traceback.""" + running = _make_checkout(tmp_path / "main") + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_filenotfound() -> Path: + raise FileNotFoundError("cwd is gone") + + monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound)) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_falls_back_when_stat_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """If samefile() cannot stat, fall back to comparing the paths.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + # Same tree, so the path comparison still finds them equal and stays silent + assert not caplog.text diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 8a5f4377d3..0a6bddc280 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -201,6 +201,24 @@ class TestCheckAndInstall: assert mock_nrf52_ops.download_from_mirrors.call_count == 2 assert mock_nrf52_ops.archive_extract_all.call_count == 2 + def test_framework_clone_is_shallow( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Both the manifest repository and every project are fetched at depth 1.""" + _mark_venv_ready(nrf52_dirs.python_env) + + check_and_install() + + init_cmd, update_cmd = ( + call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2] + ) + assert "init" in init_cmd + assert "-o=--depth=1" in init_cmd + assert "update" in update_cmd + assert "--fetch-opt=--depth=1" in update_cmd + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 9b738ebc81..9091b429f6 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -13,9 +13,9 @@ from esphome.components.zephyr.const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, - KEY_USER, KEY_ZEPHYR, ) import esphome.config_validation as cv @@ -53,9 +53,9 @@ def _setup_nrf52_core( KEY_BOOTLOADER: bootloader, KEY_PRJ_CONF: {}, KEY_OVERLAY: {"": ""}, + KEY_OVERLAY_BUILDER: [], KEY_EXTRA_BUILD_FILES: {}, KEY_PM_STATIC: [], - KEY_USER: {}, KEY_KCONFIG: "", } diff --git a/tests/unit_tests/test_platform_hooks.py b/tests/unit_tests/test_platform_hooks.py new file mode 100644 index 0000000000..97b25e7c0f --- /dev/null +++ b/tests/unit_tests/test_platform_hooks.py @@ -0,0 +1,186 @@ +"""Guard the platform CLI-hook registry in ``esphome.platform_hooks``. + +The registry lets the logs/upload fast path skip importing platform +packages that don't provide a hook; these tests fail when a platform +gains or loses a hook without the registry being updated, and pin down +that the fast path really avoids the import. +""" + +from __future__ import annotations + +import importlib +import logging +from unittest.mock import Mock + +import pytest + +from esphome import platform_hooks +from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform + + +def test_no_unregistered_platform_exposes_a_hook() -> None: + """Every platform hook the packages expose must be registered. + + Behavioural on purpose: a hook added as a re-export, an assignment, + or an ``async def`` is invisible to source scanning but very visible + to ``hasattr``, and an unregistered hook is silently never called. + The registered direction is covered by + test_every_registered_pair_resolves below. + """ + for platform in frozenset(Platform): + module = importlib.import_module(f"esphome.components.{platform}") + for hook, registered in platform_hooks.PLATFORM_HOOKS.items(): + if hasattr(module, hook): + assert platform in registered, ( + f"{platform} exposes {hook} but is not registered for it. " + "Update esphome/platform_hooks.py." + ) + + +def test_registered_platform_resolves_hook() -> None: + hook = platform_hooks.get_platform_hook(PLATFORM_ESP32, "process_stacktrace") + from esphome.components import esp32 + + assert hook is esp32.process_stacktrace + + +def test_every_registered_pair_resolves() -> None: + """Each registered platform must actually expose the hook at runtime. + + Text scanning can miss re-exports or decorated definitions; this is + the behavioural check for the direction that matters when the CLI + runs. + """ + for hook, platforms in platform_hooks.PLATFORM_HOOKS.items(): + for platform in platforms: + assert callable(platform_hooks.get_platform_hook(platform, hook)), ( + f"{platform} is registered for {hook} but does not expose it" + ) + + +def test_external_platform_falls_back_to_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Out-of-tree target platforms keep working via the dynamic probe.""" + module = type("FakePlatform", (), {"show_logs": staticmethod(lambda *a: True)}) + imported: list[str] = [] + + def fake_import(name: str): + imported.append(name) + return module + + monkeypatch.setattr(platform_hooks, "import_module", fake_import) + hook = platform_hooks.get_platform_hook("my_external_chip", "show_logs") + assert hook is module.show_logs + assert imported == ["esphome.components.my_external_chip"] + + +def test_external_platform_missing_module_degrades( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A warm-cache run may not have the external package importable. + + Skipping a behavior-changing hook is visible at warning; losing + stacktrace decoding is cosmetic and stays at debug. + """ + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock( + side_effect=ModuleNotFoundError( + "not found", name="esphome.components.my_external_chip" + ) + ), + ) + assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None + assert "not importable" in caplog.text + assert any(r.levelname == "WARNING" for r in caplog.records) + + caplog.clear() + assert ( + platform_hooks.get_platform_hook("my_external_chip", "process_stacktrace") + is None + ) + assert not any(r.levelname == "WARNING" for r in caplog.records) + + +def test_external_platform_without_hook_logs_debug( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The common no-hook case stays quiet but diagnosable.""" + caplog.set_level("DEBUG", logger="esphome.platform_hooks") + module = type("ExternalPlatform", (), {}) # imports fine, no hook + monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module)) + assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None + assert "does not expose" in caplog.text + assert not any(r.levelname == "WARNING" for r in caplog.records) + + +def test_stale_registry_entry_warns( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vendored tree where a registered hook vanished must say so.""" + module = type("StalePlatform", (), {}) # registered but no hook + monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module)) + assert platform_hooks.get_platform_hook("nrf52", "show_logs") is None + assert "no longer exposes it" in caplog.text + + +def test_external_platform_broken_dependency_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing dependency inside the external package must surface.""" + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=ModuleNotFoundError("not found", name="some_missing_dep")), + ) + with pytest.raises(ModuleNotFoundError, match="not found"): + platform_hooks.get_platform_hook("my_external_chip", "show_logs") + + +def test_lookup_miss_does_not_import_platform_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The whole point: probing a platform without hooks must not import it.""" + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=AssertionError("platform package imported on registry miss")), + ) + assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None + + +def test_get_stacktrace_handler_resolves_registered_platform() -> None: + hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) + from esphome.components import esp32 + + assert hook is esp32.process_stacktrace + + +def test_get_stacktrace_handler_reports_missing_analyzer( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO", logger="esphome.platform_hooks") + assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None + assert "no compatible analyzer" in caplog.text + # A capability gap is ordinary; it must not warn. + assert not any(r.levelno >= logging.WARNING for r in caplog.records) + + +def test_get_stacktrace_handler_reports_import_failure( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr( + platform_hooks, + "import_module", + Mock(side_effect=ImportError("broken install")), + ) + assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None + assert "failed to import: broken install" in caplog.text + # A broken install is a real breakage; it must warn, not inform. + assert any(r.levelno == logging.WARNING for r in caplog.records) diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index c0a0c678db..0eede78656 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -10,13 +10,14 @@ from pathlib import Path import pytest -from esphome.core import Library +from esphome.core import EsphomeError, Library import esphome.platformio.library as lib from esphome.platformio.library import ( ConvertedLibrary, GitSource, InvalidLibrary, LibraryBackend, + LocalSource, Source, URLSource, _resolve_registry_version, @@ -87,6 +88,68 @@ def test_gitsource_str_includes_ref_when_present(): assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" +def test_source_root_defaults_to_build_dir() -> None: + # Registry/git sources are read from where they were downloaded. + build = Path("/some/build/dir") + assert URLSource("http://x/y.tar.gz").source_root(build) == build + assert GitSource("http://x/y.git", None).source_root(build) == build + + +def test_converted_library_source_dir_defaults_to_path() -> None: + c = ConvertedLibrary("x", "1.0", source=None) + c.path = Path("/build") + assert c.source_dir == Path("/build") # no source_path set -> build dir + c.source_path = Path("/user/lib") + assert c.source_dir == Path("/user/lib") + + +def test_convert_libraries_local_missing_manifest_is_esphome_error( + setup_core: Path, +) -> None: + # A local directory that has no library.json/library.properties is user + # input, so it must surface as a clean EsphomeError (named at the user's dir). + src = setup_core / "not_a_lib" + src.mkdir() # exists, but no manifest + # match= is a regex; a Windows path has backslashes, so match a literal + # fragment and check the directory is named separately. + with pytest.raises(EsphomeError, match="missing library.json") as excinfo: + convert_libraries([Library("Foo", None, src.as_uri())], _backend()) + assert str(src) in str(excinfo.value) + + +def test_localsource_download_missing_dir_raises(tmp_path: Path) -> None: + # EsphomeError so the CLI prints it cleanly instead of a traceback. + with pytest.raises(EsphomeError, match="does not exist"): + LocalSource(str(tmp_path / "nope")).download("mylib") + + +def test_localsource_str() -> None: + assert str(LocalSource("/tmp/lib")) == "file:///tmp/lib" + # A relative path can't form a file:// URI; fall back rather than raise. + assert str(LocalSource("rel/lib")) == "file://rel/lib" + + +def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None: + # Nothing is copied: download() returns an empty build dir (for generated + # files), and source_root() points back at the user's directory. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text("{}") + (src / "src" / "a.cpp").write_text("int a;") + + source = LocalSource(str(src)) + out = source.download("mylib", salt="s", namespace="ns") + + assert out.is_dir() + assert list(out.iterdir()) == [] # no sources copied in + assert out != src + assert source.source_root(out) == src + + # salt/namespace change the cache path. + plain = LocalSource(str(src)).download("mylib") + assert plain != out + + def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) dl_calls: list[list[str]] = [] @@ -317,6 +380,140 @@ def test_convert_libraries_url_in_name_resolves_as_git( assert source.ref is None +def test_convert_libraries_file_url_resolves_as_local( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A "Name=file://" library points at an on-disk folder: it resolves as a + # local source read in place (no copy), and the registry is never consulted. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "TeslaBLE"})) + (src / "src" / "tesla.cpp").write_text("int foo() { return 1; }") + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + # as_uri() produces a valid file:// URL on every platform (file:///tmp/... on + # POSIX, file:///C:/... on Windows). + top = convert_libraries([Library("TeslaBLE", None, src.as_uri())], _backend()) + + assert [c.name for c in top] == ["TeslaBLE"] + assert top[0].data["name"] == "TeslaBLE" + assert isinstance(top[0].source, LocalSource) + # Sources are read in place from the user's dir; the build dir stays separate + # and holds no copied sources. + assert top[0].source_path == src + assert top[0].path != src + assert not (top[0].path / "src").exists() + + +def test_convert_libraries_local_overrides_registry_version( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # The same library requested both from the registry (with a version) and as + # a local directory resolves to the local source, with a warning that the + # registry version was dropped. + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "TeslaBLE"})) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [ + Library("TeslaBLE", "1.0.0", None), + Library("TeslaBLE", None, src.as_uri()), + ], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert "local source" in caplog.text + + +def test_convert_libraries_versionless_registry_and_local_warns( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # A bare cg.add_library("Foo") (versionless registry, the common case) that + # collides with a local directory of the same key must still warn -- the + # registry spec is dropped and the local folder silently takes over. + src = setup_core / "foo" + src.mkdir() + (src / "library.json").write_text(json.dumps({"name": "Foo"})) + + def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None: + raise AssertionError(f"registry consulted for {owner}/{pkgname}") + + monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [Library("Foo", None, None), Library("Foo", None, src.as_uri())], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert "a registry package" in caplog.text + + +def test_convert_libraries_two_local_dirs_warns( + setup_core: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + # The same key pointed at two local directories warns and uses the last one. + dir_a = setup_core / "a" + dir_b = setup_core / "b" + for d in (dir_a, dir_b): + d.mkdir() + (d / "library.json").write_text(json.dumps({"name": "Foo"})) + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries( + [ + Library("Foo", None, dir_a.as_uri()), + Library("Foo", None, dir_b.as_uri()), + ], + _backend(), + ) + + assert isinstance(top[0].source, LocalSource) + assert top[0].source_path == dir_b # the last one wins + assert "two local directories" in caplog.text + + +@pytest.mark.parametrize("local_first", [True, False]) +def test_convert_libraries_git_and_local_same_key_warns( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + local_first: bool, +) -> None: + # A key requested as both a git source and a local directory warns and uses + # git, whichever order they appear in. The git URL basename matches the local + # custom name so both map to the key "Foo". + _patch_download_with_manifests(monkeypatch, tmp_path, {"Foo": {"name": "Foo"}}) + git = Library("X", None, "https://host/Foo") + local = Library("Foo", None, "file:///abs/foo") + libs = [local, git] if local_first else [git, local] + + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + top = convert_libraries(libs, _backend()) + + assert isinstance(top[0].source, GitSource) + assert "using the git source" in caplog.text + + def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): # A dependency that declares an incompatible platform is skipped (the # top-level library still builds). diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py new file mode 100644 index 0000000000..f375aa457a --- /dev/null +++ b/tests/unit_tests/test_platformio_runner.py @@ -0,0 +1,93 @@ +"""Tests for esphome.platformio.runner.""" + +from __future__ import annotations + +from collections.abc import Callable +import io +import sys +from types import ModuleType + +import pytest + +from esphome.platformio import runner + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int] +) -> io.BytesIO: + """Point ``runner.main()`` at a fake PlatformIO with a fake stdout. + + The real ``main`` patches PlatformIO internals and then hands control to + it; both are stubbed out so only the stream wrapping is exercised. The + fake stdout is block buffered like a pipe, so the caller can see what + actually left the wrapper. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "argv", ["pio", "run"]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(runner, "patch_structhash", lambda: None) + monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + + platformio = ModuleType("platformio") + platformio_main = ModuleType("platformio.__main__") + platformio_main.main = pio_main # type: ignore[attr-defined] + platformio.__main__ = platformio_main # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "platformio", platformio) + monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main) + + return buf + + +def test_main_drains_a_partial_line_on_a_clean_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A build ending mid line still shows that line.""" + + def pio_main() -> int: + print("Linking .pioenvs/firmware.elf\n", end="") + print("Building took 12.4 seconds", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue().decode("utf-8") == ( + "Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n" + ) + + +def test_main_drains_when_platformio_exits_early( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaving through ``sys.exit`` still drains, because it runs in a finally.""" + + def pio_main() -> int: + print("*** [.pioenvs/firmware.elf] Error 1", end="") + sys.exit(1) + + buf = _prepare_main(monkeypatch, pio_main) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 1 + assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n" + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + + def pio_main() -> int: + # Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator. + print("Verbose mode can be enabled via `-v, --verbose` option", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue() == b"" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 013030d38f..02c11b4e45 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM from esphome.core import CORE, EsphomeError from esphome.platformio import runner, toolchain -from esphome.util import FlashImage +from esphome.util import ESP32_ARDUINO_ENV, FlashImage def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -278,15 +279,116 @@ def test_run_idedata_raises_on_no_json( def test_run_idedata_raises_on_invalid_json( setup_core: Path, mock_run_platformio_cli_run: Mock ) -> None: - """Test _run_idedata raises on malformed JSON.""" + """Malformed JSON is the environment (garbage stdout), so it must + surface as EsphomeError and get the recompile hint downstream. + """ config = {"name": "test"} mock_run_platformio_cli_run.return_value = '{"invalid": json"}' - # The ValueError from json.loads is re-raised - with pytest.raises(ValueError): + with pytest.raises(EsphomeError): toolchain._run_idedata(config) +def test_run_idedata_raises_on_launch_failure( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """A failed platformio launch returns its exit code as an int; that + must surface as EsphomeError, not a TypeError from re.search. + """ + config = {"name": "test"} + mock_run_platformio_cli_run.return_value = 1 + + with pytest.raises(EsphomeError): + toolchain._run_idedata(config) + + +def test_idedata_missing_prog_path_raises_esphome_error(setup_core: Path) -> None: + """A stale cached idedata JSON without prog_path is the build tree's + fault; it must surface as EsphomeError, not a KeyError. + """ + with pytest.raises(EsphomeError): + _ = toolchain.IDEData({}).firmware_elf_path + + +def test_idedata_missing_flash_image_field_raises_esphome_error( + setup_core: Path, +) -> None: + """A cached idedata whose flash image entries lost a field must + classify as an environment error too, not a raw KeyError. + """ + idedata = toolchain.IDEData({"extra": {"flash_images": [{"offset": "0x1000"}]}}) + with pytest.raises(EsphomeError): + _ = idedata.extra_flash_images + + +def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: + """A section that is null instead of absent must classify the same + as a missing key instead of escaping as TypeError. + """ + with pytest.raises(EsphomeError): + _ = toolchain.IDEData({"extra": None}).extra_flash_images + + +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", "1"), + ("esp32", "esp-idf", None), + ("esp8266", "arduino", None), + ], +) +def test_run_platformio_cli_flags_an_esp32_arduino_build( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + framework: str, + expected: str | None, +) -> None: + """Only an ESP32 Arduino build is flagged, and an inherited one is cleared.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get(ESP32_ARDUINO_ENV) == expected + # Only the subprocess env is touched; ours is left as it was. + assert os.environ[ESP32_ARDUINO_ENV] == "1" + + +def test_run_platformio_cli_ignores_an_inherited_flag_without_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """An inherited flag must not end up answering for CORE.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data.pop(KEY_CORE, None) + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert ESP32_ARDUINO_ENV not in env + + +def test_run_platformio_cli_raises_on_a_half_filled_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A CORE set up but left incomplete must surface, not fall back.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = {} + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_process.return_value = 0 + with pytest.raises(KeyError): + toolchain.run_platformio_cli("test", "arg") + + def test_run_platformio_cli_sets_environment_variables( setup_core: Path, mock_run_external_process: Mock ) -> None: @@ -322,6 +424,149 @@ def test_run_platformio_cli_sets_environment_variables( assert "arg" in args +def test_ccache_env_enabled_by_default(setup_core: Path) -> None: + """Ccache is enabled when the binary is on PATH and no override is set.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + assert env["CCACHE_DIR"].endswith("platformio-ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + # Nothing may leak into os.environ: a later ESP-IDF build in the same + # process would otherwise skip its own ccache defaults. + assert "CCACHE_BASEDIR" not in os.environ + assert "ESPHOME_CCACHE_ENABLE" not in os.environ + + +def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: + """Ccache stays off when the binary is not on PATH.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value=None), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_opt_out(setup_core: Path) -> None: + """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: + """A truthy override value is normalized to "1" for the build scripts.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), + patch.object(toolchain.shutil, "which", return_value=None), + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + + +def test_ccache_env_respects_user_values_and_refreshes_basedir( + setup_core: Path, +) -> None: + """User CCACHE_* values win, but CCACHE_BASEDIR follows the build dir.""" + user_env = { + "CCACHE_DIR": "/custom/cache", + "CCACHE_BASEDIR": "/stale/other-device", + } + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, user_env, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + env = toolchain._ccache_env() + + # CCACHE_DIR is not returned, so the user's os.environ value applies in + # the subprocess; CCACHE_BASEDIR is always refreshed to the build dir. + assert "CCACHE_DIR" not in env + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + + +def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """The ccache settings reach the subprocess env without touching os.environ.""" + CORE.build_path = str(setup_core / "build" / "test") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) + assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "CCACHE_BASEDIR" not in os.environ + + +def test_ccache_env_requires_build_path(setup_core: Path) -> None: + """Enabling ccache without a build path fails loudly.""" + CORE.build_path = None + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + pytest.raises(ValueError, match="CORE.build_path must be set"), + ): + toolchain._ccache_env() + + +def test_run_platformio_cli_merges_caller_env( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A caller-supplied env is the base and gains the ccache settings.""" + CORE.build_path = str(setup_core / "build" / "test") + + with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli( + "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} + ) + + env = mock_run_external_process.call_args[1]["env"] + assert env["CUSTOM_VAR"] == "1" + # The normalized enable flag still lands in the subprocess env. + assert "ESPHOME_CCACHE_ENABLE" in env + + +def test_copy_ccache_script(setup_core: Path) -> None: + """The shared ccache pre-script is copied into the build dir.""" + CORE.build_path = setup_core / "build" / "test" + + toolchain.copy_ccache_script() + + dest = setup_core / "build" / "test" / "ccache.py" + source = Path(toolchain.__file__).parent / "ccache.py.script" + assert dest.read_text() == source.read_text() + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ @@ -375,7 +620,10 @@ def test_run_platformio_cli_strips_win_long_path_prefix( ) with ( - patch.dict(os.environ, {}, clear=False), + # Pin ccache off: patching sys.platform to win32 (sys is a singleton, + # so the stdlib sees it too) would send shutil.which down the Windows + # code path, which crashes on a POSIX host. + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False), patch("esphome.platformio.toolchain.sys.platform", "win32"), patch("esphome.platformio.toolchain.sys.executable", prefixed_exe), ): diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py new file mode 100644 index 0000000000..d3e5fac36a --- /dev/null +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -0,0 +1,239 @@ +"""Tests to verify preference and entity key hash values remain stable. + +These tests ensure the hash algorithms do NOT change, as any change would cause +users to lose stored preferences (calibration values, restore states, etc.) on +firmware upgrades, or break entity state routing to API clients. + +Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): +1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). + Existing devices have preferences stored under keys derived from it; slot-based + backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. +2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). + Sent to API clients and used as the preference key base on key-lookup backends. + +DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, +the change breaks backward compatibility and will cause data loss. +""" + +import pytest + +from esphome.helpers import ( + FNV1_OFFSET_BASIS, + FNV1_PRIME, + fnv1_hash_name, + fnv1_hash_object_id, +) + +# ============================================================================= +# Test: fnv1_hash_object_id produces stable hashes for entity names +# ============================================================================= + + +@pytest.mark.parametrize( + ("entity_name", "expected_object_id_hash"), + [ + # ===================================================================== + # Core entity types - these names appear in many ESPHome configurations + # ===================================================================== + # Basic single-word names + ("Light", 0x735CF023), + ("Switch", 0xBEDF78E5), + ("Sensor", 0x75E61B1B), + ("Fan", 0x468F6780), + ("Climate", 0xAA22FD4A), + ("Cover", 0xA630D0A2), + ("Lock", 0x1D2FD708), + ("Valve", 0x25ED5F65), + ("Button", 0x3A42C455), + ("Number", 0xB900E22A), + ("Select", 0x556391B5), + ("Text", 0xB12BFA38), + # Multi-word names (spaces become underscores, lowercase) + ("Living Room Light", 0xC6F81EC9), + ("Kitchen Switch", 0xC63C0F6E), + ("Temperature Sensor", 0x16AF55B6), + ("Garage Door Cover", 0x685E5281), + ("Bedroom Fan", 0x21AB1DED), + ("Front Door Lock", 0xB9BEF8E1), + # Already snake_case names (should hash same as space-separated) + ("living_room_light", 0xC6F81EC9), # Same as "Living Room Light" + ("kitchen_switch", 0xC63C0F6E), # Same as "Kitchen Switch" + # Names with numbers + ("Sensor 1", 0x99828E4B), + ("Relay 2", 0x6FFEF2FB), + ("Zone 10", 0xFD83AA95), + # Names with special characters (become underscores) + ("AC Unit", 0x336C6886), + ("WiFi Signal", 0x2FA52175), + ("CO2 Level", 0x31049870), + # Mixed case handling + ("mySwitch", 0x9AA10553), + ("MySwitch", 0x9AA10553), # Same as lowercase + ("MYSWITCH", 0x9AA10553), # Same as lowercase + # ===================================================================== + # Edge cases + # ===================================================================== + # Empty name (hashes to the FNV-1 offset basis since no chars processed) + ("", 0x811C9DC5), + # Single character + ("a", 0x050C5D7E), + ("A", 0x050C5D7E), # Same after lowercase + ("1", 0x050C5D2E), + ("_", 0x050C5D40), + # Names that differ only in case (should hash identically) + ("test", 0xBC2C0BE9), + ("Test", 0xBC2C0BE9), + ("TEST", 0xBC2C0BE9), + # Names that differ only in spaces vs underscores (should hash identically) + ("foo bar", 0x3AE35AA1), + ("foo_bar", 0x3AE35AA1), + ("Foo Bar", 0x3AE35AA1), + ("FOO_BAR", 0x3AE35AA1), + # Non-ASCII names (sanitized per code point, one underscore per character) + ("äöü", 0x10028B12), + ("温度", 0x3276CB9F), + ("Température", 0x965698F3), + # ===================================================================== + # Real-world component entity names from ESPHome codebase + # ===================================================================== + # From fan.cpp - FanRestoreState + ("Ceiling Fan", 0x640DEF00), + # From climate.cpp - ClimateRestoreState + ("HVAC", 0xDD68438B), + ("Thermostat", 0x30A5B7C6), + # From light/light_state.cpp + ("LED Strip", 0x2A068423), + ("Dimmable Light", 0xD70393F3), + # From cover/cover.cpp + ("Garage Door", 0x53987A5D), + ("Window Blind", 0x851291A5), + # From switch/switch.cpp + ("Relay", 0xD3A92FE4), + ("Power Switch", 0x5C4A47B3), + # From number/automation.cpp + ("Brightness", 0xF46E252C), + ("Volume", 0x8FFEBE43), + # From template datetime entities + ("Wake Time", 0xEE612B53), + ("Schedule Date", 0xF538C8DD), + ], +) +def test_entity_object_id_hash_stability( + entity_name: str, expected_object_id_hash: int +) -> None: + """Verify fnv1_hash_object_id produces stable hashes for entity names. + + CRITICAL: These expected values MUST NOT CHANGE. Existing devices have + preferences stored under keys derived from this legacy hash; changing it + breaks the old-to-new key migration and loses stored preferences. + """ + actual = fnv1_hash_object_id(entity_name) + assert actual == expected_object_id_hash, ( + f"Hash for '{entity_name}' changed from {expected_object_id_hash:#010x} to {actual:#010x}. " + f"This will cause users to lose stored preferences!" + ) + + +# ============================================================================= +# Test: Legacy preference key computation formula +# ============================================================================= + + +def compute_legacy_preference_key( + entity_name: str, version: int = 0, device_id: int = 0 +) -> int: + """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. + + This is the key existing devices have data stored under. Slot-based backends + (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the + migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + """ + object_id_hash = fnv1_hash_object_id(entity_name) + preference_hash = object_id_hash ^ device_id + key = preference_hash ^ version + return key & 0xFFFFFFFF + + +# Restore state version constants from ESPHome components +# These MUST match the RESTORE_STATE_VERSION values in the C++ code +FAN_RESTORE_STATE_VERSION = 0x71700ABA # From fan/fan.cpp +CLIMATE_RESTORE_STATE_VERSION = 0x848EA6AD # From climate/climate.cpp + + +@pytest.mark.parametrize( + ("entity_name", "version", "device_id", "expected_key"), + [ + # No version, main device (key equals the plain object_id hash) + ("Test Sensor", 0, 0, 0x5D74FA46), + ("Light", 0, 0, 0x735CF023), + # Restore state versions on the main device + ("Ceiling Fan", FAN_RESTORE_STATE_VERSION, 0, 0x157DE5BA), + ("HVAC", CLIMATE_RESTORE_STATE_VERSION, 0, 0x59E6E526), + # Sub-devices: same entity name on different devices gets different keys + ("Light", 0, 1, 0x735CF022), + ("Fan", FAN_RESTORE_STATE_VERSION, 0xABCD, 0x37FFC6F7), + ], +) +def test_legacy_preference_key_computation( + entity_name: str, version: int, device_id: int, expected_key: int +) -> None: + """Verify legacy preference key computation matches expected values. + + This test ensures the formula doesn't change, which would break both slot-based + preference storage and the migration source keys on key-lookup backends. + """ + actual_key = compute_legacy_preference_key(entity_name, version, device_id) + + assert actual_key == expected_key, ( + f"Preference key for '{entity_name}' (version={version:#x}, device_id={device_id}) " + f"changed from {expected_key:#010x} to {actual_key:#010x}. " + f"This will cause users to lose stored preferences!" + ) + + +# ============================================================================= +# Test: fnv1_hash_name produces stable entity keys (raw name, UTF-8 bytes) +# ============================================================================= + + +@pytest.mark.parametrize( + ("entity_name", "expected_key"), + [ + # ASCII names + ("Temperature Sensor", 0x801C3665), + ("LED Strip", 0xD5C7B082), + ("Garage Door", 0x2D70E086), + ("Relay", 0x565177C4), + # Raw names are case and space sensitive, unlike the old object_id hash + ("temperature sensor", 0xF9F431E5), + # Non-ASCII names hash their UTF-8 bytes and stay distinct + ("Датчик открытия", 0x001861C1), + ("温度", 0x8EDF61C9), + ("Température", 0x531A74AA), + # Empty name hashes to the FNV-1 offset basis + ("", 0x811C9DC5), + ], +) +def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: + """Verify fnv1_hash_name produces stable entity keys. + + CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to + API clients and is the new preference key base; changing the algorithm + would break state routing and lose stored preferences. + Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + """ + actual = fnv1_hash_name(entity_name) + assert actual == expected_key, ( + f"Entity key for '{entity_name}' changed from {expected_key:#010x} to {actual:#010x}. " + f"This breaks state routing and stored preferences!" + ) + + +def test_fnv1_hash_name_matches_utf8_byte_hash() -> None: + """Verify fnv1_hash_name hashes the UTF-8 encoded bytes of the name.""" + name = "Température 温度" + hash_value = FNV1_OFFSET_BASIS + for byte in name.encode("utf-8"): + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF + hash_value ^= byte + assert fnv1_hash_name(name) == hash_value diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 7862c268ca..16294a3813 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -4,12 +4,13 @@ from __future__ import annotations import re import socket -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr import pytest +from esphome.async_thread import AsyncDispatchTimeout from esphome.core import EsphomeError from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver @@ -116,20 +117,17 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: """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 - + # Patch run_async inside esphome.resolver so we never actually start a + # thread and can simulate the wait timing out. with ( - patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner), - patch("esphome.resolver.hr.async_resolve_host"), + patch( + "esphome.resolver.run_async", side_effect=AsyncDispatchTimeout + ) as mock_run, pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")), ): AsyncResolver(["test.local"], 6053).resolve() - fake_runner.start.assert_called_once() + mock_run.assert_called_once_with(ANY, timeout=RESOLVE_TIMEOUT + 1.0) def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: diff --git a/tests/unit_tests/test_stacktrace.py b/tests/unit_tests/test_stacktrace.py new file mode 100644 index 0000000000..0b11ac3f83 --- /dev/null +++ b/tests/unit_tests/test_stacktrace.py @@ -0,0 +1,565 @@ +"""Tests for esphome.stacktrace.""" + +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path +import re +from unittest.mock import Mock, patch + +from hypothesis import given, settings +from hypothesis.strategies import data as st_data, from_regex +import pytest + +from esphome import stacktrace +from esphome.const import ( + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_NRF52, + PLATFORM_RP2, +) +from esphome.core import EsphomeError + +CONFIG = {"esphome": {"name": "test"}} + +# Real dump lines per registered platform; the gate must fire on each. +# "addresses" are decoder-consumed dump lines, "state_markers" open a +# decoder's dump region, and "extra_triggers" fire the gate without a +# decoder pattern (the stored-dump banner). A new decoder declares its +# lines here so drift fails in CI instead of in the field. +CRASH_SAMPLES: dict[str, dict[str, list[str]]] = { + PLATFORM_ESP32: { + "state_markers": [], + "extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"], + "addresses": [ + "Backtrace: 0x400d1a2c:0x3ffb1f60 0x400d2a3c:0x3ffb1f80", + "PC : 0x400d1a2c PS : 0x00060330", + "EXCVADDR: 0x40001234", + "MEPC : 0x40380abc RA : 0x40380def", + "MTVAL : 0x40000123", + "last failed alloc call: 40201234(512)", + "BT0: 0x40104960", + ], + }, + PLATFORM_ESP8266: { + "state_markers": [">>>stack>>>"], + "extra_triggers": ["*** CRASH DETECTED ON PREVIOUS BOOT ***"], + "addresses": [ + "epc1=0x40201234 epc2=0x00000000 excvaddr=0x40001234", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + "PC : 40201234", + "EXCVADDR: 0x40001234", + "BT0: 0x40201234", + "last failed alloc call: 40201234(512)", + "Exception (28):", + ], + }, + PLATFORM_RP2: { + "state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"], + "addresses": ["PC: 0x10001234 (fault location)"], + }, + PLATFORM_NRF52: { + "state_markers": ["Last crash:"], + "addresses": [ + # %08x zero-pads even a vector-table PC past the {3,} bound. + "PC=0x00000050 LR=0x00000000", + # Synthetic short form; pins the bound's lower edge. + "PC=0x27a1c LR=0x1e33", + ], + }, +} + +BENIGN_LINES = [ + "[I][app:100] hello world", + "[C][wifi:400] BSSID: AA:BB:CC:DD:EE:FF", + "[19:26:11.966][I][main:151]: version 2026.7.0-dev", + "[I][app:102]: Uptime: 12345678 ms", + "[I][app:102]: Uptime: 41234567 ms", + "[V][esp-idf:000]: I (40219876) wifi: connected", + "[D][api:102]: Client connected (40123456)", + "[D][sensor:093]: 'Water meter': Sending state 12345678.00000 L", + # No internal word boundary; the bare-8-hex branch must not fire. + "[I][ota:117]: MD5 of binary: d41d8cd98f00b204e9800998ecf8427e", + # Short 0x tokens (BLE handles); the 3-digit minimum keeps them out. + "[D][ble:200]: Connection handle 0x1F, MTU 23", + "[C][network:600]: IPv6: fe80::1a2b:3c4d:5e6f:7a8b", + "[C][ota:097]: Version: 2026.7.0", +] + +GATE_PARAMS = [ + pytest.param(platform, line, True, id=f"{platform}-{kind}-{n}") + for platform, samples in CRASH_SAMPLES.items() + for kind in ("addresses", "state_markers", "extra_triggers") + for n, line in enumerate(samples.get(kind, [])) +] + [ + pytest.param(platform, line, False, id=f"benign-{platform}-{n}") + for platform in CRASH_SAMPLES + for n, line in enumerate(BENIGN_LINES) +] + + +@pytest.mark.parametrize(("platform", "line", "should_fire"), GATE_PARAMS) +def test_platform_gate(platform: str, line: str, should_fire: bool) -> None: + gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert bool(gate.search(line)) is should_fire + + +def test_gates_are_platform_scoped() -> None: + """Another platform's markers must not fire an esp32 session's gate.""" + esp32_gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[PLATFORM_ESP32]) + for line in ( + ">>>stack>>>", + "Last crash:", + "Exception (28):", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + ): + assert not esp32_gate.search(line) + + +def _top_level_branches(pattern: str) -> list[str]: + """Split a regex source on alternations outside groups and classes.""" + branches: list[str] = [] + depth = 0 + in_class = False + esc = False + start = 0 + for i, ch in enumerate(pattern): + if esc: + esc = False + elif ch == "\\": + esc = True + elif in_class: + in_class = ch != "]" + elif ch == "[": + in_class = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == "|" and depth == 0: + branches.append(pattern[start:i]) + start = i + 1 + branches.append(pattern[start:]) + return branches + + +@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES)) +def test_every_gate_branch_is_exercised(platform: str) -> None: + """Every gate branch must be hit by a sample; the superset checks + stay green when a typoed alternation matches nothing. + """ + samples = CRASH_SAMPLES[platform] + lines = [line for kind in samples for line in samples[kind]] + branches = _top_level_branches(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert len(branches) > 1 + for branch in branches: + assert any(re.search(branch, line) for line in lines), ( + f"no {platform} sample exercises gate branch {branch!r}; add one " + "or drop the dead branch" + ) + + +# In-tree sources that print each marker literal the gates key on; +# esp8266's >>>stack>>> comes from the Arduino core, outside this tree. +FIRMWARE_MARKER_SOURCES = { + "CRASH DETECTED ON PREVIOUS BOOT": ( + "esphome/components/esp32/crash_handler.cpp", + "esphome/components/esp8266/crash_handler.cpp", + "esphome/components/rp2/crash_handler.cpp", + ), + "Last crash:": ("esphome/components/logger/logger_zephyr.cpp",), +} + + +def test_gate_markers_match_firmware_output() -> None: + """A reworded firmware banner must fail here, not in the field; + every regex-level guard stays green when the C++ side drifts. + """ + root = Path(__file__).parents[2] + for marker, sources in FIRMWARE_MARKER_SOURCES.items(): + for source in sources: + text = (root / source).read_text(encoding="utf-8") + assert marker in text, ( + f"{source} no longer prints {marker!r}; update the gates and " + "samples to the new banner" + ) + + +def test_crash_samples_cover_registry() -> None: + """A newly registered decoder must come with a non-empty gate sample.""" + assert set(CRASH_SAMPLES) == set(stacktrace.platform_hooks.STACKTRACE_GATES) + assert set(stacktrace.platform_hooks.STACKTRACE_GATES) == set( + stacktrace.platform_hooks.PLATFORM_HOOKS["process_stacktrace"] + ) + assert all(samples["addresses"] for samples in CRASH_SAMPLES.values()) + + +# The stacktrace pattern constants each decoder module exports. The +# samples and these patterns must cover each other, so an edit on either +# side fails the guards below instead of quietly widening the gap +# between the gate and the decoders. +DECODER_PATTERNS: dict[str, list[str]] = { + PLATFORM_ESP32: [ + "STACKTRACE_ESP32_PC_RE", + "STACKTRACE_ESP32_EXCVADDR_RE", + "STACKTRACE_ESP32_C3_PC_RE", + "STACKTRACE_ESP32_C3_RA_RE", + "STACKTRACE_ESP32_C3_MTVAL_RE", + "STACKTRACE_BAD_ALLOC_RE", + "STACKTRACE_ESP32_BACKTRACE_RE", + "STACKTRACE_ESP32_BACKTRACE_PC_RE", + "STACKTRACE_ESP32_CRASH_BT_RE", + ], + PLATFORM_ESP8266: [ + "STACKTRACE_ESP8266_EXCEPTION_TYPE_RE", + "STACKTRACE_ESP8266_PC_RE", + "STACKTRACE_ESP8266_EXCVADDR_RE", + "STACKTRACE_ESP8266_CRASH_PC_RE", + "STACKTRACE_ESP8266_CRASH_EXCVADDR_RE", + "STACKTRACE_ESP8266_CRASH_BT_RE", + "STACKTRACE_BAD_ALLOC_RE", + "STACKTRACE_ESP8266_BACKTRACE_PC_RE", + ], + PLATFORM_RP2: ["_CRASH_RE", "_CRASH_ADDR_RE"], + PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"], +} + +# Declared decoder patterns whose language the gate deliberately does +# not cover: bare stack-dump words, where the gate keys on the dump +# line's 3ff... stack address instead and a lone letter-free word never +# appears outside a dump region whose other lines already fired. +GATE_EXEMPT_PATTERNS = { + "STACKTRACE_ESP32_BACKTRACE_PC_RE", + "STACKTRACE_ESP8266_BACKTRACE_PC_RE", +} + + +@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES)) +def test_platform_declarations_match_decoder(platform: str) -> None: + r"""Samples, declared patterns, and the decoder must agree. + + Checks: declared patterns exist, samples and patterns cover each + other, no stacktrace pattern is undeclared, markers open the dump + region, and a state-setting decoder declares a marker. + + Known blind spots: the catch-all backtrace patterns can satisfy the + sample direction alone; the undeclared sweep keys off naming; the + state-gating check is a textual heuristic (pinned against + respelling by the declared-markers direction); a second opening + marker beside a declared one passes unnoticed; and the generative + guard draws full matches, so trailing word characters defeating the + pointer branch's ``\b`` are invisible to it. + """ + module = importlib.import_module(f"esphome.components.{platform}") + patterns: dict[str, re.Pattern] = {} + for name in DECODER_PATTERNS[platform]: + pattern = getattr(module, name, None) + if pattern is None: + pytest.fail( + f"{platform} no longer defines {name}; update DECODER_PATTERNS " + "and CRASH_SAMPLES together" + ) + patterns[name] = pattern + + lines = ( + CRASH_SAMPLES[platform]["state_markers"] + CRASH_SAMPLES[platform]["addresses"] + ) + for line in CRASH_SAMPLES[platform]["addresses"]: + assert any(p.search(line) for p in patterns.values()), ( + f"{line!r} no longer matches any {platform} decoder pattern; " + "update CRASH_SAMPLES and re-derive the gate" + ) + for name, pattern in patterns.items(): + assert any(pattern.search(line) for line in lines), ( + f"no sample exercises {platform}.{name}; add one so the gate " + "provably covers it" + ) + undeclared = [ + name + for name, value in vars(module).items() + if isinstance(value, re.Pattern) + and ("STACKTRACE" in name or name.startswith("_CRASH")) + and name not in DECODER_PATTERNS[platform] + ] + assert not undeclared, ( + f"{platform} gained stacktrace patterns {undeclared}; declare them in " + "DECODER_PATTERNS with samples" + ) + + for marker in CRASH_SAMPLES[platform]["state_markers"]: + assert module.process_stacktrace(CONFIG, marker, False) is True, ( + f"{marker!r} no longer opens {platform}'s dump region; update " + "state_markers to the line the decoder actually keys on" + ) + # Textual heuristic, deliberately one-directional: a state-gated + # decoder must declare a marker. The reverse (a stateless decoder + # declaring none) is not asserted; an unrelated "return True" added + # to a decoder would turn it into a false failure. + source = inspect.getsource(module.process_stacktrace) + sets_state = "return True" in source or "backtrace_state = True" in source + if CRASH_SAMPLES[platform]["state_markers"]: + # The heuristic fails open on a respelling (return bool(...)); + # pinning it against the decoders known to be state-gated today + # turns a silent disarm into a failure that names the fix. + assert sets_state, ( + f"{platform}.process_stacktrace declares state_markers but the " + "state-gating heuristic no longer recognises it; update the " + "spelling list in this test" + ) + if sets_state: + assert CRASH_SAMPLES[platform]["state_markers"], ( + f"{platform}.process_stacktrace is state-gated but declares no " + "state_markers; the gate cannot promise to open its dump region" + ) + + +@pytest.mark.parametrize( + ("platform", "name"), + [ + (platform, name) + for platform, names in DECODER_PATTERNS.items() + for name in names + if name not in GATE_EXEMPT_PATTERNS + ], +) +@given(data=st_data()) +@settings(max_examples=25, deadline=None) +def test_address_gate_covers_decoder_pattern_languages( + platform: str, name: str, data +) -> None: + """Each platform's gate must be a superset of its decoder patterns; + generated inputs catch a widened decoder the finite samples miss. + """ + pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name) + example = data.draw(from_regex(pattern, fullmatch=True)) + gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform]) + assert gate.search(example), ( + f"{platform}.{name} accepts {example!r} but the {platform} gate does " + "not fire; decoding would silently never start on that form" + ) + + +def _run( + handler, + platform: str = PLATFORM_ESP32, + lines: tuple[str, ...] = ("PC: 0x4010496e",), +) -> stacktrace.LogLineProcessor: + """Processor with the resolver stubbed, fed the given lines.""" + with patch.object( + stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler + ): + processor = stacktrace.LogLineProcessor(CONFIG, platform) + for line in lines: + processor.process_line(line) + return processor + + +def _fed(handler) -> list[str]: + return [call.args[1] for call in handler.call_args_list] + + +def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [r.message for r in caplog.records if r.levelname == "WARNING"] + + +def test_decoder_contains_failures_and_short_circuits() -> None: + """One decode failure is contained and never retried; a retry per + backtrace line would stall streaming on a failing subprocess. + """ + handler = Mock(side_effect=EsphomeError("no idedata")) + processor = _run( + handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e", "BT1: 0x401049aa") + ) + + assert handler.call_count == 1 + assert processor.backtrace_state is False + + +def test_decoder_swallows_os_error_with_remediation_hint( + caplog: pytest.LogCaptureFixture, +) -> None: + """An OSError (missing build tree) is the user's environment, not a + decoder bug; it must keep the recompile hint. + """ + handler = Mock( + side_effect=FileNotFoundError(2, "No such file or directory", "/build") + ) + processor = _run(handler, lines=("PC: 0x4010496e", "BT0: 0x4010496e")) + + assert handler.call_count == 1 + assert processor.backtrace_state is False + warnings = _warnings(caplog) + assert any("esphome compile" in m for m in warnings) + assert not any("this is a bug" in m for m in warnings) + + +def test_decoder_warning_uses_fallback_for_empty_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A bare EsphomeError must not render as empty parens.""" + _run(Mock(side_effect=EsphomeError())) + + warnings = _warnings(caplog) + assert any("build artifacts not found locally" in m for m in warnings) + assert not any("()" in m for m in warnings) + + +def test_decoder_bug_with_empty_message_names_the_type( + caplog: pytest.LogCaptureFixture, +) -> None: + """A decoder bug says so instead of sending the user down the + dead-end recompile path. + """ + _run(Mock(side_effect=IndexError())) + + warnings = _warnings(caplog) + assert any("IndexError" in m and "this is a bug" in m for m in warnings) + assert not any("esphome compile" in m for m in warnings) + + +def test_decoder_bug_warning_keeps_the_type_with_a_message( + caplog: pytest.LogCaptureFixture, +) -> None: + """The type must survive a non-empty message; a bare KeyError message + like 'prog_path' reads as a raised string in a bug report paste. + """ + _run(Mock(side_effect=KeyError("prog_path"))) + + warnings = _warnings(caplog) + assert any("KeyError: 'prog_path'" in m for m in warnings) + + +def test_marker_then_address_threads_state() -> None: + """A state marker resolves the decoder live and threads state to + the following stack words. + """ + handler = Mock(side_effect=[True, True]) + processor = _run( + handler, + platform=PLATFORM_ESP8266, + lines=(">>>stack>>>", "3ffffe10: 40201234 3ffe8410 00000000 40201000"), + ) + + assert _fed(handler) == [ + ">>>stack>>>", + "3ffffe10: 40201234 3ffe8410 00000000 40201000", + ] + assert handler.call_args_list[0].args[2] is False + assert handler.call_args_list[1].args[2] is True + assert processor.backtrace_state is True + + +def test_lines_before_the_gate_never_reach_the_decoder() -> None: + """Benign lines are dropped, not buffered.""" + handler = Mock(return_value=False) + quiet = tuple(f"quiet line {n}" for n in range(12)) + _run(handler, lines=quiet + ("PC: 0x4010496e",)) + + assert _fed(handler) == ["PC: 0x4010496e"] + + +def test_processor_resolves_lazily_on_address_token() -> None: + """No resolution attempt until a line carries an address token.""" + handler = Mock(return_value=False) + + with patch.object( + stacktrace.platform_hooks, "get_stacktrace_handler", return_value=handler + ) as mock_resolve: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("[I][app:100] hello world") + mock_resolve.assert_not_called() + + processor.process_line("PC: 0x40104960") + mock_resolve.assert_called_once_with(PLATFORM_ESP32) + + # Later lines feed the resolved handler directly, no re-resolution. + processor.process_line("[I][app:101] back to normal") + mock_resolve.assert_called_once() + + assert _fed(handler) == ["PC: 0x40104960", "[I][app:101] back to normal"] + + +def test_processor_unexpected_resolution_error_disables_decoding( + caplog: pytest.LogCaptureFixture, +) -> None: + """Resolution is inside the containment guarantee like everything else.""" + with patch.object( + stacktrace.platform_hooks, + "get_stacktrace_handler", + side_effect=OSError("filesystem went away"), + ) as mock_resolve: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("PC: 0x40104960") + processor.process_line("BT0: 0x40104960") + + mock_resolve.assert_called_once() + warnings = _warnings(caplog) + assert len(warnings) == 1 + assert "could not be loaded" in warnings[0] + assert processor.backtrace_state is False + + +def test_processor_import_failure_disables_decoding( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken platform package degrades once instead of raising.""" + caplog.set_level("INFO", logger="esphome.platform_hooks") + + with patch.object( + stacktrace.platform_hooks, + "import_module", + Mock(side_effect=ImportError("broken install")), + ) as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_ESP32) + processor.process_line("PC: 0x40104960") + processor.process_line("BT0: 0x40104960") + + mock_import.assert_called_once() + assert "Stacktrace analysis is unavailable" in caplog.text + assert "broken install" in caplog.text + assert processor.backtrace_state is False + + +def test_processor_registry_miss_disables_at_construction( + caplog: pytest.LogCaptureFixture, +) -> None: + """Platforms the registry proves have no analyzer disable up front. + + The unavailable notice fires at session start (as it always did) and + the per-line gate never runs. + """ + caplog.set_level("INFO", logger="esphome.platform_hooks") + + with patch.object(stacktrace.platform_hooks, "import_module") as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX) + processor.process_line("PC: 0x40104960") + + mock_import.assert_not_called() + assert "Stacktrace analysis is unavailable" in caplog.text + assert processor.backtrace_state is False + + +def test_external_platform_resolves_at_construction( + caplog: pytest.LogCaptureFixture, +) -> None: + """External platforms resolve eagerly; the gates cannot speak for an + external decoder and the import belongs off the streaming callback. + """ + caplog.set_level("INFO", logger="esphome.platform_hooks") + module = type("ExternalPlatform", (), {}) # no process_stacktrace + + with patch.object( + stacktrace.platform_hooks, + "import_module", + Mock(return_value=module), + ) as mock_import: + processor = stacktrace.LogLineProcessor(CONFIG, "my_external_chip") + mock_import.assert_called_once() + assert "Stacktrace analysis is unavailable" in caplog.text + + processor.process_line("PC: 0x40104960") + + mock_import.assert_called_once() + assert processor.backtrace_state is False diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index baaa99f2a7..f4063237b1 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,3 +1,5 @@ +from collections import ChainMap +from fnmatch import fnmatchcase import logging from pathlib import Path from typing import Any @@ -368,7 +370,7 @@ def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: """ test_config = _get_test_minimal_valid_config(tmp_path) - result = config_module.validate_config(test_config, None) + result = config_module.validate_config(test_config, None, snapshot_user_config=True) # Snapshot is populated. assert result.user_config is not None @@ -391,7 +393,7 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No """ test_config = _get_test_minimal_valid_config(tmp_path) - result = config_module.validate_config(test_config, None) + result = config_module.validate_config(test_config, None, snapshot_user_config=True) assert result.user_config is not None # preload_core_config injected build_path onto the validated config. @@ -402,6 +404,32 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No assert result["esphome"] is not result.user_config["esphome"] +def test_validate_config_snapshot_without_substitutions(tmp_path: Path) -> None: + """The snapshot works for configs that have no substitutions block.""" + test_config = _get_test_minimal_valid_config(tmp_path) + del test_config[CONF_SUBSTITUTIONS] + + result = config_module.validate_config(test_config, None, snapshot_user_config=True) + + assert result.user_config is not None + assert CONF_SUBSTITUTIONS not in result.user_config + assert result.user_config["esphome"] == {"name": "test_device"} + + +def test_validate_config_skips_user_config_snapshot_by_default( + tmp_path: Path, +) -> None: + """Without ``snapshot_user_config`` the deep copy is skipped entirely; + only ``esphome config --no-defaults`` needs the snapshot and the copy is + too expensive to take on every load. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is None + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type. @@ -961,3 +989,134 @@ def test_remote_package_scalar_yaml_raises_helpful_error( msg = str(exc_info.value) assert "mapping at the top level" in msg assert "file1.yaml" in msg + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param("wifi.yaml", ["wifi.yaml"], id="literal_passthrough"), + pytest.param( + "keys/${system_name}.yaml", ["keys/*.yaml"], id="embedded_substitution" + ), + pytest.param( + "network/${eth_model}/config.yaml", + ["network/*/config.yaml"], + id="directory_substitution", + ), + pytest.param( + "device-$platform.yaml", ["device-*.yaml"], id="unbraced_substitution" + ), + pytest.param("${a}${b}.yaml", ["*.yaml"], id="adjacent_wildcards_collapse"), + pytest.param( + '${ "a.yaml" if x else "../empty.yaml" }', + ["a.yaml", "../empty.yaml"], + id="conditional_literals", + ), + pytest.param( + 'pre-${ "a" if c else "b" }.yaml', + ["pre-a.yaml", "pre-b.yaml"], + id="conditional_spliced", + ), + pytest.param( + '${ "x.yaml" if a else ("y.yaml" if b else "z.yaml") }', + ["x.yaml", "y.yaml", "z.yaml"], + id="nested_conditional", + ), + pytest.param( + '${ "same.yaml" if x else "same.yaml" }', + ["same.yaml"], + id="duplicate_literals_dedupe", + ), + pytest.param('${ "a.yaml" if x }', ["a.yaml"], id="conditional_no_else"), + pytest.param( + '${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }', + ["NO BLUETOOTH SUPPORT ON ESP8266.yaml", "../empty.yaml"], + id="issue_17650_verbatim", + ), + pytest.param( + '${ "" if x else "b.yaml" }', ["b.yaml"], id="empty_literal_dropped" + ), + pytest.param( + "keys\\${system_name}.yaml", + ["keys\\*.yaml"], + id="backslash_separator", + ), + pytest.param( + '${ "it\'s.yaml" if x else "b.yaml" }', + ["it's.yaml", "b.yaml"], + id="apostrophe_in_literal", + ), + pytest.param( + '${ "a-${x}.yaml" if c else "b.yaml" }', + ["a-*.yaml", "b.yaml"], + id="substitution_inside_literal", + ), + pytest.param("sensor [${x}].yaml", ["sensor [[]*].yaml"], id="bracket_escaped"), + pytest.param( + "config?${x}.yaml", ["config[?]*.yaml"], id="question_mark_escaped" + ), + pytest.param( + "../${x}/config.yaml", ["../*/config.yaml"], id="ascending_directory" + ), + pytest.param("${file}", [], id="bare_variable_dropped"), + pytest.param("../${file}", [], id="ascending_bare_variable_dropped"), + pytest.param( + '${ name ~ ".yaml" }', [".yaml"], id="dynamic_concat_extracts_literal" + ), + pytest.param("${ if }", [], id="no_literal_expression_dropped"), + pytest.param( + "<% if x %>a.yaml<% endif %>", ["*a.yaml*"], id="block_statement_globs" + ), + ], +) +def test_include_candidate_patterns(value: str, expected: list[str]) -> None: + """Templated include paths expand to glob patterns and branch literals.""" + assert substitutions.include_candidate_patterns(value) == expected + + +@pytest.mark.parametrize( + ("template", "variables"), + [ + pytest.param( + "keys/${system_name}.yaml", {"system_name": "esp-buero"}, id="embedded" + ), + pytest.param("device-$platform.yaml", {"platform": "esp32"}, id="unbraced"), + pytest.param( + "network/${eth_model}/config.yaml", {"eth_model": "eth01"}, id="directory" + ), + pytest.param( + '${ "NO BT.yaml" if bt else "../empty.yaml" }', + {"bt": True}, + id="conditional_true", + ), + pytest.param( + '${ "NO BT.yaml" if bt else "../empty.yaml" }', + {"bt": False}, + id="conditional_false", + ), + pytest.param('pre-${ "a" if c else "b" }.yaml', {"c": True}, id="spliced"), + pytest.param("${a}${b}.yaml", {"a": "x", "b": "y"}, id="adjacent"), + pytest.param("sensor [${x}].yaml", {"x": "a"}, id="bracket"), + ], +) +def test_include_candidate_patterns_cover_real_expansion( + template: str, variables: dict[str, Any] +) -> None: + """ + Lockstep pin against the real substitution machinery. + + include_candidate_patterns mirrors _expand_substitutions without + variable values (the evaluator returns the one selected branch, so it + cannot enumerate candidates itself); this asserts every filename the + real pass resolves is covered by a candidate pattern, so a change to + reference syntax or expansion order breaks here instead of silently + dropping files from bundles. + """ + resolved = str( + substitutions._expand_substitutions( + template, [], ChainMap(variables), True, None + ) + ) + patterns = substitutions.include_candidate_patterns(template) + assert any(fnmatchcase(resolved, p) or resolved == p for p in patterns) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 581b1aca99..a4b091b7c2 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +import logging from pathlib import Path import subprocess import sys @@ -13,6 +14,8 @@ from unittest.mock import MagicMock, patch import pytest from esphome import util +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: @@ -422,6 +425,199 @@ def _make_redirect( return redirect, buf +def test_redirect_text_flushes_so_piped_output_streams() -> None: + """Regression: in-process esptool progress must reach the pipe right away. + + ``run_external_command`` runs esptool inside our own process, so its + progress output goes through ``RedirectText.write``. That used to be + flushed only because ``colorama.init()`` wrapped stdout in a stream that + flushed after every write. + """ + buf = io.BytesIO() + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + redirect = util.RedirectText(piped_stream) + + redirect.write("Writing at 0x00010000 (50%)\r") + + # No explicit flush here on purpose: RedirectText has to do it. + assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" + + +@pytest.mark.parametrize( + "break_char", + ["\x0c", "\x0b", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"], + ids=["formfeed", "vtab", "fs", "gs", "rs", "nel", "lsep", "psep"], +) +def test_redirect_text_keeps_output_after_an_exotic_break_character( + break_char: str, +) -> None: + r"""Only ``\n`` and ``\r`` end a line; the rest is ordinary text. + + ``str.splitlines`` treats all of these as line breaks. Splitting on them + used to strand the fragment in the buffer and drop every complete line + that came after it, which for a form feed in toolchain output meant + losing the rest of the build log. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write(f"first{break_char}second\nthird\n") + + assert buf.getvalue() == f"first{break_char}second\nthird\n" + + +def test_redirect_text_treats_crlf_as_one_terminator() -> None: + r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("one\r\ntwo\rthree\nfour") + + # "four" has no terminator yet, so it is held back. + assert buf.getvalue() == "one\r\ntwo\rthree\n" + + redirect.drain() + + assert buf.getvalue() == "one\r\ntwo\rthree\nfour\n" + + +def test_redirect_text_drain_releases_held_partial_line() -> None: + """A last line with no terminator must still reach the user. + + A tool that dies part way through a line leaves that text in the buffer, + and it is usually the message saying what went wrong. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + # Still held: no terminator has arrived. + assert buf.getvalue() == "" + + redirect.drain() + + assert buf.getvalue() == "FATAL: ld returned 1 exit status\n" + + +def test_redirect_text_drain_still_applies_the_filter() -> None: + """Releasing a held line does not smuggle noise past the filter.""" + redirect, buf = _make_redirect(filter_lines=["Verbose mode can be enabled"]) + redirect.write("Verbose mode can be enabled") + + redirect.drain() + + assert buf.getvalue() == "" + + +def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: + """Draining twice, or with an empty buffer, writes nothing extra.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("complete line\n") + + redirect.drain() + redirect.drain() + + assert buf.getvalue() == "complete line\n" + + +def test_flash_error_help_is_quiet_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: reading the platform used to raise in the runner.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.get_esp32_arduino_flash_error_help() is None + + +def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parent tells the subprocess what it cannot work out for itself.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + help_msg = util.get_esp32_arduino_flash_error_help() + + assert help_msg is not None + assert "esp-idf" in help_msg + + +def test_is_esp32_arduino_build_raises_on_a_half_filled_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A half filled in CORE is a bug, so it must raise, not fall back.""" + + monkeypatch.setattr(CORE, "data", {KEY_CORE: {}}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + with pytest.raises(KeyError): + util.is_esp32_arduino_build() + + +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", True), + ("esp32", "esp-idf", False), + ("esp8266", "arduino", False), + ], +) +def test_is_esp32_arduino_build_from_a_configured_core( + monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool +) -> None: + """With CORE set up, it is the source of truth and the env var is ignored.""" + + monkeypatch.setattr( + CORE, + "data", + {KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}}, + ) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.is_esp32_arduino_build() is expected + + +def test_redirect_text_survives_a_flash_error_without_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The overflow line goes through even from a process with no CORE.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + +def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: + """An out-of-flash error gets the how-to-fix note appended.""" + monkeypatch.setattr( + util, "get_esp32_arduino_flash_error_help", lambda: "TIP: switch to esp-idf\n" + ) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert "Error: The program size" in buf.getvalue() + assert "TIP: switch to esp-idf" in buf.getvalue() + + +def test_redirect_text_skips_flash_size_help_on_other_platforms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The note is ESP32-with-Arduino only, so elsewhere the line stands alone.""" + monkeypatch.setattr(util, "get_esp32_arduino_flash_error_help", lambda: None) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -551,6 +747,140 @@ def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> N assert "CALLBACK FIRED" in captured.out +def test_run_external_command_drains_partial_line( + capsys: pytest.CaptureFixture, +) -> None: + """A command that stops mid line still shows that line. + + esptool runs in-process here, so a message it writes without a trailing + newline would otherwise be dropped when the streams are put back. + """ + + def fake_main() -> int: + print("A fatal error occurred: no serial data", end="") + return 1 + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 1 + assert "A fatal error occurred: no serial data" in capsys.readouterr().out + + +def test_run_external_command_drains_on_early_exit( + capsys: pytest.CaptureFixture, +) -> None: + """The drain also happens when the command exits through ``sys.exit``.""" + + def fake_main() -> int: + print("Fatal: bailing out", end="") + sys.exit(3) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 3 + assert "Fatal: bailing out" in capsys.readouterr().out + + +def test_run_external_command_capture_stdout_has_nothing_to_drain() -> None: + """With ``capture_stdout`` there is nothing held to write out. + + The stdout wrapper still gets built, but ``sys.stdout`` is replaced by + the capture buffer right after, so the wrapper never sees a write and + draining it does nothing. + """ + + def fake_main() -> int: + print("captured output", end="") + return 0 + + out = util.run_external_command( + fake_main, "fake", capture_stdout=True, filter_lines=["ignore me"] + ) + + assert out == "captured output" + + +def test_run_external_command_survives_a_command_that_swaps_stdout( + capsys: pytest.CaptureFixture, +) -> None: + """Draining must not depend on what the command left in ``sys.stdout``. + + A command is free to replace the stream; reaching for ``drain`` on + whatever it left there would raise from the cleanup path and bury the + real exit code. + """ + + def fake_main() -> int: + print("before the swap", end="") + sys.stdout = io.StringIO() + sys.exit(7) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 7 + assert "before the swap" in capsys.readouterr().out + + +def test_drain_reports_the_lost_line_instead_of_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken stream during cleanup is reported, not raised. + + The warning carries the held text, because the stream we were asked to + write it to is the one that just failed. + """ + caplog.set_level(logging.WARNING, logger=util.__name__) + out = MagicMock() + out.write.side_effect = BrokenPipeError("pipe is gone") + redirect = util.RedirectText(out, filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + redirect.drain() + + assert "pipe is gone" in caplog.text + assert "FATAL: ld returned 1 exit status" in caplog.text + + +def test_drain_lets_other_errors_through() -> None: + """Only an unusable stream is tolerated; a bug still has to be visible.""" + + def broken_callback(line: str) -> str | None: + raise TypeError("a line callback is broken") + + redirect, _buf = _make_redirect(line_callbacks=[broken_callback]) + redirect.write("a line with no terminator") + + with pytest.raises(TypeError): + redirect.drain() + + +def test_run_external_command_drains_stderr_even_if_stdout_drain_raises( + capsys: pytest.CaptureFixture, +) -> None: + """One stream failing must not strand the other's held line. + + ``drain`` deliberately lets anything that is not a stream error through, + so a broken line callback would otherwise skip the stderr drain and take + that line down with it. + """ + + def broken_on_stdout(line: str) -> str | None: + if "stdout" in line: + raise TypeError("a line callback is broken") + return None + + def fake_main() -> int: + print("stdout partial", end="") + print("stderr FATAL: the real reason", end="", file=sys.stderr) + return 0 + + with pytest.raises(TypeError): + util.run_external_command(fake_main, "fake", line_callbacks=[broken_on_stdout]) + + # The bug still surfaces, but stderr's held line was written first. + assert "stderr FATAL: the real reason" in capsys.readouterr().err + + def test_run_external_process_line_callbacks() -> None: """Test that run_external_process passes line_callbacks to RedirectText.""" results: list[str] = [] @@ -561,7 +891,7 @@ def test_run_external_process_line_callbacks() -> None: return "PROCESS CALLBACK\n" return None - with patch("esphome.util.subprocess.run") as mock_run: + with patch("subprocess.run") as mock_run: def run_side_effect(*args: Any, **kwargs: Any) -> MagicMock: # Simulate subprocess writing to the stdout RedirectText @@ -635,7 +965,7 @@ def test_detect_rp2040_bootsel_found() -> None: """Test BOOTSEL device detection when device is present.""" mock_result = MagicMock() mock_result.stdout = b"Device Information\n type: RP2040\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 1 assert result.permission_error is False @@ -645,7 +975,7 @@ def test_detect_rp2040_bootsel_multiple() -> None: """Test BOOTSEL detection with multiple devices.""" mock_result = MagicMock() mock_result.stdout = b"type: RP2040\ntype: RP2350\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 2 assert result.permission_error is False @@ -658,7 +988,7 @@ def test_detect_rp2040_bootsel_none() -> None: b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" ) mock_result.stderr = b"" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False @@ -675,7 +1005,7 @@ def test_detect_rp2040_bootsel_permission_error() -> None: b"but picotool was unable to connect. " b"Maybe try 'sudo' or check your permissions.\n" ) - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is True @@ -686,7 +1016,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None: mock_result = MagicMock() mock_result.stdout = b"" mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): + with patch("subprocess.run", return_value=mock_result): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is True @@ -694,7 +1024,7 @@ def test_detect_rp2040_bootsel_libusb_access_error() -> None: def test_detect_rp2040_bootsel_oserror() -> None: """Test BOOTSEL detection handles OSError.""" - with patch("esphome.util.subprocess.run", side_effect=OSError("not found")): + with patch("subprocess.run", side_effect=OSError("not found")): result = util.detect_rp2040_bootsel("/usr/bin/picotool") assert result.device_count == 0 assert result.permission_error is False @@ -703,7 +1033,7 @@ def test_detect_rp2040_bootsel_oserror() -> None: def test_detect_rp2040_bootsel_timeout() -> None: """Test BOOTSEL detection handles timeout.""" with patch( - "esphome.util.subprocess.run", + "subprocess.run", side_effect=subprocess.TimeoutExpired("picotool", 10), ): result = util.detect_rp2040_bootsel("/usr/bin/picotool") @@ -717,7 +1047,6 @@ class TestSafePrint: @pytest.fixture(autouse=True) def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None: """Default ``CORE.dashboard`` to False so each test starts hermetic.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", False) @@ -739,12 +1068,36 @@ class TestSafePrint: monkeypatch: pytest.MonkeyPatch, ) -> None: r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) util.safe_print("\033[0;32mhi\033[0m") assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + def test_flushes_so_piped_output_streams( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: each line must reach the OS pipe right away. + + The dashboard runs ``esphome logs`` with stdout as a pipe, which + Python block buffers at 8 KiB. Log lines used to be flushed only + because ``colorama.init()`` wrapped stdout in a stream that flushed + after every write; once that wrapping was skipped for dashboard runs + the lines sat in the buffer and the log view stayed empty until + enough output piled up to fill it. + """ + buf = io.BytesIO() + # newline="\n" keeps Windows from rewriting the terminator to "\r\n"; + # this test is about flushing, not about line endings. + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + monkeypatch.setattr(sys, "stdout", piped_stream) + + util.safe_print("live log line") + + # No explicit flush here on purpose: safe_print has to do it. + assert buf.getvalue() == b"live log line\n" + def test_fallback_writes_string_not_bytes_repr( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -764,7 +1117,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("bars: \u2582\u2584\u2586\u2588 done") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Output is a clean line, not the bytes repr. @@ -781,7 +1134,6 @@ class TestSafePrint: self, monkeypatch: pytest.MonkeyPatch ) -> None: """Dashboard ESC escaping + cp1252 fallback compose correctly.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) buf = io.BytesIO() @@ -789,7 +1141,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Dashboard escaping turned ESC into literal "\033" (5 chars), which diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 5c38fce105..e0a81652e3 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -282,8 +282,54 @@ test: !include_dir_named test_dir assert ".hidden_dir" not in actual["test"] +def test_include_dir_list(tmp_path: Path) -> None: + """!include_dir_list loads every .yaml file in the directory as a list.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("key: value_a") + (test_dir / "b.yaml").write_text("key: value_b") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_list test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert len(actual["test"]) == 2 + assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"} + + +def test_include_dir_merge_list(tmp_path: Path) -> None: + """!include_dir_merge_list concatenates the lists from every .yaml file.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("- item_a1\n- item_a2\n") + (test_dir / "b.yaml").write_text("- item_b1\n") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_merge_list test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"] + + +def test_include_dir_merge_named(tmp_path: Path) -> None: + """!include_dir_merge_named merges the mappings from every .yaml file.""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + (test_dir / "a.yaml").write_text("key_a: value_a") + (test_dir / "b.yaml").write_text("key_b: value_b") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("test: !include_dir_merge_named test_dir\n") + + actual = yaml_util.load_yaml(test_yaml) + + assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"} + + def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: - """Test that _find_files works recursively through include_dir_named.""" + """Test that find_files works recursively through include_dir_named.""" # Copy fixture directory to temporary location src_dir = fixture_path / "yaml_util" dst_dir = tmp_path / "yaml_util" @@ -1003,8 +1049,10 @@ class _StubInclude: load_result: object = None, raise_on_load: EsphomeError | None = None, ) -> None: + # Default parent lives in a nonexistent directory so unresolved + # stubs never glob real files during candidate expansion. self.file = Path(file) - self.parent_file = parent_file or Path("/tmp/parent.yaml") + self.parent_file = parent_file or Path("/nonexistent/parent.yaml") self._unresolved = unresolved self._load_result = load_result if load_result is not None else {} self._raise = raise_on_load @@ -1182,6 +1230,247 @@ def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: assert discovered.files.count(wifi_resolved) == 1 +def test_discover_user_yaml_files_expands_directory_substitution( + tmp_path: Path, +) -> None: + """A substitution spanning a directory segment globs across directories.""" + _write(tmp_path, "network/eth01/config.yaml", "ethernet:\n") + _write(tmp_path, "network/eth02/config.yaml", "ethernet:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "network/${eth_model}/config.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "network/eth01/config.yaml").resolve() in resolved + assert (tmp_path / "network/eth02/config.yaml").resolve() in resolved + + +def test_discover_user_yaml_files_loads_both_branches_of_issue_conditional( + tmp_path: Path, +) -> None: + """Both branch files of the issue-17650 conditional load when present, + including the filename with spaces.""" + _write(tmp_path, "empty.yaml", "{}\n") + _write(tmp_path, "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml", "api:\n") + _write( + tmp_path, + "boards/esp8266.yaml", + "packages:\n" + ' - !include ${ "NO BLUETOOTH SUPPORT ON ESP8266.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }\n', + ) + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "boards/esp8266.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "boards/NO BLUETOOTH SUPPORT ON ESP8266.yaml").resolve() in ( + resolved + ) + assert (tmp_path / "empty.yaml").resolve() in resolved + + +def test_discover_user_yaml_files_glob_matches_bracket_filenames( + tmp_path: Path, +) -> None: + """Glob metacharacters in the literal filename text stay literal.""" + _write(tmp_path, "sensor [a].yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "sensor [${x}].yaml") + ) + assert "sensor [a].yaml" in {p.name for p in discovered.files} + + +def test_discover_user_yaml_files_ascending_glob(tmp_path: Path) -> None: + """A templated include reaching into a sibling directory via ``..`` globs.""" + _write(tmp_path, "shared/common.yaml", "api:\n") + _write(tmp_path, "nodes/dev.yaml", "p: !include ../shared/${x}.yaml\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "nodes/dev.yaml") + ) + assert (tmp_path / "shared/common.yaml").resolve() in discovered.files + + +def test_discover_user_yaml_files_mapping_include_with_vars(tmp_path: Path) -> None: + """The mapping !include form (file + vars) expands a templated filename.""" + _write(tmp_path, "keys/a.yaml", "pin: ${num}\n") + entry = _write( + tmp_path, + "entry.yaml", + "wifi: !include\n file: keys/${n}.yaml\n vars:\n num: 4\n", + ) + discovered = discover_user_yaml_files(entry) + assert (tmp_path / "keys/a.yaml").resolve() in discovered.files + + +def test_discover_user_yaml_files_absolute_templated_include_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An absolute templated include is skipped gracefully instead of crashing.""" + shared = tmp_path / "shared" + _write(tmp_path, "shared/common.yaml", "api:\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, f"{shared}/${{x}}.yaml") + ) + assert (shared / "common.yaml").resolve() not in discovered.files + assert any("Cannot glob include pattern" in r.message for r in caplog.records) + + +def test_discover_user_yaml_files_glob_skips_dollar_named_files( + tmp_path: Path, +) -> None: + """An on-disk filename containing ``$`` can't load; the glob skips it.""" + _write(tmp_path, "keys/a.yaml", "api:\n") + _write(tmp_path, "keys/b$roken.yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${n}.yaml") + ) + names = {p.name for p in discovered.files} + assert "a.yaml" in names + assert "b$roken.yaml" not in names + + +def test_discover_user_yaml_files_glob_error_skips_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A filesystem error during candidate globbing warns and skips the include.""" + entry = _write_entry_including(tmp_path, "keys/${n}.yaml") + with ( + patch.object(Path, "glob", side_effect=OSError("boom")), + caplog.at_level("DEBUG", logger="esphome.yaml_util"), + ): + discovered = discover_user_yaml_files(entry) + assert [p.name for p in discovered.files] == ["entry.yaml"] + matching = [ + r.levelname + for r in caplog.records + if "I/O error globbing include pattern" in r.message + ] + assert matching == ["WARNING"] + + +def test_force_load_candidate_failure_warns_by_default( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken candidate logs at WARNING outside the discovery re-parse.""" + _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") + entry = _write_entry_including(tmp_path, "keys/${n}.yaml") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + force_load_include_files(yaml_util.load_yaml(entry)) + matching = [ + r.levelname for r in caplog.records if "Failed to load candidate" in r.message + ] + assert matching == ["WARNING"] + + +def test_discover_user_yaml_files_glob_skips_hidden_files(tmp_path: Path) -> None: + """Candidate globs exclude hidden files, matching ``!include_dir_*``.""" + _write(tmp_path, "keys/device-a.yaml", "api:\n") + _write(tmp_path, "keys/.hidden.yaml", "api:\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${name}.yaml") + ) + names = {p.name for p in discovered.files} + assert "device-a.yaml" in names + assert ".hidden.yaml" not in names + + +def test_discover_user_yaml_files_bare_expression_not_expanded( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A fully dynamic filename never globs the whole directory.""" + _write(tmp_path, "sibling.yaml", "api:\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "${file}") + ) + assert (tmp_path / "sibling.yaml").resolve() not in discovered.files + assert any( + "Cannot resolve !include" in r.message and r.levelname == "DEBUG" + for r in caplog.records + ) + + +def test_discover_user_yaml_files_self_glob_match_skipped( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A glob whose only match is the including file itself claims nothing.""" + entry = _write_entry_including(tmp_path, "${platform}.yaml") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files(entry) + assert [p.name for p in discovered.files] == ["entry.yaml"] + assert any("Cannot resolve !include" in r.message for r in caplog.records) + + +def test_discover_user_yaml_files_candidate_cycle_terminates(tmp_path: Path) -> None: + """Mutually glob-matching includes expand finitely and capture both files.""" + _write(tmp_path, "sub/a.yaml", "p: !include ${x}.yaml\n") + _write(tmp_path, "sub/b.yaml", "p: !include ${y}.yaml\n") + entry = _write(tmp_path, "entry.yaml", "wifi: !include sub/a.yaml\n") + discovered = discover_user_yaml_files(entry) + names = {p.name for p in discovered.files} + assert names == {"entry.yaml", "a.yaml", "b.yaml"} + + +def test_discover_user_yaml_files_many_candidates_keep_nested_includes( + tmp_path: Path, +) -> None: + """Every candidate's nested includes are discovered. + + Regression test: the id()-based cycle guard is only safe while every + traversed tree stays alive. Candidate trees used to be freed between + loop iterations, so CPython recycled their addresses and later + candidates' fresh trees were skipped as already seen, silently dropping + their nested includes. Needs several candidates to manifest; two were + not enough to trigger the reuse.""" + count = 12 + for i in range(count): + _write( + tmp_path, f"keys/k{i}.yaml", f"sensor{i}: !include ../nested/n{i}.yaml\n" + ) + _write(tmp_path, f"nested/n{i}.yaml", f"api{i}: true\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${x}.yaml") + ) + names = {p.name for p in discovered.files} + expected = {f"n{i}.yaml" for i in range(count)} + expected |= {f"k{i}.yaml" for i in range(count)} + expected.add("entry.yaml") + assert names == expected + + +def test_discover_user_yaml_files_bad_candidate_still_tracked( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A matched candidate that fails to parse warns even during discovery, + stays tracked (the load listener fires before parsing), and doesn't block + other candidates.""" + _write(tmp_path, "keys/good.yaml", "api:\n") + _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "keys/${name}.yaml") + ) + resolved = set(discovered.files) + assert (tmp_path / "keys/good.yaml").resolve() in resolved + assert (tmp_path / "keys/bad.yaml").resolve() in resolved + matching = [ + r.levelname for r in caplog.records if "Failed to load candidate" in r.message + ] + assert matching == ["WARNING"] + + +def test_discover_user_yaml_files_tolerates_templated_top_level_include( + tmp_path: Path, +) -> None: + """A literal include whose entire content is a templated ``!include`` is + tracked and skipped instead of aborting discovery.""" + _write(tmp_path, "wrapper.yaml", "!include ${x}_settings.yaml\n") + discovered = discover_user_yaml_files( + _write_entry_including(tmp_path, "wrapper.yaml") + ) + assert (tmp_path / "wrapper.yaml").resolve() in discovered.files + + def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: """`track_yaml_loads` is the building block — sanity-check it resolves symlinks so callers can dedupe by identity.""" @@ -1491,3 +1780,122 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert result["api"] == {"reboot_timeout": "5min"} assert result["logger"] == {"level": "DEBUG"} assert yaml_util.take_dropped_merge_keys() == [] + + +# --------------------------------------------------------------------------- +# track_document_range=False (validated-config-cache fast path) +# --------------------------------------------------------------------------- + +FAST_MODE_MAIN_YAML = """\ +defaults: &defaults + port: 6053 + reboot_timeout: 15min + +esphome: + name: !secret devname + +api: + <<: *defaults + port: 6054 + +number_value: 42 +float_value: 3.5 +lambda_value: !lambda 'return x * 2;' +extend_value: !extend some_id +remove_value: !remove some_id +literal_value: !literal keep_me_verbatim +included: !include included.yaml +""" + + +@pytest.fixture +def fast_mode_config_dir(tmp_path: Path) -> Path: + _write(tmp_path, "main.yaml", FAST_MODE_MAIN_YAML) + _write(tmp_path, "included.yaml", "inner_key: inner_value\ninner_num: 7\n") + _write(tmp_path, "secrets.yaml", "devname: livingroom\n") + return tmp_path + + +def _resolve_includes(config: dict) -> dict: + return { + key: value.load() if isinstance(value, yaml_util.IncludeFile) else value + for key, value in config.items() + } + + +def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None: + """Both modes produce equal values; only the metadata wrapping differs.""" + yaml_file = fast_mode_config_dir / "main.yaml" + + normal = _resolve_includes(yaml_util.load_yaml(yaml_file)) + fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False)) + + # Lambda has no __eq__; compare it by value and the rest structurally. + fast_lambda = fast.pop("lambda_value") + normal_lambda = normal.pop("lambda_value") + assert fast == normal + assert isinstance(fast_lambda, core.Lambda) + assert fast_lambda.value == normal_lambda.value == "return x * 2;" + assert fast["esphome"]["name"] == "livingroom" + assert fast["api"]["port"] == 6054 + assert fast["api"]["reboot_timeout"] == "15min" + assert fast["extend_value"] == Extend("some_id") + assert fast["remove_value"] == Remove("some_id") + # !literal wraps via make_literal, independent of range tracking. + assert isinstance(fast["literal_value"], ESPLiteralValue) + assert fast["literal_value"] == "keep_me_verbatim" + + # Fast mode returns plain values; default mode keeps the range metadata. + assert not isinstance(fast["number_value"], ESPHomeDataBase) + assert not isinstance(fast["float_value"], ESPHomeDataBase) + assert all(type(key) is str for key in fast) + assert isinstance(normal["number_value"], ESPHomeDataBase) + assert normal["number_value"].esp_range is not None + assert all(isinstance(key, ESPHomeDataBase) for key in normal) + + # Nested includes inherit fast mode through the recursive loader. + included = fast["included"] + assert not isinstance(included["inner_num"], ESPHomeDataBase) + assert all(type(key) is str for key in included) + + +def test_load_yaml_fast_mode_survives_pure_python_fallback( + fast_mode_config_dir: Path, +) -> None: + """The ESPHomePurePythonLoader retry must honour fast mode too.""" + yaml_file = fast_mode_config_dir / "main.yaml" + + class _AlwaysFailingLoader(yaml_util.ESPHomeLoader): + def __init__(self, *args, **kwargs) -> None: + raise EsphomeError("forced fallback to the pure-Python loader") + + with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader): + fast = yaml_util.load_yaml(yaml_file, track_document_range=False) + + assert not isinstance(fast["number_value"], ESPHomeDataBase) + assert all(type(key) is str for key in fast) + + +def test_load_yaml_fast_mode_rejects_custom_loader() -> None: + """A caller-supplied yaml_loader cannot combine with fast mode.""" + with pytest.raises(ValueError, match="default yaml_loader"): + yaml_util.parse_yaml( + Path("x.yaml"), + io.StringIO("a: 1"), + lambda f: {}, + track_document_range=False, + ) + + +def test_load_yaml_fast_mode_records_dropped_merge_keys( + fast_mode_config_dir: Path, +) -> None: + """The duplicate-merge-key bookkeeping must not crash on plain str keys. + + With plain keys there is no esp_range, so the recorded location falls + back to the parent file name. + """ + yaml_file = fast_mode_config_dir / "main.yaml" + + yaml_util.load_yaml(yaml_file, track_document_range=False) + assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py index 0ba3577fa7..b370fe0c47 100644 --- a/tests/unit_tests/test_zephyr_library.py +++ b/tests/unit_tests/test_zephyr_library.py @@ -59,7 +59,10 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path): assert "-DFOO" in out assert "-Wall" in out assert "zephyr_link_libraries(" in out - assert "-Llibdir" in out + # -L paths are absolutised against the library dir (the CMakeLists lives in a + # zephyr/ subdir, so a relative path would resolve from the wrong place). + abs_libdir = str((tmp_path / "libdir").resolve()).replace("\\", "\\\\") + assert f"-L{abs_libdir}" in out assert "-lm" in out