Merge branch 'dev' into 20260210-serial-proxy

This commit is contained in:
Keith Burzinski
2026-03-03 17:20:18 -06:00
committed by GitHub
106 changed files with 1751 additions and 528 deletions

View File

@@ -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);

143
.github/scripts/codeowners.js vendored Normal file
View File

@@ -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<string>, teams: Set<string>, 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
};

View File

@@ -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}`);
}

View File

@@ -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 = '<!-- codeowner-review-request-bot -->';
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({

View File

@@ -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}}"

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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<number::Number *>(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<enums::NumberMode>(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();

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
)

View File

@@ -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<bool>, public EntityBase_DeviceClass {
class BinarySensor : public StatefulEntityBase<bool> {
public:
explicit BinarySensor(){};

View File

@@ -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)

View File

@@ -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.
*

View File

@@ -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")

View File

@@ -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"

View File

@@ -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(

View File

@@ -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();

View File

@@ -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)

View File

@@ -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<uint64_t>(esp_timer_get_time()) / 1000ULL; }
uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time())); }
uint64_t HOT millis_64() { return micros_to_millis<uint64_t>(static_cast<uint64_t>(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() {

View File

@@ -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<const char *>(pgm_read_ptr(addr)); // NOLINT
}
uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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:

View File

@@ -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)

View File

@@ -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;

View File

@@ -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:

View File

@@ -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<float>(-tx), static_cast<float>(ty)) * (180.0f / std::numbers::pi_v<float>);
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<float>(-tx), static_cast<float>(ty)) * (180.0f / std::numbers::pi_v<float>);
SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle);
}
#endif
#ifdef USE_TEXT_SENSOR
// DIRECTION

View File

@@ -7,6 +7,9 @@
#include "esphome/core/helpers.h"
#include "preferences.h"
#include <FreeRTOS.h>
#include <task.h>
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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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();
}
}

View File

@@ -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)

View File

@@ -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")

View File

@@ -0,0 +1,159 @@
#pragma once
#include "esphome/components/audio/audio.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <string>
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

View File

@@ -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<uint8_t>(mode), static_cast<uint8_t>(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;
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -1,7 +1,7 @@
#pragma once
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#include <cmath>
#include <cstdint>
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; }

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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<otIp6Address> 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<int8_t> output_power_{};
bool teardown_started_{false};
bool teardown_complete_{false};

View File

@@ -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();

View File

@@ -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<uint32_t>(millis_64()); }
uint64_t millis_64() { return micros_to_millis<uint64_t>(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<const char *>(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(); }

View File

@@ -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"

View File

@@ -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, []):

View File

@@ -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)

View File

@@ -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();

View File

@@ -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<char, SOCKADDR_STR_LEN> buf) {
struct sockaddr_storage storage;
socklen_t len = sizeof(storage);
@@ -86,14 +95,6 @@ std::unique_ptr<Socket> socket_loop_monitored(int domain, int type, int protocol
return create_socket(domain, type, protocol, true);
}
std::unique_ptr<ListenSocket> socket_listen(int domain, int type, int protocol) {
return create_socket(domain, type, protocol, false);
}
std::unique_ptr<ListenSocket> 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

View File

@@ -13,6 +13,10 @@
#include <lwip/sockets.h>
#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};
};

View File

@@ -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();
}

View File

@@ -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<char, SOCKADDR_STR_LEN> buf) {
struct sockaddr_storage storage;
socklen_t len = sizeof(storage);
@@ -86,14 +95,6 @@ std::unique_ptr<Socket> socket_loop_monitored(int domain, int type, int protocol
return create_socket(domain, type, protocol, true);
}
std::unique_ptr<ListenSocket> socket_listen(int domain, int type, int protocol) {
return create_socket(domain, type, protocol, false);
}
std::unique_ptr<ListenSocket> 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

View File

@@ -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};
};

View File

@@ -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> 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<ListenSocket> 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<ListenSocket> 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

View File

@@ -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> 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> socket_ip(int type, int protocol);
std::unique_ptr<Socket> socket_loop_monitored(int domain, int type, int protocol);
/// Create a listening socket of the given domain, type and protocol.
std::unique_ptr<ListenSocket> socket_listen(int domain, int type, int protocol);
/// Create a listening socket and monitor it for data in the main loop.
std::unique_ptr<ListenSocket> 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<ListenSocket> socket_listen(int domain, int type, int protocol);
std::unique_ptr<ListenSocket> socket_listen_loop_monitored(int domain, int type, int protocol);
std::unique_ptr<ListenSocket> socket_ip_loop_monitored(int type, int protocol);
#else
// BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate.
inline std::unique_ptr<ListenSocket> socket_listen(int domain, int type, int protocol) {
return socket(domain, type, protocol);
}
inline std::unique_ptr<ListenSocket> socket_listen_loop_monitored(int domain, int type, int protocol) {
return socket_loop_monitored(domain, type, protocol);
}
inline std::unique_ptr<ListenSocket> 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

View File

@@ -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)

View File

@@ -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());

View File

@@ -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};

View File

@@ -567,7 +567,7 @@ void Sprinkler::set_valve_run_duration(const optional<size_t> 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<uint32_t>(roundf(this->valve_[valve_number].run_duration_number->state * 60));
} else {
return static_cast<uint32_t>(roundf(this->valve_[valve_number].run_duration_number->state));

View File

@@ -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)

View File

@@ -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();

View File

@@ -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:

View File

@@ -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")

View File

@@ -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;

View File

@@ -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,

View File

@@ -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(

View File

@@ -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();

View File

@@ -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

View File

@@ -75,6 +75,15 @@ void USBUartTypeCH34X::enable_channels() {
}
this->start_channels();
}
std::vector<CdcEps> 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

View File

@@ -20,6 +20,7 @@ static optional<CdcEps> 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

View File

@@ -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<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
};
} // namespace esphome::usb_uart

View File

@@ -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)

View File

@@ -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();

View File

@@ -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")

View File

@@ -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

View File

@@ -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"]

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_LWIP_FAST_SELECT
std::vector<struct lwip_sock *> monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read
#elif defined(USE_SOCKET_SELECT_SUPPORT)
std::vector<int> 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

View File

@@ -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<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, static_cast<const char *>(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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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());
}

View File

@@ -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<uint8_t>(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.

View File

@@ -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):

View File

@@ -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

View File

@@ -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];

View File

@@ -512,8 +512,8 @@ template<size_t STACK_SIZE, typename T = uint8_t> 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<std::integral T> 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<typename ReturnT = uint32_t> inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) {
constexpr uint32_t d = 125U;
constexpr uint32_t q = static_cast<uint32_t>((1ULL << 32) / d); // 34359738
constexpr uint32_t r = static_cast<uint32_t>((1ULL << 32) % d); // 46
// 1000 = 8 * 125; divide-by-8 is a free shift
uint64_t x = us >> 3;
uint32_t lo = static_cast<uint32_t>(x);
uint32_t hi = static_cast<uint32_t>(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<ReturnT>(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q
return static_cast<ReturnT>(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};

View File

@@ -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) {

View File

@@ -4,6 +4,17 @@
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
#include <stdbool.h>
#include <stdint.h>
// 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.

View File

@@ -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)')

View File

@@ -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)",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -13,3 +13,4 @@ openthread:
force_dataset: true
use_address: open-thread-test.local
poll_period: 20sec
output_power: 1dBm

View File

@@ -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"

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More