diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 80d8847bc1..832fcb41db 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -7,6 +7,7 @@ const { hasDashboardChanges, hasGitHubActionsChanges, } = require('../detect-tags'); +const { loadCodeowners, getEffectiveOwners } = require('../codeowners'); // Strategy: Merge branch detection async function detectMergeBranch(context) { @@ -148,51 +149,15 @@ async function detectGitHubActionsChanges(changedFiles) { // Strategy: Code owner detection async function detectCodeOwner(github, context, changedFiles) { const labels = new Set(); - const { owner, repo } = context.repo; try { - const { data: codeownersFile } = await github.rest.repos.getContent({ - owner, - repo, - path: 'CODEOWNERS', - }); - - const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + const codeownersPatterns = loadCodeowners(); const prAuthor = context.payload.pull_request.user.login; - const codeownersLines = codeownersContent.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - - const codeownersRegexes = codeownersLines.map(line => { - const parts = line.split(/\s+/); - const pattern = parts[0]; - const owners = parts.slice(1); - - let regex; - if (pattern.endsWith('*')) { - const dir = pattern.slice(0, -1); - regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); - } else if (pattern.includes('*')) { - // First escape all regex special chars except *, then replace * with .* - const regexPattern = pattern - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*'); - regex = new RegExp(`^${regexPattern}$`); - } else { - regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); - } - - return { regex, owners }; - }); - - for (const file of changedFiles) { - for (const { regex, owners } of codeownersRegexes) { - if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) { - labels.add('by-code-owner'); - return labels; - } - } + // Check if PR author is a codeowner of any changed file + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + if (effective.users.has(prAuthor)) { + labels.add('by-code-owner'); } } catch (error) { console.log('Failed to read or parse CODEOWNERS file:', error.message); diff --git a/.github/scripts/codeowners.js b/.github/scripts/codeowners.js new file mode 100644 index 0000000000..9a10391699 --- /dev/null +++ b/.github/scripts/codeowners.js @@ -0,0 +1,143 @@ +// Shared CODEOWNERS parsing and matching utilities. +// +// Used by: +// - codeowner-review-request.yml +// - codeowner-approved-label.yml +// - auto-label-pr/detectors.js (detectCodeOwner) + +/** + * Convert a CODEOWNERS glob pattern to a RegExp. + * + * Handles **, *, and ? wildcards after escaping regex-special characters. + */ +function globToRegex(pattern) { + let regexStr = pattern + .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') + .replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace + .replace(/\*/g, '[^/]*') // single star + .replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar + .replace(/\?/g, '.'); + return new RegExp('^' + regexStr + '$'); +} + +/** + * Parse raw CODEOWNERS file content into an array of + * { pattern, regex, owners } objects. + * + * Each `owners` entry is the raw string from the file (e.g. "@user" or + * "@esphome/core"). + */ +function parseCodeowners(content) { + const lines = content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + const patterns = []; + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pattern = parts[0]; + const owners = parts.slice(1); + const regex = globToRegex(pattern); + patterns.push({ pattern, regex, owners }); + } + return patterns; +} + +/** + * Fetch and parse the CODEOWNERS file via the GitHub API. + * + * @param {object} github - octokit instance from actions/github-script + * @param {string} owner - repo owner + * @param {string} repo - repo name + * @param {string} [ref] - git ref (SHA / branch) to read from + * @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>} + */ +async function fetchCodeowners(github, owner, repo, ref) { + const params = { owner, repo, path: 'CODEOWNERS' }; + if (ref) params.ref = ref; + + const { data: file } = await github.rest.repos.getContent(params); + const content = Buffer.from(file.content, 'base64').toString('utf8'); + return parseCodeowners(content); +} + +/** + * Classify raw owner strings into individual users and teams. + * + * @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"] + * @returns {{ users: string[], teams: string[] }} + * users – login names without "@" + * teams – team slugs without the "org/" prefix + */ +function classifyOwners(rawOwners) { + const users = []; + const teams = []; + for (const o of rawOwners) { + const clean = o.startsWith('@') ? o.slice(1) : o; + if (clean.includes('/')) { + teams.push(clean.split('/')[1]); + } else { + users.push(clean); + } + } + return { users, teams }; +} + +/** + * For each file, find its effective codeowners using GitHub's + * "last match wins" semantics, then union across all files. + * + * @param {string[]} files - list of file paths + * @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners + * @returns {{ users: Set, teams: Set, matchedFileCount: number }} + */ +function getEffectiveOwners(files, codeownersPatterns) { + const users = new Set(); + const teams = new Set(); + let matchedFileCount = 0; + + for (const file of files) { + // Last matching pattern wins for each file + let effectiveOwners = null; + for (const { regex, owners } of codeownersPatterns) { + if (regex.test(file)) { + effectiveOwners = owners; + } + } + if (effectiveOwners) { + matchedFileCount++; + const classified = classifyOwners(effectiveOwners); + for (const u of classified.users) users.add(u); + for (const t of classified.teams) teams.add(t); + } + } + + return { users, teams, matchedFileCount }; +} + +/** + * Read and parse the CODEOWNERS file from disk. + * + * Use this when the repo is already checked out (avoids an API call). + * + * @param {string} [repoRoot='.'] - path to the repo root + * @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>} + */ +function loadCodeowners(repoRoot = '.') { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8'); + return parseCodeowners(content); +} + +module.exports = { + globToRegex, + parseCodeowners, + fetchCodeowners, + loadCodeowners, + classifyOwners, + getEffectiveOwners +}; diff --git a/.github/workflows/codeowner-approved-label.yml b/.github/workflows/codeowner-approved-label.yml new file mode 100644 index 0000000000..217ae06419 --- /dev/null +++ b/.github/workflows/codeowner-approved-label.yml @@ -0,0 +1,158 @@ +# This workflow adds/removes a 'code-owner-approved' label when a +# component-specific codeowner approves (or dismisses) a PR. +# This helps maintainers prioritize PRs that have codeowner sign-off. +# +# Only component-specific codeowners count — the catch-all @esphome/core +# team is excluded so the label reflects domain-expert approval. + +name: Codeowner Approved Label + +on: + pull_request_review: + types: [submitted, dismissed] + +permissions: + pull-requests: write + contents: read + +jobs: + codeowner-approved: + name: Run + if: ${{ github.repository == 'esphome/esphome' }} + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Check codeowner approval and update label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr_number = context.payload.pull_request.number; + const LABEL_NAME = 'code-owner-approved'; + + console.log(`Processing PR #${pr_number} for codeowner approval label`); + + try { + // Get the list of changed files in this PR (with pagination) + const prFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); + + const changedFiles = prFiles.map(file => file.filename); + console.log(`Found ${changedFiles.length} changed files`); + + if (changedFiles.length === 0) { + console.log('No changed files found, skipping'); + return; + } + + // Parse CODEOWNERS from the checked-out base branch + const codeownersPatterns = loadCodeowners(); + + // Get effective owners using last-match-wins semantics + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + + // Only keep individual component-specific codeowners (exclude teams) + const componentCodeowners = effective.users; + + console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`); + + if (componentCodeowners.size === 0) { + console.log('No component-specific codeowners found for changed files'); + // Remove label if present since there are no component codeowners + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`); + } catch (error) { + if (error.status !== 404) { + console.log(`Failed to remove label: ${error.message}`); + } + } + return; + } + + // Get all reviews on the PR + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number: pr_number + } + ); + + // Get the latest review per user (reviews are returned chronologically) + const latestReviewByUser = new Map(); + for (const review of reviews) { + // Skip bot reviews and comment-only reviews + if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue; + latestReviewByUser.set(review.user.login, review); + } + + // Check if any component-specific codeowner has an active approval + let hasCodeownerApproval = false; + for (const [login, review] of latestReviewByUser) { + if (review.state === 'APPROVED' && componentCodeowners.has(login)) { + console.log(`Codeowner '${login}' has approved`); + hasCodeownerApproval = true; + break; + } + } + + // Get current labels to check if label is already present + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: pr_number + }); + const hasLabel = currentLabels.some(label => label.name === LABEL_NAME); + + if (hasCodeownerApproval && !hasLabel) { + // Add the label + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr_number, + labels: [LABEL_NAME] + }); + console.log(`Added '${LABEL_NAME}' label`); + } else if (!hasCodeownerApproval && hasLabel) { + // Remove the label + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: LABEL_NAME + }); + console.log(`Removed '${LABEL_NAME}' label`); + } catch (error) { + if (error.status !== 404) { + console.log(`Failed to remove label: ${error.message}`); + } + } + } else { + console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`); + } + + } catch (error) { + console.error(error); + core.setFailed(`Failed to process codeowner approval label: ${error.message}`); + } diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 6f4351b298..02bf0e4a29 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -24,10 +24,17 @@ jobs: if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Request reviews from component codeowners uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | + const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); + const owner = context.repo.owner; const repo = context.repo.repo; const pr_number = context.payload.pull_request.number; @@ -38,12 +45,15 @@ jobs: const BOT_COMMENT_MARKER = ''; try { - // Get the list of changed files in this PR - const { data: files } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number: pr_number - }); + // Get the list of changed files in this PR (with pagination) + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); const changedFiles = files.map(file => file.filename); console.log(`Found ${changedFiles.length} changed files`); @@ -53,32 +63,10 @@ jobs: return; } - // Fetch CODEOWNERS file from root - const { data: codeownersFile } = await github.rest.repos.getContent({ - owner, - repo, - path: 'CODEOWNERS', - ref: context.payload.pull_request.base.sha - }); - const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + // Parse CODEOWNERS from the checked-out base branch + const codeownersPatterns = loadCodeowners(); - // Parse CODEOWNERS file to extract all patterns and their owners - const codeownersLines = codeownersContent.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - - const codeownersPatterns = []; - - // Convert CODEOWNERS pattern to regex (robust glob handling) - function globToRegex(pattern) { - // Escape regex special characters except for glob wildcards - let regexStr = pattern - .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars - .replace(/\*\*/g, '.*') // globstar - .replace(/\*/g, '[^/]*') // single star - .replace(/\?/g, '.'); // question mark - return new RegExp('^' + regexStr + '$'); - } + console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`); // Helper function to create comment body function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) { @@ -93,50 +81,11 @@ jobs: } } - for (const line of codeownersLines) { - const parts = line.split(/\s+/); - if (parts.length < 2) continue; - - const pattern = parts[0]; - const owners = parts.slice(1); - - // Use robust glob-to-regex conversion - const regex = globToRegex(pattern); - codeownersPatterns.push({ pattern, regex, owners }); - } - - console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`); - - // Match changed files against CODEOWNERS patterns - const matchedOwners = new Set(); - const matchedTeams = new Set(); - const fileMatches = new Map(); // Track which files matched which patterns - - for (const file of changedFiles) { - for (const { pattern, regex, owners } of codeownersPatterns) { - if (regex.test(file)) { - console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`); - - if (!fileMatches.has(file)) { - fileMatches.set(file, []); - } - fileMatches.get(file).push({ pattern, owners }); - - // Add owners to the appropriate set (remove @ prefix) - for (const owner of owners) { - const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner; - if (cleanOwner.includes('/')) { - // Team mention (org/team-name) - const teamName = cleanOwner.split('/')[1]; - matchedTeams.add(teamName); - } else { - // Individual user - matchedOwners.add(cleanOwner); - } - } - } - } - } + // Match changed files against CODEOWNERS patterns using last-match-wins semantics + const effective = getEffectiveOwners(changedFiles, codeownersPatterns); + const matchedOwners = effective.users; + const matchedTeams = effective.teams; + const matchedFileCount = effective.matchedFileCount; if (matchedOwners.size === 0 && matchedTeams.size === 0) { console.log('No codeowners found for any changed files'); @@ -170,11 +119,14 @@ jobs: } // Check for completed reviews to avoid re-requesting users who have already reviewed - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr_number - }); + const reviews = await github.paginate( + github.rest.pulls.listReviews, + { + owner, + repo, + pull_number: pr_number + } + ); const reviewedUsers = new Set(); reviews.forEach(review => { @@ -247,7 +199,7 @@ jobs: } const totalReviewers = reviewersList.length + teamsList.length; - console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`); + console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`); // Request reviews try { @@ -279,7 +231,7 @@ jobs: // Only add a comment if there are new codeowners to mention (not previously pinged) if (reviewersList.length > 0 || teamsList.length > 0) { - const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true); + const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true); await github.rest.issues.createComment({ owner, @@ -297,7 +249,7 @@ jobs: // Only try to add a comment if there are new codeowners to mention if (reviewersList.length > 0 || teamsList.length > 0) { - const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false); + const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false); try { await github.rest.issues.createComment({ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5d7c32eaa9..4bd018b5c9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index f23c2c870e..198b9a6b25 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -26,14 +26,19 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; + const author = context.payload.pull_request.user.login; + + // Skip bot PRs (e.g. dependabot) - they have their own title format + if (author === 'dependabot[bot]') { + return; + } // Block titles starting with "word:" or "word(scope):" patterns const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/; if (commitStylePattern.test(title)) { core.setFailed( `PR title should not start with a "prefix:" style format.\n` + - `Please use the format: [component] Brief description\n` + - `Example: [pn532] Add health checking and auto-reset` + `Please use the format: [component] Brief description\n` ); return; } diff --git a/CODEOWNERS b/CODEOWNERS index c7d2c6c46d..39e0815c28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,7 @@ esphome/components/mcp9808/* @k7hpn esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz +esphome/components/media_source/* @kahrendt esphome/components/micro_wake_word/* @jesserockz @kahrendt esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt @@ -411,7 +412,7 @@ esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 -esphome/components/rtttl/* @glmnet +esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8130/* @beormund diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b1e2252ce7..b855586152 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -186,8 +186,8 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( ) +@setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): - await setup_entity(var, config, "alarm_control_panel") for conf in config.get(CONF_ON_STATE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 08dbe46bd8..eb7c819eb9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -773,9 +773,9 @@ uint16_t APIConnection::try_send_number_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_number_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *number = static_cast(entity); ListEntitiesNumberResponse msg; - msg.unit_of_measurement = number->traits.get_unit_of_measurement_ref(); + msg.unit_of_measurement = number->get_unit_of_measurement_ref(); msg.mode = static_cast(number->traits.get_mode()); - msg.device_class = number->traits.get_device_class_ref(); + msg.device_class = number->get_device_class_ref(); msg.min_value = number->traits.get_min_value(); msg.max_value = number->traits.get_max_value(); msg.step = number->traits.get_step(); diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index acb1c4d9db..b141329e94 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_POWER_MODE, CONF_RANGE, + CONF_WATCHDOG, ) CODEOWNERS = ["@ammmze"] @@ -57,7 +58,6 @@ FAST_FILTER = { CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" -CONF_WATCHDOG = "watchdog" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_START_POSITION = "start_position" diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 1491852e07..e84733a484 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -23,7 +23,6 @@ AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingCompone CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" -CONF_WATCHDOG = "watchdog" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_PWM_FREQUENCY = "pwm_frequency" diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 036d78da73..1f64118560 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -60,7 +60,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -604,11 +608,9 @@ async def _build_binary_sensor_automations(var, config): ) +@setup_entity("binary_sensor") async def setup_binary_sensor_core_(var, config): - await setup_entity(var, config, "binary_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) trigger = config.get(CONF_TRIGGER_ON_INITIAL_STATE, False) or config.get( CONF_PUBLISH_INITIAL_STATE, False ) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 4b655e1bd1..6ae5d04bcb 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -30,7 +30,7 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi * The sub classes should notify the front-end of new states via the publish_state() method which * handles inverted inputs for you. */ -class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceClass { +class BinarySensor : public StatefulEntityBase { public: explicit BinarySensor(){}; diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 94816a0974..12d9ebaba6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_UPDATE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -84,15 +88,13 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +@setup_entity("button") async def setup_button_core_(var, config): - await setup_entity(var, config, "button") - for conf in config.get(CONF_ON_PRESS, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - if device_class := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index be6e080917..0f7576a419 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -22,7 +22,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o * * A button is just a momentary switch that does not have a state, only a trigger. */ -class Button : public EntityBase, public EntityBase_DeviceClass { +class Button : public EntityBase { public: /** Press this button. This is called by the front-end. * diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 2150a30c3e..1f449ad2a4 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -268,9 +268,8 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +@setup_entity("climate") async def setup_climate_core_(var, config): - await setup_entity(var, config, "climate") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3201db5dfd..059bf3f26a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,14 +8,17 @@ BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" +CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" +CONF_PARITY = "parity" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" ICON_CURRENT_DC = "mdi:current-dc" diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 17095f41f6..c330241f4d 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -37,7 +37,11 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, TemplateArgsType @@ -190,11 +194,9 @@ def cover_schema( return _COVER_SCHEMA.extend(schema) +@setup_entity("cover") async def setup_cover_core_(var, config): - await setup_entity(var, config, "cover") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if CONF_ON_OPEN in config: _LOGGER.warning( diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 0af48f75de..8cf9aa092a 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -107,7 +107,7 @@ const LogString *cover_operation_to_str(CoverOperation op); * to control all values of the cover. Also implement get_traits() to return what operations * the cover supports. */ -class Cover : public EntityBase, public EntityBase_DeviceClass { +class Cover : public EntityBase { public: explicit Cover(); diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 602db3827a..74c9d594f7 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -134,9 +134,8 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: return _DATETIME_SCHEMA.extend(schema) +@setup_entity("datetime") async def setup_datetime_core_(var, config): - await setup_entity(var, config, "datetime") - if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 59b791da40..46c000562e 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -22,8 +22,8 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { return (uint32_t) (esp_timer_get_time() / 1000ULL); } -uint64_t HOT millis_64() { return static_cast(esp_timer_get_time()) / 1000ULL; } +uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } @@ -48,6 +48,7 @@ void arch_init() { void HOT arch_feed_wdt() { esp_task_wdt_reset(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index b665124d66..159ec20e77 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -34,6 +34,9 @@ void HOT arch_feed_wdt() { system_soft_wdt_feed(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 8fac7a279c..14cc1505ad 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -18,7 +18,11 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@nohat"] @@ -85,17 +89,15 @@ def event_schema( return _EVENT_SCHEMA.extend(schema) +@setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): - await setup_entity(var, config, "event") - for conf in config.get(CONF_ON_EVENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) cg.add(var.set_event_types(event_types)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index a7451407bb..5b6a94b47c 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -20,7 +20,7 @@ namespace event { LOG_ENTITY_DEVICE_CLASS(TAG, prefix, *(obj)); \ } -class Event : public EntityBase, public EntityBase_DeviceClass { +class Event : public EntityBase { public: void trigger(const std::string &event_type); diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index e839df6aee..da28c577c8 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -222,9 +222,8 @@ def validate_preset_modes(value): return value +@setup_entity("fan") async def setup_fan_core_(var, config): - await setup_entity(var, config, "fan") - cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index fe11a93a4b..fe83b1ea7c 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -51,7 +51,7 @@ _RESTORING_SCHEMA = cv.Schema( def _globals_schema(config: ConfigType) -> ConfigType: """Select schema based on restore_value setting.""" - if config.get(CONF_RESTORE_VALUE, False): + if cv.boolean(config.get(CONF_RESTORE_VALUE, False)): return _RESTORING_SCHEMA(config) return _NON_RESTORING_SCHEMA(config) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index cb2b2e19d7..d5c61ec986 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,6 +59,7 @@ void HOT arch_feed_wdt() { } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { struct timespec spec; diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 5c759d6fd9..6a2a72fa5d 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -45,9 +45,9 @@ def infrared_schema(class_: type[cg.MockObjClass]) -> cv.Schema: ) +@setup_entity("infrared") async def setup_infrared_core_(var: cg.Pvariable, config: ConfigType) -> None: """Set up core infrared configuration.""" - await setup_entity(var, config, "infrared") async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 583918e5f5..eb17cc7de7 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -474,15 +474,12 @@ void LD2450Component::handle_periodic_data_() { is_moving = false; // tx is used for further calculations, so always needs to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - SAFE_PUBLISH_SENSOR(this->move_x_sensors_[index], tx); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - SAFE_PUBLISH_SENSOR(this->move_y_sensors_[index], ty); // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; - SAFE_PUBLISH_SENSOR(this->move_resolution_sensors_[index], res); #endif // SPEED start = TARGET_SPEED + index * 8; @@ -491,9 +488,6 @@ void LD2450Component::handle_periodic_data_() { is_moving = true; moving_target_count++; } -#ifdef USE_SENSOR - SAFE_PUBLISH_SENSOR(this->move_speed_sensors_[index], ts); -#endif // DISTANCE // Optimized: use already decoded tx and ty values, replace pow() with multiplication int32_t x_squared = (int32_t) tx * tx; @@ -503,10 +497,23 @@ void LD2450Component::handle_periodic_data_() { target_count++; } #ifdef USE_SENSOR - SAFE_PUBLISH_SENSOR(this->move_distance_sensors_[index], td); - // ANGLE - atan2f computes angle from Y axis directly, no sqrt/division needed - angle = atan2f(static_cast(-tx), static_cast(ty)) * (180.0f / std::numbers::pi_v); - SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle); + if (td == 0) { + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_x_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_y_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_resolution_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_speed_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_distance_sensors_[index]); + SAFE_PUBLISH_SENSOR_UNKNOWN(this->move_angle_sensors_[index]); + } else { + SAFE_PUBLISH_SENSOR(this->move_x_sensors_[index], tx); + SAFE_PUBLISH_SENSOR(this->move_y_sensors_[index], ty); + SAFE_PUBLISH_SENSOR(this->move_resolution_sensors_[index], res); + SAFE_PUBLISH_SENSOR(this->move_speed_sensors_[index], ts); + SAFE_PUBLISH_SENSOR(this->move_distance_sensors_[index], td); + // ANGLE - atan2f computes angle from Y axis directly, no sqrt/division needed + angle = atan2f(static_cast(-tx), static_cast(ty)) * (180.0f / std::numbers::pi_v); + SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle); + } #endif #ifdef USE_TEXT_SENSOR // DIRECTION diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 74b33a30a0..6bb2d9dcc1 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -7,6 +7,9 @@ #include "esphome/core/helpers.h" #include "preferences.h" +#include +#include + void setup(); void loop(); @@ -22,6 +25,22 @@ void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } void arch_init() { libretiny::setup_preferences(); lt_wdt_enable(10000L); +#ifdef USE_BK72XX + // BK72xx SDK creates the main Arduino task at priority 3, which is lower than + // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop + // stalls whenever WiFi background processing runs, because the main task + // cannot resume until every higher-priority task finishes. + // + // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). + // + // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the + // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. + // This is safe because ESPHome yields voluntarily via yield_with_select_() and + // the Arduino mainTask yield() after each loop() iteration. + static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; + static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); + vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); +#endif #if LT_GPIO_RECOVER lt_gpio_recover(); #endif @@ -36,6 +55,7 @@ void HOT arch_feed_wdt() { lt_wdt_feed(); } uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } } // namespace esphome diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 40382bbda7..4403281116 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -243,9 +243,8 @@ def validate_color_temperature_channels(value): return value -async def setup_light_core_(light_var, output_var, config): - await setup_entity(light_var, config, "light") - +@setup_entity("light") +async def setup_light_core_(light_var, config, output_var): cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: @@ -312,7 +311,7 @@ async def register_light(output_var, config): cg.add(cg.App.register_light(light_var)) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) - await setup_light_core_(light_var, output_var, config) + await setup_light_core_(light_var, config, output_var) async def new_light(config, *args): diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 9d893d3ad9..e37092756f 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -91,9 +91,8 @@ def lock_schema( return _LOCK_SCHEMA.extend(schema) +@setup_entity("lock") async def _setup_lock_core(var, config): - await setup_entity(var, config, "lock") - for conf in config.get(CONF_ON_LOCK, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 7fc5774b08..592b7faaf0 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -8,6 +8,7 @@ static constexpr const char *const TAG = "lps22"; static constexpr uint8_t WHO_AM_I = 0x0F; static constexpr uint8_t LPS22HB_ID = 0xB1; static constexpr uint8_t LPS22HH_ID = 0xB3; +static constexpr uint8_t LPS22DF_ID = 0xB4; static constexpr uint8_t CTRL_REG2 = 0x11; static constexpr uint8_t CTRL_REG2_ONE_SHOT_MASK = 0b1; static constexpr uint8_t STATUS = 0x27; @@ -24,8 +25,8 @@ static constexpr float TEMPERATURE_SCALE = 0.01f; void LPS22Component::setup() { uint8_t value = 0x00; this->read_register(WHO_AM_I, &value, 1); - if (value != LPS22HB_ID && value != LPS22HH_ID) { - ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB or LPS22HH ID", value); + if (value != LPS22HB_ID && value != LPS22HH_ID && value != LPS22DF_ID) { + ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB/HH/DF ID", value); this->mark_failed(); } } diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index b2afbe5e58..051e386eaf 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -96,8 +96,8 @@ VolumeSetAction = media_player_ns.class_( ) +@setup_entity("media_player") async def setup_media_player_core_(var, config): - await setup_entity(var, config, "media_player") for conf_key, _ in _STATE_TRIGGERS: for conf in config.get(conf_key, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/media_source/__init__.py b/esphome/components/media_source/__init__.py new file mode 100644 index 0000000000..43256db4af --- /dev/null +++ b/esphome/components/media_source/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObjClass + +CODEOWNERS = ["@kahrendt"] + +AUTO_LOAD = ["audio"] + +IS_PLATFORM_COMPONENT = True + +media_source_ns = cg.esphome_ns.namespace("media_source") + +MediaSource = media_source_ns.class_("MediaSource") + + +async def register_media_source(var, config): + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + CORE.register_platform_component("media_source", var) + return var + + +_MEDIA_SOURCE_SCHEMA = cv.Schema({}) + + +def media_source_schema( + class_: MockObjClass, +) -> cv.Schema: + schema = {cv.GenerateID(CONF_ID): cv.declare_id(class_)} + + return _MEDIA_SOURCE_SCHEMA.extend(schema) + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config): + cg.add_global(media_source_ns.using) + cg.add_define("USE_MEDIA_SOURCE") diff --git a/esphome/components/media_source/media_source.h b/esphome/components/media_source/media_source.h new file mode 100644 index 0000000000..688c27134f --- /dev/null +++ b/esphome/components/media_source/media_source.h @@ -0,0 +1,159 @@ +#pragma once + +#include "esphome/components/audio/audio.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::media_source { + +enum class MediaSourceState : uint8_t { + IDLE, // Not playing, ready to accept play_uri + PLAYING, // Currently playing media + PAUSED, // Playback paused, can be resumed + ERROR, // Error occurred during playback; sources are responsible for logging their own error details +}; + +/// @brief Commands that are sent from the orchestrator to a media source +enum class MediaSourceCommand : uint8_t { + // All sources should support these basic commands. + PLAY, + PAUSE, + STOP, + + // Only sources with internal playlists will handle these; simple sources should ignore them. + NEXT, + PREVIOUS, + CLEAR_PLAYLIST, + REPEAT_ALL, + REPEAT_ONE, + REPEAT_OFF, + SHUFFLE, + UNSHUFFLE, +}; + +/// @brief Callbacks from a MediaSource to its orchestrator +class MediaSourceListener { + public: + virtual ~MediaSourceListener() = default; + + // Callbacks that all sources use to send data and state changes to the orchestrator. + /// @brief Send audio data to the listener + virtual size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) = 0; + /// @brief Notify listener of state changes + virtual void report_state(MediaSourceState state) = 0; + + // Callbacks from smart sources requesting the orchestrator to change volume, mute, or start a new URI. + // Simple sources never invoke these. + /// @brief Request the orchestrator to change volume + virtual void request_volume(float volume) {} + /// @brief Request the orchestrator to change mute state + virtual void request_mute(bool is_muted) {} + /// @brief Request the orchestrator to play a new URI + virtual void request_play_uri(const std::string &uri) {} +}; + +/// @brief Abstract base class for media sources +/// MediaSource provides audio data to an orchestrator via the MediaSourceListener interface. It also receives commands +/// from the orchestrator to control playback. +class MediaSource { + public: + virtual ~MediaSource() = default; + + // === Playback Control === + + /// @brief Start playing the given URI + /// Sources should validate the URI and state, returning false if the source is busy. + /// The orchestrator is responsible for stopping active sources before starting a new one. + /// @param uri URI to play; e.g., "http://stream_url" + /// @return true if playback started successfully, false otherwise + virtual bool play_uri(const std::string &uri) = 0; + + /// @brief Handle playback commands (pause, stop, next, etc.) + /// @param command Command to execute + virtual void handle_command(MediaSourceCommand command) = 0; + + /// @brief Whether this source manages its own playlist internally + /// Smart sources that handle next/previous/repeat/shuffle should override this to return true. + virtual bool has_internal_playlist() const { return false; } + + // === State Access === + + /// @brief Get current playback state (must only be called from the main loop) + /// @return Current state of this source + MediaSourceState get_state() const { return this->state_; } + + // === URI Matching === + + /// @brief Check if this source can handle the given URI + /// Each source must override this to match its supported URI scheme(s). + /// @param uri URI to check + /// @return true if this source can handle the URI + virtual bool can_handle(const std::string &uri) const = 0; + + // === Listener: Source -> Orchestrator === + + /// @brief Set the listener that receives callbacks from this source + /// @param listener Pointer to the MediaSourceListener implementation + void set_listener(MediaSourceListener *listener) { this->listener_ = listener; } + + /// @brief Check if a listener has been registered + bool has_listener() const { return this->listener_ != nullptr; } + + /// @brief Write audio data to the listener + /// @param data Pointer to audio data buffer (not modified by this method) + /// @param length Number of bytes to write + /// @param timeout_ms Milliseconds to wait if the listener can't accept data immediately + /// @param stream_info Audio stream format information + /// @return Number of bytes written, or 0 if no listener is set + size_t write_output(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + if (this->listener_ != nullptr) { + return this->listener_->write_audio(data, length, timeout_ms, stream_info); + } + return 0; + } + + // === Callbacks: Orchestrator -> Source === + + /// @brief Notify the source that volume changed + /// Simple sources ignore this. Override for smart sources that track volume state. + /// @param volume New volume level (0.0 to 1.0) + virtual void notify_volume_changed(float volume) {} + + /// @brief Notify the source that mute state changed + /// Simple sources ignore this. Override for smart sources that track mute state. + /// @param is_muted New mute state + virtual void notify_mute_changed(bool is_muted) {} + + /// @brief Notify the source about audio that has been played + /// Called when the speaker reports that audio frames have been written to the DAC. + /// Sources can override this to track playback progress for synchronization. + /// @param frames Number of audio frames that were played + /// @param timestamp System time in microseconds when the frames finished writing to the DAC + virtual void notify_audio_played(uint32_t frames, int64_t timestamp) {} + + protected: + /// @brief Update state and notify listener (must only be called from the main loop) + /// This is the only way to change state_, ensuring listener notifications always fire. + /// Sources running FreeRTOS tasks should signal via event groups and call this from loop(). + /// @param state New state to set + void set_state_(MediaSourceState state) { + if (this->state_ != state) { + this->state_ = state; + if (this->listener_ != nullptr) { + this->listener_->report_state(state); + } + } + } + + private: + // Private to enforce the invariant that listener notifications always fire on state changes. + // All state transitions must go through set_state_() which couples the update with notification. + MediaSourceState state_{MediaSourceState::IDLE}; + MediaSourceListener *listener_{nullptr}; +}; + +} // namespace esphome::media_source diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index fdc909fcc9..a2734f2beb 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -48,7 +48,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MAX] = traits.get_max_value(); root[MQTT_STEP] = traits.get_step(); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto unit_of_measurement = this->number_->traits.get_unit_of_measurement_ref(); + const auto unit_of_measurement = this->number_->get_unit_of_measurement_ref(); if (!unit_of_measurement.empty()) { root[MQTT_UNIT_OF_MEASUREMENT] = unit_of_measurement; } @@ -57,7 +57,7 @@ void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_MODE] = NumberMqttModeStrings::get_progmem_str(static_cast(mode), static_cast(NUMBER_MODE_BOX)); } - const auto device_class = this->number_->traits.get_device_class_ref(); + const auto device_class = this->number_->get_device_class_ref(); if (!device_class.empty()) { root[MQTT_DEVICE_CLASS] = device_class; } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 2238f2c037..0570ac0b1e 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,7 +79,12 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -257,11 +262,10 @@ async def _build_number_automations(var, config): await automation.build_automation(trigger, [(float, "x")], conf) +@setup_entity("number") async def setup_number_core_( var, config, *, min_value: float, max_value: float, step: float ): - await setup_entity(var, config, "number") - cg.add(var.traits.set_min_value(min_value)) cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) @@ -273,10 +277,8 @@ async def setup_number_core_( CORE.add_job(_build_number_automations, var, config) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.traits.set_unit_of_measurement(unit_of_measurement)) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.traits.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 1c4126496c..c0653c3b30 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -15,8 +15,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); LOG_ENTITY_ICON(tag, prefix, *obj); - LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj->traits); - LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj->traits); + LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, *obj); + LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj); } void Number::publish_state(float state) { diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index 5ccbb9ba48..f855813c9b 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -1,7 +1,7 @@ #pragma once -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" +#include +#include namespace esphome::number { @@ -11,7 +11,7 @@ enum NumberMode : uint8_t { NUMBER_MODE_SLIDER = 2, }; -class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class NumberTraits { public: // Set/get the number value boundaries. void set_min_value(float min_value) { min_value_ = min_value; } diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 5861c3db3f..5c64cf31dc 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -4,13 +4,20 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, + get_esp32_variant, include_builtin_idf_component, only_on_variant, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID, CONF_USE_ADDRESS +from esphome.const import ( + CONF_CHANNEL, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_OUTPUT_POWER, + CONF_USE_ADDRESS, +) from esphome.core import CORE, TimePeriodMilliseconds import esphome.final_validate as fv from esphome.types import ConfigType @@ -45,6 +52,20 @@ CONF_DEVICE_TYPES = [ ] +def _validate_txpower(value): + if CORE.is_esp32: + variant = get_esp32_variant() + + # HW limits: Datasheet section "802.15.4 RF Transmitter (TX) Characteristics" + # Further regulatory/soft limit may apply, e.g. by region + if variant in (VARIANT_ESP32C6, VARIANT_ESP32C5): + return cv.int_range(min=-15, max=20)(value) + if variant == VARIANT_ESP32H2: + return cv.int_range(min=-24, max=20)(value) + + return value # Unsupported, fail later with clear error + + def set_sdkconfig_options(config): # and expose options for using SPI/UART RCPs add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) @@ -155,6 +176,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TLV): cv.string_strict, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_OUTPUT_POWER): cv.All( + cv.decibel, + _validate_txpower, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -197,4 +222,7 @@ async def to_code(config): cg.add(srp.set_mdns(mdns_component)) await cg.register_component(srp, config) + if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: + cg.add(ot.set_output_power(output_power)) + set_sdkconfig_options(config) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index d22a14aeae..92897a7e96 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -43,6 +43,9 @@ void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, " Device is configured as Minimal End Device (MED)"); } #endif + if (this->output_power_.has_value()) { + ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); + } } bool OpenThreadComponent::is_connected() { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 9e429f289b..728847afa5 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -38,6 +38,7 @@ class OpenThreadComponent : public Component { #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif + void set_output_power(int8_t output_power) { this->output_power_ = output_power; } protected: std::optional get_omr_address_(InstanceLock &lock); @@ -45,6 +46,7 @@ class OpenThreadComponent : public Component { #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif + std::optional output_power_{}; bool teardown_started_{false}; bool teardown_complete_{false}; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 9dd68a1ccc..2af78b729f 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -135,6 +135,12 @@ void OpenThreadComponent::ot_main() { TRUEFALSE(link_mode_config.mRxOnWhenIdle)); #endif + if (this->output_power_.has_value()) { + if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); + } + } + // Run the main loop #if CONFIG_OPENTHREAD_CLI esp_openthread_cli_create_task(); diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 6386d53292..63b154d80d 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -11,8 +11,8 @@ namespace esphome { void HOT yield() { ::yield(); } -uint64_t millis_64() { return time_us_64() / 1000ULL; } -uint32_t HOT millis() { return static_cast(millis_64()); } +uint64_t millis_64() { return micros_to_millis(time_us_64()); } +uint32_t HOT millis() { return micros_to_millis(time_us_64()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t HOT micros() { return ::micros(); } void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } @@ -34,6 +34,9 @@ void HOT arch_feed_wdt() { watchdog_update(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } +const char *progmem_read_ptr(const char *const *addr) { + return reinterpret_cast(pgm_read_ptr(addr)); // NOLINT +} uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index ebbe5366aa..19412bb454 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -17,7 +17,7 @@ import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@ximex"] CONF_RTTTL = "rtttl" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c114b140a9..b2c17f59ac 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -92,9 +92,8 @@ def select_schema( return _SELECT_SCHEMA.extend(schema) +@setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - await setup_entity(var, config, "select") - cg.add(var.traits.set_options(options)) for conf in config.get(CONF_ON_VALUE, []): diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 338aaae0b5..4be6ed1b84 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -106,7 +106,12 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, + setup_unit_of_measurement, +) from esphome.cpp_generator import MockObj, MockObjClass from esphome.util import Registry @@ -908,15 +913,12 @@ async def _build_sensor_automations(var, config): await automation.build_automation(trigger, [(float, "x")], conf) +@setup_entity("sensor") async def setup_sensor_core_(var, config): - await setup_entity(var, config, "sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) + setup_unit_of_measurement(config) if (state_class := config.get(CONF_STATE_CLASS)) is not None: cg.add(var.set_state_class(state_class)) - if (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None: - cg.add(var.set_unit_of_measurement(unit_of_measurement)) if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: cg.add(var.set_accuracy_decimals(accuracy_decimals)) # Only set force_update if True (default is False) diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 54e75ee2a1..197896f6f6 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -44,7 +44,7 @@ const LogString *state_class_to_string(StateClass state_class); * * A sensor has unit of measurement and can use publish_state to send out a new value with the specified accuracy. */ -class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBase_UnitOfMeasurement { +class Sensor : public EntityBase { public: explicit Sensor(); diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 92ecfc692b..aea7c776c6 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -11,11 +11,15 @@ namespace esphome::socket { BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - // Register new socket with the application for select() if monitoring requested - if (monitor_loop && this->fd_ >= 0) { - // Only set loop_monitored_ to true if registration succeeds - this->loop_monitored_ = App.register_socket_fd(this->fd_); - } + if (!monitor_loop || this->fd_ < 0) + return; +#ifdef USE_LWIP_FAST_SELECT + // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + this->cached_sock_ = esphome_lwip_get_sock(this->fd_); + this->loop_monitored_ = App.register_socket(this->cached_sock_); +#else + this->loop_monitored_ = App.register_socket_fd(this->fd_); +#endif } BSDSocketImpl::~BSDSocketImpl() { @@ -26,10 +30,17 @@ BSDSocketImpl::~BSDSocketImpl() { int BSDSocketImpl::close() { if (!this->closed_) { - // Unregister from select() before closing if monitored + // Unregister before closing to avoid dangling pointer in monitored set +#ifdef USE_LWIP_FAST_SELECT + if (this->loop_monitored_) { + App.unregister_socket(this->cached_sock_); + this->cached_sock_ = nullptr; + } +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } +#endif int ret = ::close(this->fd_); this->closed_ = true; return ret; @@ -48,8 +59,6 @@ int BSDSocketImpl::setblocking(bool blocking) { return 0; } -bool BSDSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); } - size_t BSDSocketImpl::getpeername_to(std::span buf) { struct sockaddr_storage storage; socklen_t len = sizeof(storage); @@ -86,14 +95,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -std::unique_ptr socket_listen(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, false); -} - -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, true); -} - } // namespace esphome::socket #endif // USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index d9ed9dc567..9ebbe72002 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -13,6 +13,10 @@ #include #endif +#ifdef USE_LWIP_FAST_SELECT +struct lwip_sock; +#endif + namespace esphome::socket { class BSDSocketImpl { @@ -105,6 +109,9 @@ class BSDSocketImpl { protected: int fd_{-1}; +#ifdef USE_LWIP_FAST_SELECT + struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() +#endif bool closed_{false}; bool loop_monitored_{false}; }; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 430356592f..d697bd47a5 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -36,7 +36,7 @@ void socket_delay(uint32_t ms) { esp_delay(ms, []() { return !s_socket_woke; }); } -void socket_wake() { +void IRAM_ATTR socket_wake() { s_socket_woke = true; esp_schedule(); } diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index 0322820ef4..2fad429e0f 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -11,11 +11,15 @@ namespace esphome::socket { LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) { this->fd_ = fd; - // Register new socket with the application for select() if monitoring requested - if (monitor_loop && this->fd_ >= 0) { - // Only set loop_monitored_ to true if registration succeeds - this->loop_monitored_ = App.register_socket_fd(this->fd_); - } + if (!monitor_loop || this->fd_ < 0) + return; +#ifdef USE_LWIP_FAST_SELECT + // Cache lwip_sock pointer and register for monitoring (hooks callback internally) + this->cached_sock_ = esphome_lwip_get_sock(this->fd_); + this->loop_monitored_ = App.register_socket(this->cached_sock_); +#else + this->loop_monitored_ = App.register_socket_fd(this->fd_); +#endif } LwIPSocketImpl::~LwIPSocketImpl() { @@ -26,10 +30,17 @@ LwIPSocketImpl::~LwIPSocketImpl() { int LwIPSocketImpl::close() { if (!this->closed_) { - // Unregister from select() before closing if monitored + // Unregister before closing to avoid dangling pointer in monitored set +#ifdef USE_LWIP_FAST_SELECT + if (this->loop_monitored_) { + App.unregister_socket(this->cached_sock_); + this->cached_sock_ = nullptr; + } +#else if (this->loop_monitored_) { App.unregister_socket_fd(this->fd_); } +#endif int ret = lwip_close(this->fd_); this->closed_ = true; return ret; @@ -48,8 +59,6 @@ int LwIPSocketImpl::setblocking(bool blocking) { return 0; } -bool LwIPSocketImpl::ready() const { return socket_ready_fd(this->fd_, this->loop_monitored_); } - size_t LwIPSocketImpl::getpeername_to(std::span buf) { struct sockaddr_storage storage; socklen_t len = sizeof(storage); @@ -86,14 +95,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -std::unique_ptr socket_listen(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, false); -} - -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { - return create_socket(domain, type, protocol, true); -} - } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index d6699aded2..c579219863 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -9,6 +9,10 @@ #include "esphome/core/helpers.h" #include "headers.h" +#ifdef USE_LWIP_FAST_SELECT +struct lwip_sock; +#endif + namespace esphome::socket { class LwIPSocketImpl { @@ -71,6 +75,9 @@ class LwIPSocketImpl { protected: int fd_{-1}; +#ifdef USE_LWIP_FAST_SELECT + struct lwip_sock *cached_sock_{nullptr}; // Cached for direct rcvevent read in ready() +#endif bool closed_{false}; bool loop_monitored_{false}; }; diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index c04671c7ee..bfb6ae8e13 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,7 +8,7 @@ namespace esphome::socket { -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) // Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). // Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); } @@ -89,6 +89,9 @@ std::unique_ptr socket_ip(int type, int protocol) { #endif /* USE_NETWORK_IPV6 */ } +#ifdef USE_SOCKET_IMPL_LWIP_TCP +// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. +// BSD and LWIP_SOCKETS define this inline in socket.h. std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { #if USE_NETWORK_IPV6 return socket_listen_loop_monitored(AF_INET6, type, protocol); @@ -96,6 +99,7 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { return socket_listen_loop_monitored(AF_INET, type, protocol); #endif /* USE_NETWORK_IPV6 */ } +#endif socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 86a4f0cba9..0884e4ba3e 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -6,6 +6,10 @@ #include "esphome/core/optional.h" #include "headers.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif + #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) // Include only the active implementation's header. @@ -36,12 +40,29 @@ using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; #endif -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT +/// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. +inline bool socket_ready(struct lwip_sock *cached_sock, bool loop_monitored) { + return !loop_monitored || (cached_sock != nullptr && esphome_lwip_socket_has_data(cached_sock)); +} +#elif defined(USE_SOCKET_SELECT_SUPPORT) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); #endif +// Inline ready() — defined here because it depends on socket_ready/socket_ready_fd +// declared above, while the impl headers are included before those declarations. +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +inline bool Socket::ready() const { +#ifdef USE_LWIP_FAST_SELECT + return socket_ready(this->cached_sock_, this->loop_monitored_); +#else + return socket_ready_fd(this->fd_, this->loop_monitored_); +#endif +} +#endif + /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. @@ -56,11 +77,29 @@ std::unique_ptr socket_ip(int type, int protocol); std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); /// Create a listening socket of the given domain, type and protocol. -std::unique_ptr socket_listen(int domain, int type, int protocol); /// Create a listening socket and monitor it for data in the main loop. -std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); /// Create a listening socket in the newest available IP domain and monitor it. +#ifdef USE_SOCKET_IMPL_LWIP_TCP +// LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. +std::unique_ptr socket_listen(int domain, int type, int protocol); +std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); std::unique_ptr socket_ip_loop_monitored(int type, int protocol); +#else +// BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. +inline std::unique_ptr socket_listen(int domain, int type, int protocol) { + return socket(domain, type, protocol); +} +inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { + return socket_loop_monitored(domain, type, protocol); +} +inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { +#if USE_NETWORK_IPV6 + return socket_loop_monitored(AF_INET6, type, protocol); +#else + return socket_loop_monitored(AF_INET, type, protocol); +#endif +} +#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure @@ -86,8 +125,9 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s /// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. void socket_delay(uint32_t ms); -/// Called by lwip callbacks to signal socket activity and wake delay. -void socket_wake(); +/// Signal socket/IO activity and wake the main loop from esp_delay() early. +/// ISR-safe: uses IRAM_ATTR internally and only sets a volatile flag + esp_schedule(). +void socket_wake(); // NOLINT(readability-redundant-declaration) #endif } // namespace esphome::socket diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b302bd9b23..42ca762858 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_FORMAT, CONF_ID, CONF_NUM_CHANNELS, + CONF_ON_TURN_OFF, + CONF_ON_TURN_ON, CONF_PATH, CONF_RAW_DATA_ID, CONF_SAMPLE_RATE, @@ -401,6 +403,9 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config): + if CONF_ON_TURN_OFF in config or CONF_ON_TURN_ON in config: + cg.add_define("USE_SPEAKER_MEDIA_PLAYER_ON_OFF", True) + var = await media_player.new_media_player(config) await cg.register_component(var, config) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fdf6bf66cd..3f5cb2fda6 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -51,7 +51,11 @@ static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; static const char *const TAG = "speaker_media_player"; void SpeakerMediaPlayer::setup() { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + state = media_player::MEDIA_PLAYER_STATE_OFF; +#else state = media_player::MEDIA_PLAYER_STATE_IDLE; +#endif this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); @@ -128,6 +132,12 @@ void SpeakerMediaPlayer::watch_media_commands_() { bool enqueue = media_command.enqueue.has_value() && media_command.enqueue.value(); if (media_command.url.has_value() || media_command.file.has_value()) { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + publish_state(); + } +#endif PlaylistItem playlist_item; if (media_command.url.has_value()) { playlist_item.url = *media_command.url.value(); @@ -184,6 +194,12 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (media_command.command.has_value()) { switch (media_command.command.value()) { case media_player::MEDIA_PLAYER_COMMAND_PLAY: +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + publish_state(); + } +#endif if ((this->media_pipeline_ != nullptr) && (this->is_paused_)) { this->media_pipeline_->set_pause_state(false); } @@ -195,10 +211,26 @@ void SpeakerMediaPlayer::watch_media_commands_() { } this->is_paused_ = true; break; +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + case media_player::MEDIA_PLAYER_COMMAND_TURN_ON: + if (this->state == media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_ON; + this->publish_state(); + } + break; + case media_player::MEDIA_PLAYER_COMMAND_TURN_OFF: + this->is_turn_off_ = true; + // Intentional Fall-through +#endif case media_player::MEDIA_PLAYER_COMMAND_STOP: // Pipelines do not stop immediately after calling the stop command, so confirm its stopped before unpausing. // This avoids an audible short segment playing after receiving the stop command in a paused state. +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value()) || + (this->is_turn_off_ && this->announcement_pipeline_state_ != AudioPipelineState::STOPPED)) { +#else if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { +#endif if (this->announcement_pipeline_ != nullptr) { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); @@ -366,7 +398,13 @@ void SpeakerMediaPlayer::loop() { } } else { if (this->is_paused_) { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; + } +#else this->state = media_player::MEDIA_PLAYER_STATE_PAUSED; +#endif } else if (this->media_pipeline_state_ == AudioPipelineState::PLAYING) { this->state = media_player::MEDIA_PLAYER_STATE_PLAYING; } else if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { @@ -399,7 +437,13 @@ void SpeakerMediaPlayer::loop() { } } } else { +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + } +#else this->state = media_player::MEDIA_PLAYER_STATE_IDLE; +#endif } } } @@ -409,6 +453,20 @@ void SpeakerMediaPlayer::loop() { this->publish_state(); ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); } +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + if (this->is_turn_off_ && (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED || + this->state == media_player::MEDIA_PLAYER_STATE_IDLE)) { + this->is_turn_off_ = false; + if (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED) { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } + this->state = media_player::MEDIA_PLAYER_STATE_OFF; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } +#endif } void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { @@ -481,6 +539,9 @@ media_player::MediaPlayerTraits SpeakerMediaPlayer::get_traits() { if (!this->single_pipeline_()) { traits.set_supports_pause(true); } +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + traits.set_supports_turn_off_on(true); +#endif if (this->announcement_format_.has_value()) { traits.get_supported_formats().push_back(this->announcement_format_.value()); diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6796fc9c00..3fa6f47b84 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -144,6 +144,9 @@ class SpeakerMediaPlayer : public Component, bool is_paused_{false}; bool is_muted_{false}; +#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF + bool is_turn_off_{false}; +#endif uint8_t unpause_media_remaining_{0}; uint8_t unpause_announcement_remaining_{0}; diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index d82d7baaf6..44fb9092bc 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -567,7 +567,7 @@ void Sprinkler::set_valve_run_duration(const optional valve_number, cons return; } auto call = this->valve_[valve_number.value()].run_duration_number->make_call(); - if (this->valve_[valve_number.value()].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number.value()].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { call.set_value(run_duration.value() / 60.0); } else { call.set_value(run_duration.value()); @@ -649,7 +649,7 @@ uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { return 0; } if (this->valve_[valve_number].run_duration_number != nullptr) { - if (this->valve_[valve_number].run_duration_number->traits.get_unit_of_measurement_ref() == MIN_STR) { + if (this->valve_[valve_number].run_duration_number->get_unit_of_measurement_ref() == MIN_STR) { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state * 60)); } else { return static_cast(roundf(this->valve_[valve_number].run_duration_number->state)); diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 6f1be7d53d..bbafc54bd1 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_SWITCH, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -154,9 +158,8 @@ async def _build_switch_automations(var, config): await automation.build_automation(trigger, [], conf) +@setup_entity("switch") async def setup_switch_core_(var, config): - await setup_entity(var, config, "switch") - if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) @@ -169,8 +172,7 @@ async def setup_switch_core_(var, config): if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) await zigbee.setup_switch(var, config) diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 982c640cf9..c4f8525793 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -35,7 +35,7 @@ enum SwitchRestoreMode : uint8_t { * A switch is basically just a combination of a binary sensor (for reporting switch values) * and a write_state method that writes a state to the hardware. */ -class Switch : public EntityBase, public EntityBase_DeviceClass { +class Switch : public EntityBase { public: explicit Switch(); diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 61f7119cad..224f4580d4 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -84,6 +84,7 @@ def text_schema( return _TEXT_SCHEMA.extend(schema) +@setup_entity("text") async def setup_text_core_( var, config, @@ -92,8 +93,6 @@ async def setup_text_core_( max_length: int | None, pattern: str | None, ): - await setup_entity(var, config, "text") - cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 2edf202cd2..97f394ecf7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -21,7 +21,11 @@ from esphome.const import ( DEVICE_CLASS_TIMESTAMP, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -208,11 +212,9 @@ async def _build_text_sensor_automations(var, config): await automation.build_automation(trigger, [(cg.std_string, "x")], conf) +@setup_entity("text_sensor") async def setup_text_sensor_core_(var, config): - await setup_entity(var, config, "text_sensor") - - if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: - cg.add(var.set_device_class(device_class)) + setup_device_class(config) if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 9916aa63b2..d26cfade96 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -25,7 +25,7 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text public: \ void set_##name##_text_sensor(text_sensor::TextSensor *text_sensor) { this->name##_text_sensor_ = text_sensor; } -class TextSensor : public EntityBase, public EntityBase_DeviceClass { +class TextSensor : public EntityBase { public: std::string state; diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 69db4b44aa..3bc4263b31 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -4,6 +4,7 @@ import re from esphome import automation, pins import esphome.codegen as cg +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -11,7 +12,6 @@ from esphome.const import ( CONF_BAUD_RATE, CONF_BYTES, CONF_DATA, - CONF_DATA_BITS, CONF_DEBUG, CONF_DELIMITER, CONF_DIRECTION, @@ -21,12 +21,10 @@ from esphome.const import ( CONF_ID, CONF_LAMBDA, CONF_NUMBER, - CONF_PARITY, CONF_PORT, CONF_RX_BUFFER_SIZE, CONF_RX_PIN, CONF_SEQUENCE, - CONF_STOP_BITS, CONF_TIMEOUT, CONF_TRIGGER_ID, CONF_TX_PIN, diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index e146f7e685..c36a4ab769 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -15,7 +15,11 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@jesserockz"] @@ -87,11 +91,9 @@ def update_schema( return _UPDATE_SCHEMA.extend(schema) +@setup_entity("update") async def setup_update_core_(var, config): - await setup_entity(var, config, "update") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): await automation.build_automation( diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index 405346bee4..82eaacaf76 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -29,7 +29,7 @@ enum UpdateState : uint8_t { const LogString *update_state_to_string(UpdateState state); -class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { +class UpdateEntity : public EntityBase { public: void publish_state(); diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index cc69c0cb5a..f0ee53d028 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import socket +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import UARTComponent from esphome.components.usb_host import register_usb_client, usb_device_schema import esphome.config_validation as cv @@ -7,12 +8,9 @@ from esphome.const import ( CONF_BAUD_RATE, CONF_BUFFER_SIZE, CONF_CHANNELS, - CONF_DATA_BITS, CONF_DEBUG, CONF_DUMMY_RECEIVER, CONF_ID, - CONF_PARITY, - CONF_STOP_BITS, ) from esphome.cpp_types import Component diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 7fa964c0cb..e6e52a9e2a 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -75,6 +75,15 @@ void USBUartTypeCH34X::enable_channels() { } this->start_channels(); } + +std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { + auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare + for (auto &cdc_dev : result) { + cdc_dev.interrupt_interface_number = 0xFF; + } + return result; +} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index de81bfc587..5c0397b2cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -20,6 +20,7 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in // look for an interface with an interrupt endpoint (notify), and one with two bulk endpoints (data in/out) CdcEps eps{}; eps.bulk_interface_number = 0xFF; + eps.interrupt_interface_number = 0xFF; for (;;) { const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset); if (!intf_desc) { @@ -130,7 +131,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { } void USBUartChannel::write_array(const uint8_t *data, size_t len) { if (!this->initialised_.load()) { - ESP_LOGV(TAG, "Channel not initialised - write ignored"); + ESP_LOGD(TAG, "Channel not initialised - write ignored"); return; } #ifdef USE_UART_DEBUGGER @@ -415,14 +416,15 @@ void USBUartTypeCdcAcm::on_connected() { // Claim the communication (interrupt) interface so CDC class requests are accepted // by the device. Some CDC ACM implementations (e.g. EFR32 NCP) require this before // they enable data flow on the bulk endpoints. - if (channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { + if (channel->cdc_dev_.interrupt_interface_number != 0xFF && + channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); if (err_comm != ESP_OK) { ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, esp_err_to_name(err_comm)); + channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway } else { - channel->cdc_dev_.comm_interface_claimed = true; ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); } } @@ -436,6 +438,7 @@ void USBUartTypeCdcAcm::on_connected() { return; } } + this->status_clear_error(); this->enable_channels(); } @@ -453,9 +456,10 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } - if (channel->cdc_dev_.comm_interface_claimed) { + if (channel->cdc_dev_.interrupt_interface_number != 0xFF && + channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.comm_interface_claimed = false; + channel->cdc_dev_.interrupt_interface_number = 0xFF; } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); // Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9a9fe1c2ca..0d471e46f6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -32,7 +32,6 @@ struct CdcEps { const usb_ep_desc_t *out_ep; uint8_t bulk_interface_number; uint8_t interrupt_interface_number; - bool comm_interface_claimed{false}; }; enum UARTParityOptions { @@ -192,6 +191,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { protected: void enable_channels() override; + std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; }; } // namespace esphome::usb_uart diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 73e907eb0f..22cd01988d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( DEVICE_CLASS_WATER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + setup_device_class, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -129,11 +133,9 @@ def valve_schema( return _VALVE_SCHEMA.extend(schema) +@setup_entity("valve") async def _setup_valve_core(var, config): - await setup_entity(var, config, "valve") - - if device_class_config := config.get(CONF_DEVICE_CLASS): - cg.add(var.set_device_class(device_class_config)) + setup_device_class(config) for conf in config.get(CONF_ON_OPEN, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index cd46144372..aab819a778 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -101,7 +101,7 @@ const LogString *valve_operation_to_str(ValveOperation op); * to control all values of the valve. Also implement get_traits() to return what operations * the valve supports. */ -class Valve : public EntityBase, public EntityBase_DeviceClass { +class Valve : public EntityBase { public: explicit Valve(); diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index db32c2d919..58cf5a4054 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -69,10 +69,9 @@ def water_heater_schema( return _WATER_HEATER_SCHEMA.extend(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++.""" - await setup_entity(var, config, "water_heater") - visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 47e427c0d1..6b94a103cc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1139,7 +1139,7 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v json::JsonBuilder builder; JsonObject root = builder.root(); - const auto uom_ref = obj->traits.get_unit_of_measurement_ref(); + const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); // Need two buffers: one for value, one for state with UOM diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 66cd4ce12a..bc80f167ef 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -1,18 +1,16 @@ import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, CONF_CHANNEL, - CONF_DATA_BITS, CONF_ID, CONF_INPUT, CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, - CONF_PARITY, - CONF_STOP_BITS, ) CODEOWNERS = ["@DrCoolZic"] diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index cf3ea70245..eee7fb3f4f 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -60,6 +60,7 @@ void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } +const char *progmem_read_ptr(const char *const *addr) { return *addr; } uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; } Mutex::Mutex() { diff --git a/esphome/const.py b/esphome/const.py index 7262a106d8..d5625f6a54 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -280,7 +280,6 @@ CONF_CUSTOM_PRESETS = "custom_presets" CONF_CYCLE = "cycle" CONF_DALLAS_ID = "dallas_id" CONF_DATA = "data" -CONF_DATA_BITS = "data_bits" CONF_DATA_PIN = "data_pin" CONF_DATA_PINS = "data_pins" CONF_DATA_RATE = "data_rate" @@ -760,7 +759,6 @@ CONF_PAGE_ID = "page_id" CONF_PAGES = "pages" CONF_PANASONIC = "panasonic" CONF_PARAMETERS = "parameters" -CONF_PARITY = "parity" CONF_PASSWORD = "password" CONF_PATH = "path" CONF_PATTERN = "pattern" @@ -963,7 +961,6 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" -CONF_STOP_BITS = "stop_bits" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" @@ -1094,6 +1091,7 @@ CONF_WAND_ID = "wand_id" CONF_WARM_WHITE = "warm_white" CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature" CONF_WARMUP_TIME = "warmup_time" +CONF_WATCHDOG = "watchdog" CONF_WATCHDOG_THRESHOLD = "watchdog_threshold" CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" CONF_WATER_HEATER = "water_heater" diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 26cd670629..8c2ba58c86 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -552,7 +552,32 @@ void Application::after_loop_tasks_() { this->in_loop_ = false; } -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT +bool Application::register_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + if (sock == nullptr) + return false; + esphome_lwip_hook_socket(sock); + this->monitored_sockets_.push_back(sock); + return true; +} + +void Application::unregister_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { + if (this->monitored_sockets_[i] != sock) + continue; + + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback — all LwIP sockets share the same + // static event_callback, and the socket will be closed by the caller. + if (i < this->monitored_sockets_.size() - 1) + this->monitored_sockets_[i] = this->monitored_sockets_.back(); + this->monitored_sockets_.pop_back(); + return; + } +} +#elif defined(USE_SOCKET_SELECT_SUPPORT) bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -571,15 +596,10 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); -#ifdef USE_LWIP_FAST_SELECT - // Hook the socket's netconn callback for instant wake on receive events - esphome_lwip_hook_socket(fd); -#else this->socket_fds_changed_ = true; if (fd > this->max_fd_) { this->max_fd_ = fd; } -#endif return true; } @@ -595,13 +615,9 @@ void Application::unregister_socket_fd(int fd) { continue; // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback on fast select platforms — all LwIP - // sockets share the same static event_callback, and the socket will be closed - // by the caller. if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); -#ifndef USE_LWIP_FAST_SELECT this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { @@ -611,7 +627,6 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } -#endif return; } } @@ -621,7 +636,7 @@ void Application::unregister_socket_fd(int fd) { void Application::yield_with_select_(uint32_t delay_ms) { // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) - // Fast path (ESP32/LibreTiny): reads rcvevent directly via lwip_socket_dbg_get_socket(). + // Fast path (ESP32/LibreTiny): reads rcvevent directly from cached lwip_sock pointers. // Safe because this runs on the main loop which owns socket lifetime (create, read, close). if (delay_ms == 0) [[unlikely]] { yield(); @@ -632,8 +647,8 @@ void Application::yield_with_select_(uint32_t delay_ms) { // If a socket still has unread data (rcvevent > 0) but the task notification was already // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. // This scan preserves select() semantics: return immediately when any fd is ready. - for (int fd : this->socket_fds_) { - if (esphome_lwip_socket_has_data(fd)) { + for (struct lwip_sock *sock : this->monitored_sockets_) { + if (esphome_lwip_socket_has_data(sock)) { yield(); return; } diff --git a/esphome/core/application.h b/esphome/core/application.h index af6dde6a0c..554e745234 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -34,7 +34,11 @@ #endif #endif #endif // USE_SOCKET_SELECT_SUPPORT - +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +namespace esphome::socket { +void socket_wake(); // NOLINT(readability-redundant-declaration) +} // namespace esphome::socket +#endif #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif @@ -510,14 +514,20 @@ class Application { Scheduler scheduler; - /// Register/unregister a socket file descriptor to be monitored for read events. -#ifdef USE_SOCKET_SELECT_SUPPORT - /// These functions update the fd_set used by select() in the main loop. + /// Register/unregister a socket to be monitored for read events. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop. +#ifdef USE_LWIP_FAST_SELECT + /// Fast select path: hooks netconn callback and registers for monitoring. + /// @return true if registration was successful, false if sock is null + bool register_socket(struct lwip_sock *sock); + void unregister_socket(struct lwip_sock *sock); +#elif defined(USE_SOCKET_SELECT_SUPPORT) + /// Fallback select() path: monitors file descriptors. /// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error. /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); +#endif #ifdef USE_WAKE_LOOP_THREADSAFE /// Wake the main event loop from another FreeRTOS task. @@ -542,27 +552,23 @@ class Application { static void IRAM_ATTR wake_loop_any_context() { esphome_lwip_wake_main_loop_any_context(); } #endif #endif + +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) + /// Wake the main event loop from any context (ISR, thread, or main loop). + /// On ESP8266: sets the socket wake flag and calls esp_schedule() to exit esp_delay() early. + static void IRAM_ATTR wake_loop_any_context() { socket::socket_wake(); } #endif protected: friend Component; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) friend bool socket::socket_ready_fd(int fd, bool loop_monitored); #endif friend void ::setup(); friend void ::original_setup(); -#ifdef USE_SOCKET_SELECT_SUPPORT - /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// Main loop only — with USE_LWIP_FAST_SELECT, reads rcvevent via - /// lwip_socket_dbg_get_socket(), which has no refcount; safe only because - /// the main loop owns socket lifetime (creates, reads, and closes sockets - /// on the same thread). -#ifdef USE_LWIP_FAST_SELECT - bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } -#else +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } -#endif #endif /// Register a component, detecting loop() override at compile time. @@ -624,8 +630,12 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop FixedVector looping_components_{}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_LWIP_FAST_SELECT + std::vector monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read +#elif defined(USE_SOCKET_SELECT_SUPPORT) std::vector socket_fds_; // Vector of all monitored socket file descriptors +#endif +#ifdef USE_SOCKET_SELECT_SUPPORT #if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index a71aa8b3a3..4ccc747819 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -233,7 +233,6 @@ void Component::call_dump_config_() { } } -uint8_t Component::get_component_state() const { return this->component_state_; } void Component::call() { uint8_t state = this->component_state_ & COMPONENT_STATE_MASK; switch (state) { @@ -323,10 +322,11 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // 8. Race condition with main loop is handled by clearing flag before processing this->pending_enable_loop_ = true; App.has_pending_enable_loop_requests_ = true; -#if defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32) - // Wake the main loop if sleeping in ulTaskNotifyTake(). Without this, - // the main loop would not wake until the select timeout expires (~16ms). - // Uses xPortInIsrContext() to choose the correct FreeRTOS notify API. +#if (defined(USE_LWIP_FAST_SELECT) && defined(USE_ESP32)) || (defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)) + // Wake the main loop from sleep. Without this, the main loop would not + // wake until the select/delay timeout expires (~16ms). + // ESP32: uses xPortInIsrContext() to choose the correct FreeRTOS notify API. + // ESP8266: sets socket wake flag and calls esp_schedule() to exit esp_delay() early. Application::wake_loop_any_context(); #endif } @@ -338,9 +338,6 @@ void Component::reset_to_construction_state() { this->status_clear_error(); } } -bool Component::is_in_loop_state() const { - return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP; -} void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } @@ -379,16 +376,12 @@ void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std: App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); #pragma GCC diagnostic pop } -bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool Component::is_ready() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP || (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE || (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP; } -bool Component::is_idle() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE; } bool Component::can_proceed() { return true; } -bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } -bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } bool Component::set_status_flag_(uint8_t flag) { if ((this->component_state_ & flag) != 0) return false; diff --git a/esphome/core/component.h b/esphome/core/component.h index d8102ea670..e5127b0c9f 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -142,7 +142,7 @@ class Component { */ virtual void on_powerdown() {} - uint8_t get_component_state() const; + uint8_t get_component_state() const { return this->component_state_; } /** Reset this component back to the construction state to allow setup to run again. * @@ -154,7 +154,7 @@ class Component { * * @return True if in loop state, false otherwise. */ - bool is_in_loop_state() const; + bool is_in_loop_state() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP; } /** Check if this component is idle. * Being idle means being in LOOP_DONE state. @@ -162,7 +162,7 @@ class Component { * * @return True if the component is idle */ - bool is_idle() const; + bool is_idle() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE; } /** Mark this component as failed. Any future timeouts/intervals/setup/loop will no longer be called. * @@ -230,15 +230,15 @@ class Component { */ void enable_loop_soon_any_context(); - bool is_failed() const; + bool is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; } bool is_ready() const; virtual bool can_proceed(); - bool status_has_warning() const; + bool status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } - bool status_has_error() const; + bool status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } void status_set_warning(const char *message = nullptr); void status_set_warning(const LogString *message); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index df4547edc0..a079a5d4d2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -44,7 +44,9 @@ #define USE_DEEP_SLEEP #define USE_DEVICES #define USE_DISPLAY +#define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON +#define USE_ENTITY_UNIT_OF_MEASUREMENT #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK @@ -107,6 +109,7 @@ #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER +#define USE_MEDIA_SOURCE #define USE_NEXTION_TFT_UPLOAD #define USE_NUMBER #define USE_OUTPUT @@ -234,6 +237,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WAKE_LOOP_THREADSAFE #define USE_SPEAKER +#define USE_SPEAKER_MEDIA_PLAYER_ON_OFF #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index f6a7ec1dfd..eafc04f92a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -45,24 +45,42 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } } -// Entity Icon -std::string EntityBase::get_icon() const { -#ifdef USE_ENTITY_ICON - if (this->icon_c_str_ == nullptr) { - return ""; - } - return this->icon_c_str_; +// Weak default lookup functions — overridden by generated code in main.cpp +__attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } +__attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } +__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } + +// Entity device class (from index) +StringRef EntityBase::get_device_class_ref() const { +#ifdef USE_ENTITY_DEVICE_CLASS + return StringRef(entity_device_class_lookup(this->device_class_idx_)); #else - return ""; + return StringRef(entity_device_class_lookup(0)); #endif } -void EntityBase::set_icon(const char *icon) { -#ifdef USE_ENTITY_ICON - this->icon_c_str_ = icon; +std::string EntityBase::get_device_class() const { return std::string(this->get_device_class_ref().c_str()); } + +// Entity unit of measurement (from index) +StringRef EntityBase::get_unit_of_measurement_ref() const { +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + return StringRef(entity_uom_lookup(this->uom_idx_)); #else - // No-op when USE_ENTITY_ICON is not defined + return StringRef(entity_uom_lookup(0)); #endif } +std::string EntityBase::get_unit_of_measurement() const { + return std::string(this->get_unit_of_measurement_ref().c_str()); +} + +// Entity icon (from index) +StringRef EntityBase::get_icon_ref() const { +#ifdef USE_ENTITY_ICON + return StringRef(entity_icon_lookup(this->icon_idx_)); +#else + return StringRef(entity_icon_lookup(0)); +#endif +} +std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } // Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { @@ -134,24 +152,6 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve return global_preferences->make_preference(size, key); } -std::string EntityBase_DeviceClass::get_device_class() { - if (this->device_class_ == nullptr) { - return ""; - } - return this->device_class_; -} - -void EntityBase_DeviceClass::set_device_class(const char *device_class) { this->device_class_ = device_class; } - -std::string EntityBase_UnitOfMeasurement::get_unit_of_measurement() { - if (this->unit_of_measurement_ == nullptr) - return ""; - return this->unit_of_measurement_; -} -void EntityBase_UnitOfMeasurement::set_unit_of_measurement(const char *unit_of_measurement) { - this->unit_of_measurement_ = unit_of_measurement; -} - #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_icon_ref().empty()) { @@ -160,13 +160,13 @@ void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) } #endif -void log_entity_device_class(const char *tag, const char *prefix, const EntityBase_DeviceClass &obj) { +void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_device_class_ref().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj.get_device_class_ref().c_str()); } } -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj) { +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj) { if (!obj.get_unit_of_measurement_ref().empty()) { ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj.get_unit_of_measurement_ref().c_str()); } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cbc07cc44c..042eebb40f 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -14,6 +14,12 @@ namespace esphome { +// Extern lookup functions for entity string tables. +// Generated code provides strong definitions; weak defaults return "". +extern const char *entity_device_class_lookup(uint8_t index); +extern const char *entity_uom_lookup(uint8_t index); +extern const char *entity_icon_lookup(uint8_t index); + // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; @@ -89,20 +95,41 @@ class EntityBase { this->flags_.entity_category = static_cast(entity_category); } + // Set entity string table indices — one call per entity from codegen. + // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) + void set_entity_strings([[maybe_unused]] uint32_t packed) { +#ifdef USE_ENTITY_DEVICE_CLASS + this->device_class_idx_ = packed & 0xFF; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + this->uom_idx_ = (packed >> 8) & 0xFF; +#endif +#ifdef USE_ENTITY_ICON + this->icon_idx_ = (packed >> 16) & 0xFF; +#endif + } + + // Get device class as StringRef (from packed index) + StringRef get_device_class_ref() const; + /// Get the device class as std::string (deprecated, prefer get_device_class_ref()) + ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " + "ESPHome 2026.9.0", + "2026.3.0") + std::string get_device_class() const; + // Get unit of measurement as StringRef (from packed index) + StringRef get_unit_of_measurement_ref() const; + /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) + ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " + "removed in ESPHome 2026.9.0", + "2026.3.0") + std::string get_unit_of_measurement() const; + // Get/set this entity's icon ESPDEPRECATED( "Use get_icon_ref() instead for better performance (avoids string copy). Will be removed in ESPHome 2026.5.0", "2025.11.0") std::string get_icon() const; - void set_icon(const char *icon); - StringRef get_icon_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); -#ifdef USE_ENTITY_ICON - return this->icon_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->icon_c_str_); -#else - return EMPTY_STRING; -#endif - } + StringRef get_icon_ref() const; #ifdef USE_DEVICES // Get/set this entity's device id @@ -173,9 +200,6 @@ class EntityBase { void calc_object_id_(); StringRef name_; -#ifdef USE_ENTITY_ICON - const char *icon_c_str_{nullptr}; -#endif uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; @@ -190,44 +214,16 @@ class EntityBase { uint8_t entity_category : 2; // Supports up to 4 categories uint8_t reserved : 2; // Reserved for future use } flags_{}; -}; - -class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) - public: - /// Get the device class, using the manual override if set. - ESPDEPRECATED("Use get_device_class_ref() instead for better performance (avoids string copy). Will be removed in " - "ESPHome 2026.5.0", - "2025.11.0") - std::string get_device_class(); - /// Manually set the device class. - void set_device_class(const char *device_class); - /// Get the device class as StringRef - StringRef get_device_class_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->device_class_ == nullptr ? EMPTY_STRING : StringRef(this->device_class_); - } - - protected: - const char *device_class_{nullptr}; ///< Device class override -}; - -class EntityBase_UnitOfMeasurement { // NOLINT(readability-identifier-naming) - public: - /// Get the unit of measurement, using the manual override if set. - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.5.0", - "2025.11.0") - std::string get_unit_of_measurement(); - /// Manually set the unit of measurement. - void set_unit_of_measurement(const char *unit_of_measurement); - /// Get the unit of measurement as StringRef - StringRef get_unit_of_measurement_ref() const { - static constexpr auto EMPTY_STRING = StringRef::from_lit(""); - return this->unit_of_measurement_ == nullptr ? EMPTY_STRING : StringRef(this->unit_of_measurement_); - } - - protected: - const char *unit_of_measurement_{nullptr}; ///< Unit of measurement override + // String table indices — packed into the 3 padding bytes after flags_ +#ifdef USE_ENTITY_DEVICE_CLASS + uint8_t device_class_idx_{}; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + uint8_t uom_idx_{}; +#endif +#ifdef USE_ENTITY_ICON + uint8_t icon_idx_{}; +#endif }; /// Log entity icon if set (for use in dump_config) @@ -240,10 +236,10 @@ inline void log_entity_icon(const char *, const char *, const EntityBase &) {} #endif /// Log entity device class if set (for use in dump_config) #define LOG_ENTITY_DEVICE_CLASS(tag, prefix, obj) log_entity_device_class(tag, prefix, obj) -void log_entity_device_class(const char *tag, const char *prefix, const EntityBase_DeviceClass &obj); +void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj); /// Log entity unit of measurement if set (for use in dump_config) #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) -void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase_UnitOfMeasurement &obj); +void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); /** * An entity that has a state. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c1801c0bda..551e35df65 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,9 +1,12 @@ from collections.abc import Callable +from dataclasses import dataclass, field +import functools import logging import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ENTITY_CATEGORY, @@ -11,15 +14,184 @@ from esphome.const import ( CONF_ID, CONF_INTERNAL, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, add, get_variable +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, RawStatement, add, get_variable import esphome.final_validate as fv -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) +DOMAIN = "entity_string_pool" + +# 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" + +# Bit layout for set_entity_strings(packed) — must match C++ setter in entity_base.h: +# [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) +_DC_SHIFT = 0 +_UOM_SHIFT = 8 +_ICON_SHIFT = 16 + +# Maximum unique strings per category (8-bit index, 0 = not set) +_MAX_DEVICE_CLASSES = 0xFF # 255 +_MAX_UNITS = 0xFF # 255 +_MAX_ICONS = 0xFF # 255 + + +@dataclass +class EntityStringPool: + """Pool of entity string properties for PROGMEM pointer tables. + + Strings are registered during to_code() and assigned 1-based indices. + Index 0 means "not set" (empty string). At render time, the pool + generates C++ PROGMEM pointer table + lookup function per category. + """ + + device_classes: dict[str, int] = field(default_factory=dict) + units: dict[str, int] = field(default_factory=dict) + icons: dict[str, int] = field(default_factory=dict) + tables_registered: bool = False + + +def _get_pool() -> EntityStringPool: + """Get or create the entity string pool from CORE.data.""" + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EntityStringPool() + return CORE.data[DOMAIN] + + +def _ensure_tables_registered() -> None: + """Schedule the table generation job (once).""" + pool = _get_pool() + if pool.tables_registered: + return + pool.tables_registered = True + CORE.add_job(_generate_tables_job) + + +def _generate_category_code( + table_var: str, + lookup_fn: str, + strings: dict[str, int], +) -> str: + """Generate C++ code for one string category (PROGMEM pointer table + lookup). + + Uses a PROGMEM array of string pointers. On ESP8266, pointers are stored + in flash (via PROGMEM) and read with progmem_read_ptr(). String literals + themselves remain in RAM but benefit from linker string deduplication. + Index 0 means "not set" and returns empty string. + """ + if not strings: + return "" + + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) + count = len(sorted_strings) + + return ( + f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" + f"const char *{lookup_fn}(uint8_t index) {{\n" + f' if (index == 0 || index > {count}) return "";\n' + f" return progmem_read_ptr(&{table_var}[index - 1]);\n" + f"}}\n" + ) + + +_CATEGORY_CONFIGS = ( + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"), + ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"), + ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"), +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _generate_tables_job() -> None: + """Generate all entity string table C++ code as a FINAL-priority job. + + Runs after all component to_code() calls have registered their strings. + """ + pool = _get_pool() + parts = ["namespace esphome {"] + for table_var, lookup_fn, attr in _CATEGORY_CONFIGS: + code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr)) + if code: + parts.append(code) + parts.append("} // namespace esphome") + cg.add_global(RawStatement("\n".join(parts))) + + +def _register_string( + value: str, category: dict[str, int], max_count: int, category_name: str +) -> int: + """Register a string in a category dict and return its 1-based index. + + Returns 0 if value is empty/None (meaning "not set"). + """ + if not value: + return 0 + if value in category: + return category[value] + idx = len(category) + 1 + if idx > max_count: + raise ValueError( + f"Too many unique {category_name} values (max {max_count}), got {idx}: '{value}'" + ) + category[value] = idx + _ensure_tables_registered() + return idx + + +def register_device_class(value: str) -> int: + """Register a device_class string and return its 1-based index.""" + return _register_string( + value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class" + ) + + +def register_unit_of_measurement(value: str) -> int: + """Register a unit_of_measurement string and return its 1-based index.""" + return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") + + +def register_icon(value: str) -> int: + """Register an icon string and return its 1-based index.""" + return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") + + +def setup_device_class(config: ConfigType) -> None: + """Register config's device_class and store its index for finalize_entity_strings.""" + idx = register_device_class(config.get(CONF_DEVICE_CLASS, "")) + if idx: + cg.add_define("USE_ENTITY_DEVICE_CLASS") + config[_KEY_DC_IDX] = idx + + +def setup_unit_of_measurement(config: ConfigType) -> None: + """Register config's unit_of_measurement and store its index for finalize_entity_strings.""" + idx = register_unit_of_measurement(config.get(CONF_UNIT_OF_MEASUREMENT, "")) + if idx: + cg.add_define("USE_ENTITY_UNIT_OF_MEASUREMENT") + config[_KEY_UOM_IDX] = idx + + +def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: + """Emit a single set_entity_strings() call with all packed indices. + + Call this at the end of each component's setup function, after + setup_entity() and any register_device_class/register_unit_of_measurement calls. + """ + dc_idx = config.get(_KEY_DC_IDX, 0) + uom_idx = config.get(_KEY_UOM_IDX, 0) + icon_idx = config.get(_KEY_ICON_IDX, 0) + packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) + if packed != 0: + add(var.set_entity_strings(packed)) + def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None @@ -64,8 +236,48 @@ def get_base_entity_object_id( return sanitize(snake_case(base_str)) -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. +def setup_entity(var_or_platform, config=None, platform=None): + """Set up entity properties — works as both decorator and direct call. + + Decorator mode:: + + @setup_entity("sensor") + async def setup_sensor_core_(var, config): + setup_device_class(config) + setup_unit_of_measurement(config) + ... + + Direct call mode (for entities with no extra string properties):: + + await setup_entity(var, config, "camera") + """ + if isinstance(var_or_platform, str) and config is None: + # Decorator mode: @setup_entity("sensor") + platform = var_or_platform + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper( + var: MockObj, config: ConfigType, *args, **kwargs + ) -> None: + await _setup_entity_impl(var, config, platform) + await func(var, config, *args, **kwargs) + finalize_entity_strings(var, config) + + return wrapper + + return decorator + + # Direct call mode: await setup_entity(var, config, "camera") + async def _do() -> None: + await _setup_entity_impl(var_or_platform, config, platform) + finalize_entity_strings(var_or_platform, config) + + return _do() + + +async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity (internal implementation). This function sets up the common entity properties like name, icon, entity category, etc. @@ -92,12 +304,15 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: add(var.set_disabled_by_default(True)) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) + icon_idx = 0 if CONF_ICON in config: # Add USE_ENTITY_ICON define when icons are used cg.add_define("USE_ENTITY_ICON") - add(var.set_icon(config[CONF_ICON])) + icon_idx = register_icon(config[CONF_ICON]) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) + # Store icon index for finalize_entity_strings + config[_KEY_ICON_IDX] = icon_idx def inherit_property_from(property_to_inherit, parent_id_property, transform=None): diff --git a/esphome/core/hal.h b/esphome/core/hal.h index ef45be629d..c2c9b1a325 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -42,6 +42,7 @@ void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); uint32_t arch_get_cpu_freq_hz(); uint8_t progmem_read_byte(const uint8_t *addr); +const char *progmem_read_ptr(const char *const *addr); uint16_t progmem_read_uint16(const uint16_t *addr); } // namespace esphome diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6d801e7ebc..00b447ebf2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -706,7 +706,7 @@ float gamma_correct(float value, float gamma) { if (gamma <= 0.0f) return value; - return powf(value, gamma); + return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0 } float gamma_uncorrect(float value, float gamma) { if (value <= 0.0f) @@ -714,7 +714,7 @@ float gamma_uncorrect(float value, float gamma) { if (gamma <= 0.0f) return value; - return powf(value, 1 / gamma); + return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0 } void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { @@ -794,7 +794,6 @@ void HighFrequencyLoopRequester::stop() { num_requests--; this->started_ = false; } -bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; } std::string get_mac_address() { uint8_t mac[6]; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c68cb549bb..6ce5de4975 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -512,8 +512,8 @@ template class SmallBufferWithHeapFallb ///@{ /// Compute 10^exp using iterative multiplication/division. -/// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. -/// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. +/// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT +/// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT inline float pow10_int(int8_t exp) { float result = 1.0f; if (exp >= 0) { @@ -599,6 +599,44 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. +/// +/// On 32-bit targets, GCC does not optimize 64-bit constant division into a +/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 +/// (free divide-by-8), then use the Euclidean division identity to decompose +/// the remaining 64-bit divide-by-125 into a single 32-bit division: +/// +/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] +/// 2^32 = Q * 125 + R (34359738 * 125 + 46) +/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 +/// +/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal +/// (mulhu + shift), so no division instruction is emitted. +/// +/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). +/// +/// See: https://en.wikipedia.org/wiki/Euclidean_division +/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html +template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { + constexpr uint32_t d = 125U; + constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 + constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 + // 1000 = 8 * 125; divide-by-8 is a free shift + uint64_t x = us >> 3; + uint32_t lo = static_cast(x); + uint32_t hi = static_cast(x >> 32); + // Combine remainder term: hi * (2^32 % 125) + lo + uint32_t adj = hi * r + lo; + // If adj overflowed, the true value is 2^32 + adj; apply the identity again + // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q + return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); +} + /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); /// Return a random float between 0 and 1. @@ -1694,7 +1732,7 @@ class HighFrequencyLoopRequester { void stop(); /// Check whether the loop is running continuously. - static bool is_high_frequency(); + static bool is_high_frequency() { return num_requests > 0; } protected: bool started_{false}; diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 989f66e9be..c578a9aae9 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -140,8 +140,10 @@ _Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access"); _Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access"); -// rcvevent must fit in a single atomic read -_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access"); +// rcvevent must be exactly 2 bytes (s16_t) — the inline in lwip_fast_select.h reads it as int16_t. +// If lwIP changes this to int or similar, the offset assert would still pass but the load width would be wrong. +_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) == 2, + "rcvevent size changed — update int16_t cast in esphome_lwip_socket_has_data() in lwip_fast_select.h"); // Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V/ARM. // Misaligned access would not be atomic even if the size is <= 4 bytes. @@ -150,6 +152,10 @@ _Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == _Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0, "lwip_sock.rcvevent must be naturally aligned for atomic access"); +// Verify the hardcoded offset used in the header's inline esphome_lwip_socket_has_data(). +_Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET, + "lwip_sock.rcvevent offset changed — update ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET in lwip_fast_select.h"); + // Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. static TaskHandle_t s_main_loop_task = NULL; @@ -194,23 +200,11 @@ static inline struct lwip_sock *get_sock(int fd) { return sock; } -bool esphome_lwip_socket_has_data(int fd) { - struct lwip_sock *sock = get_sock(fd); - if (sock == NULL) - return false; - // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a - // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value - // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on - // Xtensa/RISC-V/ARM and cannot produce torn values. - return *(volatile s16_t *) &sock->rcvevent > 0; +struct lwip_sock *esphome_lwip_get_sock(int fd) { + return get_sock(fd); } -void esphome_lwip_hook_socket(int fd) { - struct lwip_sock *sock = get_sock(fd); - if (sock == NULL) - return; - +void esphome_lwip_hook_socket(struct lwip_sock *sock) { // Save original callback once — all LwIP sockets share the same static event_callback // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). if (s_original_callback == NULL) { diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 6fce34fd76..46c6b711cd 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -4,6 +4,17 @@ // Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. #include +#include + +// Forward declare lwip_sock for C++ callers that store cached pointers. +// The full definition is only available in the .c file (lwip/priv/sockets_priv.h +// conflicts with C++ compilation units). +struct lwip_sock; + +// Byte offset of rcvevent (s16_t) within struct lwip_sock. +// Verified at compile time in lwip_fast_select.c via _Static_assert. +// Anonymous enum for a compile-time constant that works in both C and C++. +enum { ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET = 8 }; #ifdef __cplusplus extern "C" { @@ -13,16 +24,38 @@ extern "C" { /// Saves the current task handle for xTaskNotifyGive() wake notifications. void esphome_lwip_fast_select_init(void); -/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). -/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that -/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime: -/// both has_data reads and socket close/unregister happen on the main loop thread. -bool esphome_lwip_socket_has_data(int fd); +/// Look up a LwIP socket struct from a file descriptor. +/// Returns NULL if fd is invalid or the socket/netconn is not initialized. +/// Use this at registration time to cache the pointer for esphome_lwip_socket_has_data(). +struct lwip_sock *esphome_lwip_get_sock(int fd); + +/// Check if a cached LwIP socket has data ready via unlocked hint read of rcvevent. +/// This avoids lwIP core lock contention between the main loop (CPU0) and +/// streaming/networking work (CPU1). Correctness is preserved because callers +/// already handle EWOULDBLOCK on nonblocking sockets — a stale hint simply causes +/// a harmless retry on the next loop iteration. In practice, stale reads have not +/// been observed across multi-day testing, but the design does not depend on that. +/// +/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must +/// remain valid (caller owns socket lifetime — no concurrent close). +/// Hot path: inlined volatile 16-bit load — no function call overhead. +/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++. +/// The offset and size are verified at compile time in lwip_fast_select.c. +static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { + // Unlocked hint read — no lwIP core lock needed. + // volatile prevents the compiler from caching/reordering this cross-thread read. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a + // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value + // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on + // Xtensa/RISC-V/ARM and cannot produce torn values. + return *(volatile int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +} /// Hook a socket's netconn callback to notify the main loop task on receive events. /// Wraps the original event_callback with one that also calls xTaskNotifyGive(). /// Must be called from the main loop after socket creation. -void esphome_lwip_hook_socket(int fd); +/// The sock pointer must have been obtained from esphome_lwip_get_sock(). +void esphome_lwip_hook_socket(struct lwip_sock *sock); /// Wake the main loop task from another FreeRTOS task — costs <1 us. /// NOT ISR-safe — must only be called from task context. diff --git a/script/ci-custom.py b/script/ci-custom.py index f428eb0821..b60d7d7740 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -841,6 +841,54 @@ def lint_no_scanf(fname, match): ) +# Base entity platforms - these are linked into most builds and should not +# pull in powf/__ieee754_powf (~2.3KB flash). +BASE_ENTITY_PLATFORMS = [ + "alarm_control_panel", + "binary_sensor", + "button", + "climate", + "cover", + "datetime", + "event", + "fan", + "light", + "lock", + "media_player", + "number", + "select", + "sensor", + "switch", + "text", + "text_sensor", + "update", + "valve", + "water_heater", +] + +# Directories protected from powf: core + all base entity platforms +POWF_PROTECTED_DIRS = ["esphome/core"] + [ + f"esphome/components/{p}" for p in BASE_ENTITY_PLATFORMS +] + + +@lint_re_check( + r"[^\w]powf\s*\(" + CPP_RE_EOL, + include=[ + f"{d}/*.{ext}" for d in POWF_PROTECTED_DIRS for ext in ["h", "cpp", "tcc"] + ], +) +def lint_no_powf_in_core(fname, match): + return ( + f"{highlight('powf()')} pulls in __ieee754_powf (~2.3KB flash) and is not allowed in " + f"core or base entity platform code. These files are linked into every build.\n" + f"Please use alternatives:\n" + f" - {highlight('pow10_int(exp)')} for integer powers of 10 (from helpers.h)\n" + f" - Precomputed lookup tables for gamma/non-integer exponents\n" + f"(If powf is strictly necessary, add `// NOLINT` to the line)" + ) + + LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL) LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') diff --git a/script/clang-tidy b/script/clang-tidy index 17bcafacc7..9c2899026d 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -79,6 +79,7 @@ def clang_options(idedata): "-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)", diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 35ce1f4e11..221e7edf2c 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -11,4 +11,4 @@ def test_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml") # Then - assert 's_1->set_device_class("voltage");' in main_cpp + assert "s_1->set_entity_strings(" in main_cpp diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1593d0b6d8..4aaebe04d1 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -54,5 +54,5 @@ def test_text_sensor_device_class_set(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_2->set_device_class("timestamp");' in main_cpp - assert 'ts_3->set_device_class("date");' in main_cpp + assert "ts_2->set_entity_strings(" in main_cpp + assert "ts_3->set_entity_strings(" in main_cpp diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index efa3cba076..35dca0624f 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -27,3 +27,14 @@ globals: type: bool restore_value: false initial_value: "false" + # Test restore_value with string "false" - should be converted to bool false + - id: glob_no_restore_string_false + type: int + restore_value: "false" + initial_value: "42" + # Test restore_value with string "true" - should be converted to bool true + - id: glob_restore_string_true + type: int + restore_value: "true" + initial_value: "99" + update_interval: 5s diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 9df63b2f29..77abc433c1 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -13,3 +13,4 @@ openthread: force_dataset: true use_address: open-thread-test.local poll_period: 20sec + output_power: 1dBm diff --git a/tests/components/speaker/common-media_player_off_on.yaml b/tests/components/speaker/common-media_player_off_on.yaml new file mode 100644 index 0000000000..a5bea62c84 --- /dev/null +++ b/tests/components/speaker/common-media_player_off_on.yaml @@ -0,0 +1,18 @@ +<<: !include common.yaml + +media_player: + - platform: speaker + id: speaker_media_player_id + announcement_pipeline: + speaker: speaker_id + buffer_size: 1000000 + volume_increment: 0.02 + volume_max: 0.95 + volume_min: 0.0 + task_stack_in_psram: true + on_turn_on: + then: + - logger.log: "Turn On Media Player" + on_turn_off: + then: + - logger.log: "Turn Off Media Player" diff --git a/tests/components/speaker/media_player_off_on.esp32-idf.yaml b/tests/components/speaker/media_player_off_on.esp32-idf.yaml new file mode 100644 index 0000000000..2d5eefff19 --- /dev/null +++ b/tests/components/speaker/media_player_off_on.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO23 + +<<: !include common-media_player_off_on.yaml diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 36df1bc83e..b7f7fc60b3 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -73,11 +73,6 @@ def shared_platformio_cache() -> Generator[Path]: test_cache_dir = Path.home() / ".esphome-integration-tests" cache_dir = test_cache_dir / "platformio" - # Create the temp directory that PlatformIO uses to avoid race conditions - # This ensures it exists and won't be deleted by parallel processes - platformio_tmp_dir = cache_dir / ".cache" / "tmp" - platformio_tmp_dir.mkdir(parents=True, exist_ok=True) - # Use a lock file in the home directory to ensure only one process initializes the cache # This is needed when running with pytest-xdist # The lock file must be in a directory that already exists to avoid race conditions @@ -87,8 +82,9 @@ def shared_platformio_cache() -> Generator[Path]: with open(lock_file, "w") as lock_fd: fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - # Check if cache needs initialization while holding the lock - if not cache_dir.exists() or not any(cache_dir.iterdir()): + # Check if the native platform is installed (the actual indicator of a populated cache) + native_platform = cache_dir / "platforms" / "native" + if not native_platform.exists(): # Create the test cache directory if it doesn't exist test_cache_dir.mkdir(exist_ok=True) diff --git a/tests/integration/fixtures/external_components/uart_mock/__init__.py b/tests/integration/fixtures/external_components/uart_mock/__init__.py index dea8c38551..8deab4c21e 100644 --- a/tests/integration/fixtures/external_components/uart_mock/__init__.py +++ b/tests/integration/fixtures/external_components/uart_mock/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.uart import ( CONF_RX_FULL_THRESHOLD, CONF_RX_TIMEOUT, @@ -12,14 +13,11 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, CONF_DATA, - CONF_DATA_BITS, CONF_DEBUG, CONF_DELAY, CONF_ID, CONF_INTERVAL, - CONF_PARITY, CONF_RX_BUFFER_SIZE, - CONF_STOP_BITS, CONF_TRIGGER_ID, ) from esphome.core import ID diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml new file mode 100644 index 0000000000..d11808c43a --- /dev/null +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -0,0 +1,61 @@ +esphome: + name: micros-to-millis-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::micros_to_millis; + const char *TAG = "MTM"; + int pass = 0, fail = 0; + + auto check = [&](const char *name, uint64_t us) { + uint32_t got = micros_to_millis(us); + uint32_t want = (uint32_t)(us / 1000ULL); + if (got == want) { pass++; } + else { ESP_LOGE(TAG, "%s FAILED: got=%u want=%u", name, got, want); fail++; } + }; + + // Basic values + check("zero", 0); + check("below_1ms", 999); + check("exactly_1ms", 1000); + check("above_1ms", 1001); + + // Shift boundary (1000 = 8 * 125, exercises the >>3 shift) + check("shift_7999", 7999); + check("shift_8000", 8000); + check("shift_8001", 8001); + + // 32-bit boundary + check("u32max_minus1", 0xFFFFFFFEULL); + check("u32max", 0xFFFFFFFFULL); + check("u32max_plus1", 0x100000000ULL); + + // Realistic uptimes + check("30_days", 2592000000000ULL); + check("1_year", 31536000000000ULL); + + // Carry path: construct x = us>>3 with specific hi/lo that trigger adj overflow + { uint64_t x = (603ULL << 32) | 0xFFFFFFFFU; check("carry_603", x << 3); } + { uint64_t x = (5000ULL << 32) | 0xFFFFFFFFU; check("carry_5000", x << 3); } + + // Carry boundary: exact transition where adj overflows (hi=1000, R=46) + { + uint32_t hi = 1000; + uint32_t thr = 0xFFFFFFFFU - hi * 46U; + uint64_t h = (uint64_t)hi << 32; + check("carry_before", (h | (thr - 1)) << 3); + check("carry_at", (h | thr) << 3); + check("carry_after", (h | (thr + 1)) << 3); + } + + // Mod-8 variations (exercises the >>3 truncation) + for (int i = 0; i < 8; i++) { check("mod8", 2592000000000ULL + i); } + + if (fail == 0) { ESP_LOGI(TAG, "ALL_PASSED %d tests", pass); } + else { ESP_LOGE(TAG, "%d FAILED out of %d", fail, pass + fail); } + +host: +api: +logger: diff --git a/tests/integration/test_micros_to_millis.py b/tests/integration/test_micros_to_millis.py new file mode 100644 index 0000000000..9960d6b017 --- /dev/null +++ b/tests/integration/test_micros_to_millis.py @@ -0,0 +1,46 @@ +"""Integration test for micros_to_millis Euclidean decomposition.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_micros_to_millis( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that micros_to_millis matches reference uint64 division.""" + + all_passed = asyncio.Event() + failures: list[str] = [] + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + if "ALL_PASSED" in clean_line: + all_passed.set() + elif "FAILED" in clean_line and "[MTM" in clean_line: + failures.append(clean_line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "micros-to-millis-test" + + try: + await asyncio.wait_for(all_passed.wait(), timeout=2.0) + except TimeoutError: + if failures: + pytest.fail(f"micros_to_millis failures: {failures}") + pytest.fail("micros_to_millis test timed out") + + assert not failures, f"micros_to_millis failures: {failures}" diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index 8bf890261a..be488347aa 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -17,10 +17,10 @@ async def test_oversized_payload_plaintext( ) -> None: """Test that oversized payloads (>32768 bytes) from client cause disconnection without crashing.""" process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -30,7 +30,7 @@ async def test_oversized_payload_plaintext( and "Bad packet: message size" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect() as (client, disconnect_event): @@ -54,10 +54,13 @@ async def test_oversized_payload_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message size exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -77,10 +80,10 @@ async def test_oversized_protobuf_message_id_plaintext( This tests the message type limit - message IDs must fit in a uint16_t (0-65535). """ process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -90,7 +93,7 @@ async def test_oversized_protobuf_message_id_plaintext( and "Bad packet: message type" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect() as (client, disconnect_event): @@ -114,10 +117,13 @@ async def test_oversized_protobuf_message_id_plaintext( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message type exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message type exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect() as (client2, _): @@ -135,10 +141,10 @@ async def test_oversized_payload_noise( """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - helper_log_found = False + helper_log_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, helper_log_found + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -149,7 +155,7 @@ async def test_oversized_payload_noise( and "Bad packet: message size" in line and "exceeds maximum" in line ): - helper_log_found = True + helper_log_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -177,10 +183,13 @@ async def test_oversized_payload_noise( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected HELPER_LOG message - assert helper_log_found, ( - "Expected to see HELPER_LOG about message size exceeding maximum" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(helper_log_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see HELPER_LOG about message size exceeding maximum" + ) # Try to reconnect to verify the process is still running async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -274,10 +283,10 @@ async def test_noise_corrupt_encrypted_frame( """ noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - cipherstate_failed = False + cipherstate_event = asyncio.Event() def check_logs(line: str) -> None: - nonlocal process_exited, cipherstate_failed + nonlocal process_exited # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True @@ -290,7 +299,7 @@ async def test_noise_corrupt_encrypted_frame( "[W][api.connection" in line and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line ): - cipherstate_failed = True + cipherstate_event.set() async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -326,10 +335,14 @@ async def test_noise_corrupt_encrypted_frame( assert not process_exited, ( "ESPHome process should not crash on corrupt encrypted frames" ) - # Verify we saw the expected log message about decryption failure - assert cipherstate_failed, ( - "Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED" - ) + # Wait for the expected log message (may arrive after disconnect event) + try: + await asyncio.wait_for(cipherstate_event.wait(), timeout=2.0) + except TimeoutError: + pytest.fail( + "Expected to see log about noise_cipherstate_decrypt failure" + " or CIPHERSTATE_DECRYPT_FAILED" + ) # Verify we can still reconnect after handling the corrupt frame async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index a58d4784ce..a5cfad5ab6 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -11,6 +11,7 @@ from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_INTERNAL, @@ -18,6 +19,8 @@ from esphome.const import ( ) from esphome.core import CORE, ID, entity_helpers from esphome.core.entity_helpers import ( + _register_string, + _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, setup_entity, @@ -305,7 +308,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Temperature", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var1, config1, "sensor") + await _setup_entity_impl(var1, config1, "sensor") # Get object ID from first entity object_id1 = extract_object_id_from_expressions(added_expressions) @@ -319,7 +322,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> CONF_NAME: "Humidity", CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var2, config2, "sensor") + await _setup_entity_impl(var2, config2, "sensor") # Get object ID from second entity object_id2 = extract_object_id_from_expressions(added_expressions) @@ -354,7 +357,7 @@ async def test_setup_entity_different_platforms( object_ids: list[str] = [] for var, platform in platforms: added_expressions.clear() - await setup_entity(var, config, platform) + await _setup_entity_impl(var, config, platform) object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -416,7 +419,7 @@ async def test_setup_entity_with_devices( object_ids: list[str] = [] for var, config in [(sensor1, config1), (sensor2, config2)]: added_expressions.clear() - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) object_ids.append(object_id) @@ -438,7 +441,7 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Should use friendly name @@ -460,7 +463,7 @@ async def test_setup_entity_special_characters( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") object_id = extract_object_id_from_expressions(added_expressions) # Special characters should be sanitized @@ -471,7 +474,7 @@ async def test_setup_entity_special_characters( async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None: """Test setup_entity sets icon correctly.""" - added_expressions = setup_test_environment + setup_test_environment # noqa: F841 - fixture initializes CORE state var = MockObj("sensor1") @@ -481,12 +484,10 @@ async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None CONF_ICON: "mdi:thermometer", } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") - # Check icon was set - assert any( - 'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions - ) + # Check icon index was stored in config for finalize_entity_strings + assert config.get("_entity_icon_idx", 0) > 0 @pytest.mark.asyncio @@ -504,7 +505,7 @@ async def test_setup_entity_disabled_by_default( CONF_DISABLED_BY_DEFAULT: True, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # Check disabled_by_default was set assert any( @@ -790,7 +791,7 @@ async def test_setup_entity_empty_name_with_device( CONF_DEVICE_ID: device_id, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") entity_helpers.get_variable = original_get_variable @@ -826,7 +827,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -858,7 +859,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( @@ -891,9 +892,84 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( CONF_DISABLED_BY_DEFAULT: False, } - await setup_entity(var, config, "sensor") + await _setup_entity_impl(var, config, "sensor") # For empty-name entities, Python passes 0 - C++ calculates hash at runtime assert any('set_name("", 0)' in expr for expr in added_expressions), ( f"Expected set_name with hash 0, got {added_expressions}" ) + + +def test_register_string_overflow() -> None: + """Test _register_string raises ValueError when max count is exceeded.""" + category: dict[str, int] = {} + for i in range(3): + _register_string(f"val_{i}", category, 3, "test") + with pytest.raises(ValueError, match="Too many unique test values"): + _register_string("overflow", category, 3, "test") + + +@pytest.mark.asyncio +async def test_setup_entity_with_entity_category( + setup_test_environment: list[str], +) -> None: + """Test setup_entity sets entity_category correctly.""" + added_expressions = setup_test_environment + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ENTITY_CATEGORY: "diagnostic", + } + await _setup_entity_impl(var, config, "sensor") + assert any( + 'set_entity_category("diagnostic")' in expr for expr in added_expressions + ) + + +@pytest.mark.asyncio +async def test_setup_entity_direct_call(setup_test_environment: list[str]) -> None: + """Test setup_entity in direct call mode (legacy / backward compat).""" + added_expressions = setup_test_environment + + var = MockObj("camera1") + config = { + CONF_NAME: "My Camera", + CONF_DISABLED_BY_DEFAULT: False, + CONF_ICON: "mdi:camera", + } + + # Direct call mode: await setup_entity(var, config, "camera") + await setup_entity(var, config, "camera") + + # Should have called set_name + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "my_camera" + + # Icon index should have been stored and finalized + assert config.get("_entity_icon_idx", 0) > 0 + + +@pytest.mark.asyncio +async def test_setup_entity_decorator_mode(setup_test_environment: list[str]) -> None: + """Test setup_entity in decorator mode.""" + added_expressions = setup_test_environment + + body_called = False + + @setup_entity("sensor") + async def my_setup(var, config): + nonlocal body_called + body_called = True + + var = MockObj("sensor1") + config = { + CONF_NAME: "Temperature", + CONF_DISABLED_BY_DEFAULT: False, + } + + await my_setup(var, config) + + assert body_called + object_id = extract_object_id_from_expressions(added_expressions) + assert object_id == "temperature"