Merge branch 'fast-millis-esp32' into integration

This commit is contained in:
J. Nick Koston
2026-04-14 15:01:17 -10:00
155 changed files with 3515 additions and 749 deletions
+1 -1
View File
@@ -1 +1 @@
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6
dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
# yamllint disable-line rule:line-length
@@ -4,6 +4,7 @@ module.exports = {
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
TOO_BIG_MARKER: '<!-- too-big-request -->',
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
ORG_FORK_MARKER: '<!-- maintainer-access-warning -->',
MANAGED_LABELS: [
'new-component',
@@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
return { labels, deprecatedInfo };
}
// Strategy: Detect when maintainers cannot modify the PR branch
function detectMaintainerAccess(context) {
const pr = context.payload.pull_request;
// Only relevant for cross-repo PRs (forks)
if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
return null;
}
if (pr.maintainer_can_modify) {
return null;
}
const isOrgFork = pr.head.repo.owner.type === 'Organization';
console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
return { isOrgFork, orgName: pr.head.repo.owner.login };
}
// Strategy: Requirements detection
async function detectRequirements(allLabels, prFiles, context) {
const labels = new Set();
@@ -329,5 +347,6 @@ module.exports = {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
};
+11 -5
View File
@@ -12,9 +12,10 @@ const {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
} = require('./detectors');
const { handleReviews } = require('./reviews');
const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
const { applyLabels, removeOldLabels } = require('./labels');
// Fetch API data
@@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
deprecatedResult
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
@@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => {
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles)
detectDeprecatedComponents(github, context, changedFiles),
detectMaintainerAccess(context)
]);
// Extract deprecated component info
@@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
// Handle reviews and org fork comment
await Promise.all([
handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
handleMaintainerAccessComment(github, context, maintainerAccess)
]);
// Apply labels
await applyLabels(github, context, finalLabels);
+60 -2
View File
@@ -2,7 +2,8 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
DEPRECATED_COMPONENT_MARKER
DEPRECATED_COMPONENT_MARKER,
ORG_FORK_MARKER
} = require('./constants');
// Generate review messages
@@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
}
}
// Handle maintainer access warning comment
async function handleMaintainerAccessComment(github, context, maintainerAccess) {
if (!maintainerAccess) {
return;
}
const { owner, repo } = context.repo;
const pr_number = context.issue.number;
const prAuthor = context.payload.pull_request.user.login;
// Check if we already posted the warning (iterate pages to exit early)
let existingComment;
for await (const { data: comments } of github.paginate.iterator(
github.rest.issues.listComments,
{ owner, repo, issue_number: pr_number }
)) {
existingComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body && comment.body.includes(ORG_FORK_MARKER)
);
if (existingComment) {
break;
}
}
if (existingComment) {
console.log('Maintainer access warning comment already exists, skipping');
return;
}
let body;
if (maintainerAccess.isOrgFork) {
body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
`GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
`See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
} else {
body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`Please enable this option in the PR sidebar to allow maintainer collaboration.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr_number,
body
});
console.log('Created maintainer access warning comment');
}
module.exports = {
handleReviews
handleReviews,
handleMaintainerAccessComment
};
+3 -3
View File
@@ -20,20 +20,20 @@ env:
jobs:
label:
runs-on: ubuntu-latest
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- name: Auto Label PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
+3 -3
View File
@@ -47,7 +47,7 @@ jobs:
fi
- if: failure()
name: Review PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -62,7 +62,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: generated-proto-files
path: |
@@ -70,7 +70,7 @@ jobs:
esphome/components/api/api_pb2_service.*
- if: success()
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
- if: failure() && github.event.pull_request.head.repo.full_name == github.repository
name: Request changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -55,7 +55,7 @@ jobs:
- if: success() && github.event.pull_request.head.repo.full_name == github.repository
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({
+21 -19
View File
@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
# yamllint disable-line rule:line-length
@@ -159,7 +159,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -198,7 +198,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -231,7 +231,7 @@ jobs:
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -253,7 +253,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -387,14 +387,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -466,14 +466,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -555,14 +555,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -817,7 +817,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -841,7 +841,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -868,7 +868,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -882,7 +883,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -903,7 +904,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -929,7 +930,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -954,7 +955,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -967,7 +969,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-pr
path: memory-analysis-pr.json
@@ -34,7 +34,7 @@ jobs:
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
@@ -33,7 +33,7 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: Request reviews from component codeowners
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add external component comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify codeowners for component issues
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const {
+7 -7
View File
@@ -138,7 +138,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -221,7 +221,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -229,7 +229,7 @@ jobs:
repositories: home-assistant-addon
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -264,7 +264,7 @@ jobs:
repositories: esphome-schema
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -287,7 +287,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -295,7 +295,7 @@ jobs:
repositories: version-notifier
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for blocking labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.9
rev: v0.15.10
hooks:
# Run the linter.
- id: ruff
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.4.0-dev
PROJECT_NUMBER = 2026.5.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+8 -1
View File
@@ -750,8 +750,15 @@ def upload_using_esptool(
platformio_api.FlashImage(
path=idedata.firmware_bin_path, offset=firmware_offset
),
*idedata.extra_flash_images,
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
_LOGGER.warning(
"Skipping missing flash image declared by platform: %s",
image.path,
)
continue
flash_images.append(image)
mcu = "esp8266"
if CORE.is_esp32:
+14 -1
View File
@@ -2,7 +2,11 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
require_adc_oneshot_iram,
)
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -24,6 +28,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from . import (
ATTENUATION_MODES,
@@ -65,6 +70,13 @@ def validate_config(config):
return config
def _require_adc_iram(config: ConfigType) -> ConfigType:
"""Register ADC oneshot IRAM requirement during config validation."""
if CORE.is_esp32:
require_adc_oneshot_iram()
return config
ADCSensor = adc_ns.class_(
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
)
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s")),
validate_config,
_require_adc_iram,
)
CONF_ADC_CHANNEL_ID = "adc_channel_id"
+6 -2
View File
@@ -671,6 +671,7 @@ message SensorStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
option (no_delay) = true;
option (speed_optimized) = true;
fixed32 key = 1 [(force) = true];
float state = 2;
@@ -777,9 +778,10 @@ message SubscribeLogsResponse {
option (source) = SOURCE_SERVER;
option (log) = false;
option (no_delay) = false;
option (speed_optimized) = true;
LogLevel level = 1;
bytes message = 3;
LogLevel level = 1 [(force) = true];
bytes message = 3 [(force) = true];
}
// ==================== NOISE ENCRYPTION ====================
@@ -1625,6 +1627,7 @@ message BluetoothLEAdvertisementResponse {
}
message BluetoothLERawAdvertisement {
option (inline_encode) = true;
uint64 address = 1 [(force) = true];
sint32 rssi = 2 [(force) = true];
uint32 address_type = 3 [(max_value) = 4];
@@ -1637,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (no_delay) = true;
option (speed_optimized) = true;
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
}
+24 -12
View File
@@ -52,11 +52,11 @@
namespace esphome::api {
// Read a maximum of 5 messages per loop iteration to prevent starving other components.
// Maximum messages to read per loop iteration to prevent starving other components.
// This is a balance between API responsiveness and allowing other components to run.
// Since each message could contain multiple protobuf messages when using packet batching,
// this limits the number of messages processed, not the number of TCP packets.
static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5;
static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 10;
static constexpr uint8_t MAX_PING_RETRIES = 60;
static constexpr uint16_t PING_RETRY_INTERVAL = 1000;
static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2;
@@ -220,10 +220,17 @@ void APIConnection::loop() {
}
const uint32_t now = App.get_loop_component_start_time();
// Check if socket has data ready before attempting to read
if (this->helper_->is_socket_ready()) {
// Check if socket has data ready before attempting to read.
// Also try reading if we hit the message limit last time — LWIP's rcvevent
// (used by is_socket_ready) tracks pbuf dequeues, not bytes. When multiple
// messages share a TCP segment, the last message's data stays in LWIP's
// lastdata cache after rcvevent hits 0, making is_socket_ready() return false
// even though data remains.
if (this->helper_->is_socket_ready() || this->flags_.may_have_remaining_data) {
this->flags_.may_have_remaining_data = false;
// Read up to MAX_MESSAGES_PER_LOOP messages per loop to improve throughput
for (uint8_t message_count = 0; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
uint8_t message_count = 0;
for (; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
ReadPacketBuffer buffer;
err = this->helper_->read_packet(&buffer);
if (err == APIError::WOULD_BLOCK) {
@@ -245,6 +252,11 @@ void APIConnection::loop() {
return;
}
}
// If we hit the limit, there may be more data remaining in LWIP's
// lastdata cache that rcvevent doesn't account for.
if (message_count == MAX_MESSAGES_PER_LOOP) {
this->flags_.may_have_remaining_data = true;
}
}
// Process deferred batch if scheduled and timer has expired
@@ -2086,6 +2098,13 @@ void APIConnection::process_batch_() {
return;
}
// Ensure TCP_NODELAY is on before draining overflow and writing batch data.
// Log messages enable Nagle (NODELAY off) to coalesce small packets.
// If Nagle is still on when we try to drain, LWIP holds data in the
// Nagle buffer, the TCP send buffer stays full, and the overflow
// buffer can never drain — blocking the batch write indefinitely.
this->helper_->set_nodelay_for_message(false);
// Try to clear buffer first
if (!this->try_to_clear_buffer(true)) {
// Can't write now, we'll try again later
@@ -2193,13 +2212,6 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
shared_buf.resize(shared_buf.size() + footer_size);
}
// Ensure TCP_NODELAY is on before writing batch data.
// Log messages enable Nagle (NODELAY off) to coalesce small packets.
// Without this, batch data written to the socket sits in LWIP's Nagle
// buffer — the remote won't ACK until it sends its own data (e.g. a
// ping), which can take 20+ seconds.
this->helper_->set_nodelay_for_message(false);
// Send all collected messages
APIError err = this->helper_->write_protobuf_messages(ProtoWriteBuffer{&shared_buf},
std::span<const MessageInfo>(message_info, items_processed));
+1
View File
@@ -771,6 +771,7 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t batch_scheduled : 1;
uint8_t batch_first_message : 1; // For batch buffer allocation
uint8_t should_try_send_immediately : 1; // True after initial states are sent
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
#ifdef HAS_PROTO_MESSAGE_DUMP
uint8_t log_only_mode : 1;
#endif
+4 -1
View File
@@ -195,7 +195,10 @@ class APIFrameHelper {
}
// Get the frame footer size required by this protocol
uint8_t frame_footer_size() const { return frame_footer_size_; }
// Check if socket has data ready to read
// Check if socket has buffered data ready to read.
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
// Release excess memory from internal buffers after initial sync
void release_buffers() {
+2
View File
@@ -22,6 +22,8 @@ extend google.protobuf.MessageOptions {
optional bool log = 1039 [default=true];
optional bool no_delay = 1040 [default=false];
optional string base_class = 1041;
optional bool inline_encode = 1042 [default=false];
optional bool speed_optimized = 1043 [default=false];
}
extend google.protobuf.FieldOptions {
+46 -35
View File
@@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
#endif
return size;
}
uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state);
@@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
#endif
return pos;
}
uint32_t SensorStateResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SensorStateResponse::calculate_size() const {
uint32_t size = 0;
size += 5;
size += ProtoSize::calc_float(1, this->state);
@@ -912,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t
}
return true;
}
uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level), true);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26);
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_);
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_);
return pos;
}
uint32_t SubscribeLogsResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SubscribeLogsResponse::calculate_size() const {
uint32_t size = 0;
size += this->level ? 2 : 0;
size += ProtoSize::calc_length(1, this->message_len_);
size += 2;
size += ProtoSize::calc_length_force(1, this->message_len_);
return size;
}
#ifdef USE_API_NOISE
@@ -2324,40 +2334,41 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
}
return true;
}
uint8_t *BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8);
ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, this->address);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(this->rssi));
if (this->address_type) {
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24);
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->address_type);
}
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast<uint8_t>(this->data_len));
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data, this->data_len);
return pos;
}
uint32_t BluetoothLERawAdvertisement::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint64_force(1, this->address);
size += ProtoSize::calc_sint32_force(1, this->rssi);
size += this->address_type ? 2 : 0;
size += 2 + this->data_len;
return size;
}
uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
for (uint16_t i = 0; i < this->advertisements_len; i++) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->advertisements[i]);
auto &sub_msg = this->advertisements[i];
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10);
uint8_t *len_pos = pos;
ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8);
ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi));
if (sub_msg.address_type) {
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24);
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type);
}
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast<uint8_t>(sub_msg.data_len));
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len);
*len_pos = static_cast<uint8_t>(pos - len_pos - 1);
}
return pos;
}
uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
BluetoothLERawAdvertisementsResponse::calculate_size() const {
uint32_t size = 0;
for (uint16_t i = 0; i < this->advertisements_len; i++) {
size += ProtoSize::calc_message_force(1, this->advertisements[i].calculate_size());
auto &sub_msg = this->advertisements[i];
size += 2;
size += ProtoSize::calc_uint64_force(1, sub_msg.address);
size += ProtoSize::calc_sint32_force(1, sub_msg.rssi);
size += sub_msg.address_type ? 2 : 0;
size += 2 + sub_msg.data_len;
}
return size;
}
-2
View File
@@ -1887,8 +1887,6 @@ class BluetoothLERawAdvertisement final : public ProtoMessage {
uint32_t address_type{0};
uint8_t data[62]{};
uint8_t data_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
+6
View File
@@ -352,6 +352,12 @@ class ProtoEncode {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
*pos++ = b;
}
/// Reserve one byte for later backpatch (e.g., sub-message length).
/// Advances pos past the reserved byte without writing a value.
static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
pos++;
}
/// Write raw bytes to the buffer (no tag, no length prefix).
static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
const void *data, size_t len) {
+2 -2
View File
@@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent,
#endif
float get_reference_voltage(uint8_t phase) {
#ifdef USE_NUMBER
return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
#else
return 120.0; // Default voltage
#endif
}
float get_reference_current(uint8_t phase) {
#ifdef USE_NUMBER
return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
#else
return 5.0f; // Default current
#endif
+24 -1
View File
@@ -1,7 +1,11 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
)
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
from esphome.core import CORE
@@ -27,6 +31,7 @@ class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
micro_decoder_support: bool = False
def _get_data() -> AudioData:
@@ -50,6 +55,11 @@ def request_opus_support() -> None:
_get_data().opus_support = True
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample"
CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample"
CONF_MIN_CHANNELS = "min_channels"
@@ -208,6 +218,19 @@ async def to_code(config):
)
data = _get_data()
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False)
if not data.mp3_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
# Legacy audio_decoder.cpp support defines and components
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
+26 -10
View File
@@ -17,6 +17,7 @@ CODEOWNERS = ["@neffs", "@kbx81"]
DOMAIN = "bme68x_bsec2"
BSEC2_LIBRARY_VERSION = "1.10.2610"
BME68x_LIBRARY_VERSION = "v1.3.40408"
CONF_ALGORITHM_OUTPUT = "algorithm_output"
CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id"
@@ -184,16 +185,31 @@ async def to_code_base(config):
if core.CORE.using_arduino:
cg.add_library("Wire", None)
cg.add_library("SPI", None)
cg.add_library(
"BME68x Sensor library",
None,
"https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
if core.CORE.is_esp32:
from esphome.components.esp32 import add_idf_component
add_idf_component(
name="boschsensortec/Bosch-BME68x-Library",
repo="https://github.com/esphome-libs/Bosch-BME68x-Library",
ref=BME68x_LIBRARY_VERSION,
)
add_idf_component(
name="boschsensortec/Bosch-BSEC2-Library",
repo="https://github.com/esphome-libs/Bosch-BSEC2-Library",
ref=BSEC2_LIBRARY_VERSION,
)
else:
cg.add_library(
"BME68x Sensor library",
None,
f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
cg.add_define("USE_BSEC2")
-1
View File
@@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args):
await cg.register_parented(var, config[CONF_CANBUS_ID])
if (can_id := config.get(CONF_CAN_ID)) is not None:
can_id = await cg.templatable(can_id, args, cg.uint32)
cg.add(var.set_can_id(can_id))
cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID]))
+37 -12
View File
@@ -102,8 +102,34 @@ CC1101Component::CC1101Component() {
memset(this->pa_table_, 0, sizeof(this->pa_table_));
}
void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
void CC1101Component::setup() {
this->spi_setup();
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->configure();
if (this->is_failed()) {
return;
}
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
if (this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
});
}
}
void CC1101Component::configure() {
// Manual reset sequence per CC1101 datasheet section 19.1.2
this->cs_->digital_write(true);
delayMicroseconds(1);
this->cs_->digital_write(false);
@@ -126,11 +152,6 @@ void CC1101Component::setup() {
return;
}
// Setup GDO0 pin if configured
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->initialized_ = true;
for (uint8_t i = 0; i <= static_cast<uint8_t>(Register::TEST0); i++) {
@@ -140,16 +161,11 @@ void CC1101Component::setup() {
this->write_(static_cast<Register>(i));
}
this->set_output_power(this->output_power_requested_);
if (!this->enter_rx_()) {
this->mark_failed();
return;
}
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); });
}
}
void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi) {
@@ -160,6 +176,7 @@ void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float
}
void CC1101Component::loop() {
this->disable_loop();
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr ||
!this->gdo0_pin_->digital_read()) {
return;
@@ -240,6 +257,7 @@ void CC1101Component::begin_tx() {
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->detach_interrupt();
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
@@ -264,7 +282,7 @@ void CC1101Component::begin_rx() {
void CC1101Component::reset() {
this->strobe_(Command::RES);
this->setup();
this->configure();
}
void CC1101Component::set_idle() {
@@ -669,6 +687,13 @@ void CC1101Component::set_packet_mode(bool value) {
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
if (this->gdo0_pin_ != nullptr) {
if (value) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
} else {
this->gdo0_pin_->detach_interrupt();
}
}
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::IOCFG0);
+2
View File
@@ -25,6 +25,7 @@ class CC1101Component : public Component,
void setup() override;
void loop() override;
void dump_config() override;
void configure();
// Actions
void begin_tx();
@@ -93,6 +94,7 @@ class CC1101Component : public Component,
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
static void IRAM_ATTR gpio_intr(CC1101Component *arg);
// Packet handling
void call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi);
@@ -43,3 +43,11 @@ wave_4_26.extend(
},
},
)
ssd1677.extend(
"waveshare-3.97in",
width=800,
height=480,
mirror_x=True,
)
+34 -10
View File
@@ -671,11 +671,12 @@ def _is_framework_url(source: str) -> bool:
# The default/recommended arduino framework version
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 3, 7),
"latest": cv.Version(3, 3, 7),
"dev": cv.Version(3, 3, 7),
"recommended": cv.Version(3, 3, 8),
"latest": cv.Version(3, 3, 8),
"dev": cv.Version(3, 3, 8),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
@@ -695,6 +696,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# These versions correspond to pioarduino/esp-idf releases
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
cv.Version(3, 3, 6): cv.Version(5, 5, 2),
cv.Version(3, 3, 5): cv.Version(5, 5, 2),
@@ -714,17 +716,15 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 3, "1"),
"latest": cv.Version(5, 5, 3, "1"),
"recommended": cv.Version(5, 5, 4),
"latest": cv.Version(5, 5, 4),
"dev": cv.Version(5, 5, 4),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(
5, 5, 4
): "https://github.com/pioarduino/platform-espressif32.git#develop",
cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
@@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# The platform-espressif32 version
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
"recommended": cv.Version(55, 3, 37),
"latest": cv.Version(55, 3, 37),
"recommended": cv.Version(55, 3, 38, "1"),
"latest": cv.Version(55, 3, 38, "1"),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}
@@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
CONF_DISABLE_FATFS = "disable_fatfs"
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
# VFS requirement tracking
# Components that need VFS features can call require_vfs_*() functions
@@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
def require_vfs_select() -> None:
@@ -1168,6 +1170,17 @@ def require_fatfs() -> None:
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
def require_adc_oneshot_iram() -> None:
"""Mark that ADC oneshot IRAM safety is required by a component.
Call this from components that use the ADC oneshot driver. When flash cache is
disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management),
the ADC oneshot read function must be in IRAM to avoid crashes.
This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM.
"""
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
# Match operator followed by version-like string (digit or *)
@@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
@@ -2068,6 +2082,16 @@ async def to_code(config):
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
# Place ADC oneshot control functions in IRAM for cache safety
# When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread,
# power management, etc.), ADC reads will crash if these functions are in flash.
# Components using ADC call require_adc_oneshot_iram() to force this.
if (
CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False)
or advanced[CONF_ADC_ONESHOT_IN_IRAM]
):
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
+16
View File
@@ -1960,6 +1960,10 @@ BOARDS = {
"name": "Hornbill ESP32 Minima",
"variant": VARIANT_ESP32,
},
"huidu_hd_wf1": {
"name": "Huidu HD-WF1",
"variant": VARIANT_ESP32S2,
},
"huidu_hd_wf2": {
"name": "Huidu HD-WF2",
"variant": VARIANT_ESP32S3,
@@ -2028,6 +2032,10 @@ BOARDS = {
"name": "LilyGo T-Display-S3",
"variant": VARIANT_ESP32S3,
},
"lilygo-t-energy-s3": {
"name": "LilyGo T-Energy-S3",
"variant": VARIANT_ESP32S3,
},
"lilygo-t3-s3": {
"name": "LilyGo T3-S3",
"variant": VARIANT_ESP32S3,
@@ -2289,10 +2297,18 @@ BOARDS = {
"name": "S.ODI Ultra v1",
"variant": VARIANT_ESP32,
},
"seeed_xiao_esp32_s3_plus": {
"name": "Seeed Studio XIAO ESP32S3 Plus",
"variant": VARIANT_ESP32S3,
},
"seeed_xiao_esp32c3": {
"name": "Seeed Studio XIAO ESP32C3",
"variant": VARIANT_ESP32C3,
},
"seeed_xiao_esp32c5": {
"name": "Seeed Studio XIAO ESP32C5",
"variant": VARIANT_ESP32C5,
},
"seeed_xiao_esp32c6": {
"name": "Seeed Studio XIAO ESP32C6",
"variant": VARIANT_ESP32C6,
+27 -3
View File
@@ -23,7 +23,26 @@ extern "C" __attribute__((weak)) void initArduino() {}
namespace esphome {
void HOT yield() { vPortYield(); }
uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time())); }
// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig),
// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because
// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32.
// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract.
uint32_t IRAM_ATTR HOT millis() {
#if CONFIG_FREERTOS_HZ == 1000
if (xPortInIsrContext()) [[unlikely]] {
return xTaskGetTickCountFromISR();
}
return xTaskGetTickCount();
#else
return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time()));
#endif
}
// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is
// safe because the two are never cross-compared: millis() values are only used for
// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while
// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME),
// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly,
// so the Scheduler is internally consistent on the esp_timer clock.
uint64_t HOT millis_64() { return micros_to_millis<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(); }
@@ -61,6 +80,9 @@ uint32_t arch_get_cpu_freq_hz() {
}
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StackType_t
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void loop_task(void *pv_params) {
setup();
@@ -73,9 +95,11 @@ extern "C" void app_main() {
initArduino();
esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle);
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
&loop_task_tcb);
#else
xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1);
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
loop_task_stack, &loop_task_tcb, 1);
#endif
}
+4 -8
View File
@@ -4,7 +4,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <nvs_flash.h>
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -12,9 +11,6 @@ namespace esphome::esp32 {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
size_t actual_len;
esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len);
if (err != 0) {
@@ -108,8 +104,8 @@ bool ESP32Preferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str);
if (this->is_changed_(this->nvs_handle, save, key_str)) {
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size());
@@ -78,6 +78,14 @@ def ota_esphome_final_validate(config):
else:
new_ota_conf.append(ota_conf)
if len(merged_ota_esphome_configs_by_port) > 1:
raise cv.Invalid(
f"Only a single port is supported for '{CONF_OTA}' "
f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports "
f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate "
f"onto a single port; configs sharing a port are merged automatically."
)
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
full_conf[CONF_OTA] = new_ota_conf
@@ -147,6 +155,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
cg.add_define("USE_OTA_PASSWORD")
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
await cg.register_component(var, config)
await ota_to_code(var, config)
+33 -6
View File
@@ -15,6 +15,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#ifdef USE_LWIP_FAST_SELECT
#include "esphome/core/lwip_fast_select.h"
#endif
#include <cerrno>
#include <cstdio>
@@ -28,6 +31,17 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static ESPHomeOTAComponent *global_esphome_ota_component = nullptr;
// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ).
extern "C" void esphome_wake_ota_component_any_context() {
if (global_esphome_ota_component != nullptr) {
global_esphome_ota_component->enable_loop_soon_any_context();
}
}
void ESPHomeOTAComponent::setup() {
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->server_ == nullptr) {
@@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() {
this->server_failed_(LOG_STR("listen"));
return;
}
// loop() self-disables on its first idle tick; no explicit disable_loop() needed here.
global_esphome_ota_component = this;
#ifdef USE_LWIP_FAST_SELECT
// Filter fast-select wakes to this listener only. If the sock lookup returns nullptr,
// no wakes fire and loop() falls back to the self-disable safety net.
esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd()));
#endif
}
void ESPHomeOTAComponent::dump_config() {
@@ -81,13 +103,15 @@ void ESPHomeOTAComponent::dump_config() {
}
void ESPHomeOTAComponent::loop() {
// Skip handle_handshake_() call if no client connected and no incoming connections
// This optimization reduces idle loop overhead when OTA is not active
// Note: No need to check server_ for null as the component is marked failed in setup()
// if server_ creation fails
if (this->client_ != nullptr || this->server_->ready()) {
this->handle_handshake_();
// Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select
// listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back
// to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more
// iteration so a connection queued mid-session is still caught here.
if (this->client_ == nullptr && !this->server_->ready()) {
this->disable_loop();
return;
}
this->handle_handshake_();
}
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
@@ -576,6 +600,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
#ifdef USE_OTA_PASSWORD
this->cleanup_auth_();
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
// set while we were in LOOP state, would be lost to enable_pending_loops_()).
}
void ESPHomeOTAComponent::yield_and_feed_watchdog_() {
+1 -1
View File
@@ -7,7 +7,7 @@ namespace gdk101 {
static const char *const TAG = "gdk101";
static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5;
static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 10;
static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30;
static constexpr uint32_t RESET_INTERVAL_ID = 0;
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
+6 -1
View File
@@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
# Use the global's value_type alias as the lambda return type so
# TemplatableFn stores a direct function pointer instead of going through
# the deprecated converting trampoline when the value expression deduces
# to a different type (e.g. int literal assigned to a float global).
value_type = cg.RawExpression(f"{full_id.type}::value_type")
templ = await cg.templatable(
config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True
config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression
)
cg.add(var.set_value(templ))
return var
+1 -1
View File
@@ -8,7 +8,7 @@ from .. import hbridge_ns
CODEOWNERS = ["@DotNetDann"]
HBridgeLightOutput = hbridge_ns.class_(
"HBridgeLightOutput", cg.PollingComponent, light.LightOutput
"HBridgeLightOutput", cg.Component, light.LightOutput
)
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
@@ -1,20 +1,17 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/output/float_output.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/log.h"
#include "esphome/components/output/float_output.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace hbridge {
// Using PollingComponent as the updates are more consistent and reduces flickering
class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
class HBridgeLightOutput : public Component, public light::LightOutput {
public:
HBridgeLightOutput() : PollingComponent(1) {}
void set_pina_pin(output::FloatOutput *pina_pin) { pina_pin_ = pina_pin; }
void set_pinb_pin(output::FloatOutput *pinb_pin) { pinb_pin_ = pinb_pin; }
void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; }
void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
@@ -24,16 +21,16 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
return traits;
}
void setup() override { this->forward_direction_ = false; }
void setup() override { this->disable_loop(); }
void update() override {
// This method runs around 60 times per second
// We cannot do the PWM ourselves so we are reliant on the hardware PWM
if (!this->forward_direction_) { // First LED Direction
void loop() override {
// Only called when both channels are active — alternate H-bridge direction
// each iteration to multiplex cold and warm white.
if (!this->forward_direction_) {
this->pina_pin_->set_level(this->pina_duty_);
this->pinb_pin_->set_level(0);
this->forward_direction_ = true;
} else { // Second LED Direction
} else {
this->pina_pin_->set_level(0);
this->pinb_pin_->set_level(this->pinb_duty_);
this->forward_direction_ = false;
@@ -43,15 +40,32 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput {
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void write_state(light::LightState *state) override {
state->current_values_as_cwww(&this->pina_duty_, &this->pinb_duty_, false);
float new_pina, new_pinb;
state->current_values_as_cwww(&new_pina, &new_pinb, false);
this->pina_duty_ = new_pina;
this->pinb_duty_ = new_pinb;
if (new_pina != 0.0f && new_pinb != 0.0f) {
// Both channels active — need loop to alternate H-bridge direction
this->high_freq_.start();
this->enable_loop();
} else {
// Zero or one channel active — drive pins directly, no multiplexing needed
this->high_freq_.stop();
this->disable_loop();
this->pina_pin_->set_level(new_pina);
this->pinb_pin_->set_level(new_pinb);
}
}
protected:
output::FloatOutput *pina_pin_;
output::FloatOutput *pinb_pin_;
float pina_duty_ = 0;
float pinb_duty_ = 0;
bool forward_direction_ = false;
float pina_duty_{0};
float pinb_duty_{0};
bool forward_direction_{false};
HighFrequencyLoopRequester high_freq_;
};
} // namespace hbridge
+1 -1
View File
@@ -127,6 +127,6 @@ async def to_code(config):
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_library("tonia/HeatpumpIR", "1.0.40")
cg.add_library("tonia/HeatpumpIR", "1.0.41")
if CORE.is_libretiny or CORE.is_esp32:
CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"])
@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
)
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
def _validate_esp32_variant(config):
+10 -11
View File
@@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
@@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
}
/*
Light sensor: 38th bytes
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
}
#endif
#ifdef USE_BINARY_SENSOR
@@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_move_sensors_[gate].set_sensor(s);
}
void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_still_sensors_[gate].set_sensor(s);
}
#endif
+2 -2
View File
@@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
#endif
};
+14 -15
View File
@@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY])
if (this->detection_distance_sensor_ != nullptr) {
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
if (this->detection_distance_sensor_.has_sensor()) {
int new_detect_distance = 0;
if (target_state != 0x00 && (target_state & MOVE_BITMASK)) {
new_detect_distance =
@@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() {
} else if (target_state != 0x00) {
new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]);
}
this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance);
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
@@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
}
/*
Light sensor value
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
}
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
}
#endif
// the radar module won't tell us when it's done, so we just have to keep polling...
@@ -846,12 +846,11 @@ void LD2412Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_move_sensors_[gate].set_sensor(s);
}
void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
this->gate_still_sensors_[gate].set_sensor(s);
}
#endif
+2 -2
View File
@@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
#endif
};
+10 -10
View File
@@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() {
SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count);
// Moving Target Count
SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count);
#endif
#ifdef USE_BINARY_SENSOR
@@ -872,33 +873,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU
void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); }
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) {
this->move_x_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_x_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) {
this->move_y_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_y_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) {
this->move_speed_sensors_[target] = new SensorWithDedup<int16_t>(s);
this->move_speed_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) {
this->move_angle_sensors_[target] = new SensorWithDedup<float>(s);
this->move_angle_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) {
this->move_distance_sensors_[target] = new SensorWithDedup<uint16_t>(s);
this->move_distance_sensors_[target].set_sensor(s);
}
void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) {
this->move_resolution_sensors_[target] = new SensorWithDedup<uint16_t>(s);
this->move_resolution_sensors_[target].set_sensor(s);
}
void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_target_count_sensors_[zone].set_sensor(s);
}
void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_still_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_still_target_count_sensors_[zone].set_sensor(s);
}
void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
this->zone_moving_target_count_sensors_[zone].set_sensor(s);
}
#endif
#ifdef USE_TEXT_SENSOR
+9 -9
View File
@@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice {
ZoneOfNumbers zone_numbers_[MAX_ZONES];
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float> *, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_moving_target_count_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float>, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_moving_target_count_sensors_{};
#endif
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
+22 -22
View File
@@ -11,28 +11,20 @@
#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \
protected: \
ld24xx::SensorWithDedup<dedup_type> *name##_sensor_{nullptr}; \
ld24xx::SensorWithDedup<dedup_type> name##_sensor_{}; \
\
public: \
void set_##name##_sensor(sensor::Sensor *sensor) { \
this->name##_sensor_ = new ld24xx::SensorWithDedup<dedup_type>(sensor); \
}
void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); }
#endif
#define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \
if ((sensor) != nullptr) { \
LOG_SENSOR(tag, name, (sensor)->sens); \
if ((sensor).has_sensor()) { \
LOG_SENSOR(tag, name, (sensor).get_sensor()); \
}
#define SAFE_PUBLISH_SENSOR(sensor, value) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_if_not_dup(value); \
}
#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value)
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_unknown(); \
}
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown()
#define highbyte(val) (uint8_t)((val) >> 8)
#define lowbyte(val) (uint8_t)((val) &0xff)
@@ -73,25 +65,33 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
}
#ifdef USE_SENSOR
// Helper class to store a sensor with a deduplicator & publish state only when the value changes
/// Sensor with deduplication — sensor may be null, null check is internal.
/// Stored inline, no heap allocation. Does nothing when no sensor is set.
template<typename T> class SensorWithDedup {
public:
SensorWithDedup(sensor::Sensor *sens) : sens(sens) {}
void set_sensor(sensor::Sensor *sens) {
this->sens_ = sens;
this->dedup_ = {};
}
void publish_state_if_not_dup(T state) {
if (this->publish_dedup.next(state)) {
this->sens->publish_state(static_cast<float>(state));
if (this->sens_ != nullptr && this->dedup_.next(state)) {
this->sens_->publish_state(static_cast<float>(state));
}
}
void publish_state_unknown() {
if (this->publish_dedup.next_unknown()) {
this->sens->publish_state(NAN);
if (this->sens_ != nullptr && this->dedup_.next_unknown()) {
this->sens_->publish_state(NAN);
}
}
sensor::Sensor *sens;
Deduplicator<T> publish_dedup;
bool has_sensor() const { return this->sens_ != nullptr; }
sensor::Sensor *get_sensor() const { return this->sens_; }
protected:
sensor::Sensor *sens_{nullptr};
Deduplicator<T> dedup_;
};
#endif
} // namespace esphome::ld24xx
+4 -8
View File
@@ -3,7 +3,6 @@
#include "preferences.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -11,9 +10,6 @@ namespace esphome::libretiny {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
fdb_blob_make(this->blob, data, len);
size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob);
if (actual_len != len) {
@@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str);
if (this->is_changed_(&this->db, save, key_str)) {
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());
+57 -6
View File
@@ -58,6 +58,12 @@ void AddressableLightTransformer::start() {
// our transition will handle brightness, disable brightness in correction.
this->light_.correction_.set_local_brightness(255);
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
// Uniformity scan is deferred to the first apply() call. start() can run before the underlying
// LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE
// triggering a transition), and reading through ESPColorView would deref a null buffer.
this->uniform_start_scanned_ = false;
this->uniform_start_is_uniform_ = false;
}
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
@@ -97,12 +103,57 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
// non-linear when applying small deltas.
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
// frame buffer is valid. When every LED already has the same color (the common case: plain
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
// round to stored 0, freezing progress).
if (!this->uniform_start_scanned_) {
this->uniform_start_scanned_ = true;
if (this->light_.size() > 0) {
Color first = this->light_[0].get();
bool uniform = true;
for (int32_t i = 1; i < this->light_.size(); i++) {
if (this->light_[i].get() != first) {
uniform = false;
break;
}
}
if (uniform) {
this->uniform_start_color_ = first;
this->uniform_start_is_uniform_ = true;
}
}
}
if (this->uniform_start_is_uniform_) {
// All LEDs started at the same color: compute the interpolated value once and write it to
// every LED. No read-back, so each LED's stored byte advances through every gamma threshold
// as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values.
//
// Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be
// overwritten on the next apply() here. The fallback path below would have respected them
// via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we
// support, so this is acceptable.
// lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress).
const Color &start = this->uniform_start_color_;
int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));
uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining);
uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining);
uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining);
uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining);
for (auto led : this->light_) {
led.set_rgbw(r, g, b, w);
}
} else {
int32_t scale =
int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
}
}
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();
@@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer {
AddressableLight &light_;
float last_transition_progress_{0.0f};
Color target_color_{};
Color uniform_start_color_{};
bool uniform_start_scanned_{false};
bool uniform_start_is_uniform_{false};
};
} // namespace esphome::light
@@ -22,4 +22,20 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const {
return (target - a <= b - target) ? lo : lo + 1;
}
Color ESPColorCorrection::color_uncorrect(Color color) const {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const {
if (max_brightness == 0 || this->local_brightness_ == 0)
return 0;
// Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero,
// (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs.
uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL;
uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_;
return static_cast<uint8_t>(std::min(res, uint32_t(255)));
}
} // namespace esphome::light
@@ -46,38 +46,18 @@ class ESPColorCorrection {
uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_);
return this->gamma_correct_(res);
}
inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
Color color_uncorrect(Color color) const;
inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.red == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(red, this->max_brightness_.red);
}
inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.green == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(green, this->max_brightness_.green);
}
inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(blue, this->max_brightness_.blue);
}
inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE {
if (this->max_brightness_.white == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
return this->color_uncorrect_channel_(white, this->max_brightness_.white);
}
protected:
@@ -85,6 +65,9 @@ class ESPColorCorrection {
uint8_t gamma_correct_(uint8_t value) const;
/// Reverse gamma: binary search the forward PROGMEM table
uint8_t gamma_uncorrect_(uint8_t value) const;
/// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line
/// to avoid duplicating two 16-bit divides at every call site.
uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const;
const uint16_t *gamma_table_{nullptr};
Color max_brightness_{255, 255, 255, 255};
+1 -1
View File
@@ -341,7 +341,7 @@ async def to_code(configs):
df.LOGGER.info("LVGL will use hardware rotation via display driver")
else:
rotation_type = RotationType.ROTATION_SOFTWARE
if get_esp32_variant() == VARIANT_ESP32P4:
if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4:
df.LOGGER.info("LVGL will use software rotation (PPA accelerated)")
else:
df.LOGGER.info("LVGL will use software rotation")
+4
View File
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
@@ -24,6 +25,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(MCP23016),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -35,6 +37,8 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
def validate_mode(value):
+14
View File
@@ -24,11 +24,22 @@ void MCP23016::setup() {
// all pins input
this->write_reg_(MCP23016_IODIR1, 0xFFFF);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
this->set_invalidate_on_read_(false);
}
this->disable_loop();
}
void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); }
void MCP23016::loop() {
// Invalidate cache at the start of each loop
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
this->disable_loop();
}
}
bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); }
@@ -37,6 +48,9 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin
void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) {
if (flags == gpio::FLAG_INPUT) {
this->update_reg_(pin, true, MCP23016_IODIR1);
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
this->update_reg_(pin, false, MCP23016_IODIR1);
}
+4
View File
@@ -35,7 +35,10 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::
float get_setup_priority() const override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
protected:
static void IRAM_ATTR gpio_intr(MCP23016 *arg);
// Virtual methods from CachedGpioExpander
bool digital_read_hw(uint8_t pin) override;
bool digital_read_cache(uint8_t pin) override;
@@ -51,6 +54,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::
uint16_t olat_{0x0000};
// Cache for input values (16-bit combined for both banks)
uint16_t input_mask_{0x0000};
InternalGPIOPin *interrupt_pin_{nullptr};
};
class MCP23016GPIOPin : public GPIOPin {
+1 -1
View File
@@ -170,7 +170,7 @@ async def to_code(config):
cg.add_library("LEAmDNS", None)
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.10.0")
add_idf_component(name="espressif/mdns", ref="1.11.0")
cg.add_define("USE_MDNS")
@@ -451,6 +451,8 @@ async def to_code(config):
ota.request_ota_state_listeners()
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
@@ -29,14 +29,6 @@ void VADModel::log_model_config() {
bool StreamingModel::load_model_() {
RAMAllocator<uint8_t> arena_allocator;
if (this->tensor_arena_ == nullptr) {
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
if (this->tensor_arena_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
return false;
}
}
if (this->var_arena_ == nullptr) {
this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE);
if (this->var_arena_ == nullptr) {
@@ -53,6 +45,26 @@ bool StreamingModel::load_model_() {
return false;
}
// Probe for the actual required tensor arena size if not yet determined
if (!this->tensor_arena_size_probed_) {
size_t probed_size = this->probe_arena_size_();
if (probed_size > 0) {
ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size);
this->tensor_arena_size_ = probed_size;
} else {
ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_);
}
this->tensor_arena_size_probed_ = true;
}
if (this->tensor_arena_ == nullptr) {
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
if (this->tensor_arena_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
return false;
}
}
if (this->interpreter_ == nullptr) {
this->interpreter_ =
make_unique<tflite::MicroInterpreter>(tflite::GetModel(this->model_start_), this->streaming_op_resolver_,
@@ -94,6 +106,70 @@ bool StreamingModel::load_model_() {
return true;
}
size_t StreamingModel::probe_arena_size_() {
RAMAllocator<uint8_t> arena_allocator;
// Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different
// versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct,
// and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16
// bytes.
size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15,
(this->tensor_arena_size_ * 2 + 15) & ~15};
for (size_t attempt_size : attempt_sizes) {
uint8_t *probe_arena = arena_allocator.allocate(attempt_size);
if (probe_arena == nullptr) {
continue;
}
// Verify the model works at all with this arena size
auto probe_interpreter = make_unique<tflite::MicroInterpreter>(
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_);
if (probe_interpreter->AllocateTensors() != kTfLiteOk) {
probe_interpreter.reset();
arena_allocator.deallocate(probe_arena, attempt_size);
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
continue;
}
// Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment).
// If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds.
size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15;
probe_interpreter.reset();
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
size_t upper = attempt_size;
while (lower < upper) {
auto test_interpreter = make_unique<tflite::MicroInterpreter>(
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_);
bool ok = test_interpreter->AllocateTensors() == kTfLiteOk;
test_interpreter.reset();
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
if (ok) {
// Found a working size smaller than the full arena
upper = lower + 16; // Pad by 16 bytes to be safe for future allocations
break;
}
// Try the midpoint between current attempt and full size
lower = ((lower + upper) / 2 + 15) & ~15;
}
arena_allocator.deallocate(probe_arena, attempt_size);
return upper;
}
return 0;
}
void StreamingModel::unload_model() {
this->interpreter_.reset();
@@ -63,6 +63,10 @@ class StreamingModel {
/// @brief Allocates tensor and variable arenas and sets up the model interpreter
/// @return True if successful, false otherwise
bool load_model_();
/// @brief Probes the actual required tensor arena size by trial allocation.
/// Tries the manifest size first, then 2x if that fails.
/// @return The required arena size rounded up to 16-byte alignment, or 0 on failure.
size_t probe_arena_size_();
/// @brief Returns true if successfully registered the streaming model's TensorFlow operations
bool register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver);
@@ -70,6 +74,7 @@ class StreamingModel {
bool loaded_{false};
bool enabled_{true};
bool tensor_arena_size_probed_{false};
bool unprocessed_probability_status_{false};
uint8_t current_stride_step_{0};
int16_t ignore_windows_{-MIN_SLICES_BEFORE_DETECTION};
+2 -1
View File
@@ -28,7 +28,8 @@ void AirConditioner::on_status_change() {
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
// Read existing presets (set by codegen), append frost protection, write back
const auto &existing = this->get_traits().get_supported_custom_presets();
auto traits = this->get_traits();
const auto &existing = traits.get_supported_custom_presets();
bool found = false;
for (const char *p : existing) {
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {
+4 -4
View File
@@ -234,9 +234,9 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL,
this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_,
this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
HAS_HARDWARE_ROTATION);
}
@@ -305,7 +305,7 @@ class MipiSpi : public display::Display,
this->write_command_(BRIGHTNESS, this->brightness_.value());
// calculate new madctl value from base value adjusted for rotation
uint8_t madctl = MADCTL; // lower 8 bits only
uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only
constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0;
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;
+74 -34
View File
@@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) {
}
#ifdef USE_NEXTION_COMMAND_SPACING
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
const uint32_t now = App.get_loop_component_start_time();
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) {
ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str());
return false;
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) {
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
this->write_array(to_send, sizeof(to_send));
#ifdef USE_NEXTION_COMMAND_SPACING
// Mark sent immediately after writing to UART. The pacer enforces inter-command
// spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_
// at zero indefinitely, making can_send() always return true and spacing a no-op.
// ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally.
if (!this->connection_state_.ignore_is_setup_) {
this->command_pacer_.mark_sent(now);
}
#endif // USE_NEXTION_COMMAND_SPACING
return true;
}
@@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
return false;
if (this->send_command_(command)) {
this->add_no_result_to_queue_("command");
return true;
}
return false;
this->add_no_result_to_queue_with_command_("command", command);
return true;
}
bool Nextion::send_command_printf(const char *format, ...) {
@@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) {
return false;
}
if (this->send_command_(buffer)) {
this->add_no_result_to_queue_("command_printf");
return true;
}
return false;
this->add_no_result_to_queue_with_command_("command_printf", buffer);
return true;
}
#ifdef NEXTION_PROTOCOL_LOG
@@ -349,25 +354,43 @@ void Nextion::loop() {
}
#ifdef USE_NEXTION_COMMAND_SPACING
// Try to send any pending commands if spacing allows
this->process_pending_in_queue_();
#ifdef USE_NEXTION_WAVEFORM
if (!this->waveform_queue_.empty()) {
this->check_pending_waveform_();
}
#endif // USE_NEXTION_WAVEFORM
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_COMMAND_SPACING
void Nextion::process_pending_in_queue_() {
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
return;
}
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
size_t commands_sent = 0;
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
// Check if first item in queue has a pending command
auto *front_item = this->nextion_queue_.front();
if (front_item && !front_item->pending_command.empty()) {
if (this->send_command_(front_item->pending_command)) {
// Command sent successfully, clear the pending command
front_item->pending_command.clear();
ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str());
for (auto *item : this->nextion_queue_) {
if (item == nullptr || item->pending_command.empty()) {
continue; // Already sent, waiting for ACK — skip, don't stop
}
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
if (++commands_sent > this->max_commands_per_loop_) {
ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring");
break;
}
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
const uint32_t now = App.get_loop_component_start_time();
if (!this->command_pacer_.can_send(now)) {
break; // Spacing not elapsed, stop for this loop iteration
}
if (!this->send_command_(item->pending_command)) {
break; // Unexpected send failure, stop
}
item->pending_command.clear();
ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str());
}
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() {
this->setup_callback_.call();
}
}
#ifdef USE_NEXTION_COMMAND_SPACING
this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent
ESP_LOGN(TAG, "Command spacing: marked command sent");
#endif
break;
case 0x02: // invalid Component ID or name was used
ESP_LOGW(TAG, "Invalid component ID/name");
@@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
}
/**
* @brief
* @brief Send a command and enqueue it for response tracking.
*
* @param variable_name Variable name for the queue
* @param command
* Callers are responsible for checking is_sleeping() before calling this
* method. The sleep guard is deliberately absent here because some callers
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
* sleep-safe and must bypass it.
*
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
* the command is saved in the queue entry for retry rather than dropped.
*
* @param variable_name Name of the variable or component associated with the command.
* @param command The raw command string to send.
*/
void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty())
@@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
std::string command = "get " + component->get_variable_name_to_send();
#ifdef USE_NEXTION_COMMAND_SPACING
// Always enqueue first so the response handler is present when the command
// is eventually sent. Store the command for retry if spacing blocked it;
// process_pending_in_queue_() will transmit it when the pacer allows.
nextion_queue->pending_command = command;
this->nextion_queue_.push_back(nextion_queue);
if (this->send_command_(command)) {
nextion_queue->pending_command.clear();
}
#else // USE_NEXTION_COMMAND_SPACING
if (this->send_command_(command)) {
this->nextion_queue_.push_back(nextion_queue);
} else {
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
}
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_WAVEFORM
@@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() {
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
component->get_wave_channel_id(), buffer_to_send);
if (!this->send_command_(command)) {
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->waveform_queue_.pop();
}
// If spacing or setup state blocks the send, leave the entry at the front
// of waveform_queue_ for retry on the next loop iteration via
// check_pending_waveform_(). Only pop on a successful send.
this->send_command_(command);
}
#endif // USE_NEXTION_WAVEFORM
+10 -5
View File
@@ -55,15 +55,20 @@ class NextionCommandPacer {
uint8_t get_spacing() const { return spacing_ms_; }
/**
* @brief Check if enough time has passed to send next command
* @return true if enough time has passed since last command
* @brief Check if enough time has passed to send the next command.
* @param now Current timestamp in milliseconds (use App.get_loop_component_start_time()
* for consistency with the rest of the queue timing).
* @return true if the spacing interval has elapsed since the last command was sent.
*/
bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; }
bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; }
/**
* @brief Mark a command as sent, updating the timing
* @brief Record the transmit timestamp for the most recently sent command.
* @param now Current timestamp in milliseconds, as returned by
* App.get_loop_component_start_time(). Must use the same clock
* source as can_send() to avoid unsigned underflow.
*/
void mark_sent() { last_command_time_ = millis(); }
void mark_sent(uint32_t now) { last_command_time_ = now; }
private:
uint8_t spacing_ms_;
+38 -9
View File
@@ -45,6 +45,18 @@ def is_remote_package(package_config: dict) -> bool:
return CONF_URL in package_config
def is_package_definition(value: object) -> bool:
"""Returns True if the value looks like a package definition rather than a config fragment.
Package definitions are IncludeFile objects, git URL shorthand strings, or
remote package dicts (containing a ``url:`` key). Config fragments are
plain dicts that represent component configuration.
"""
return isinstance(value, (yaml_util.IncludeFile, str)) or (
isinstance(value, dict) and is_remote_package(value)
)
def valid_package_contents(package_config: dict) -> dict:
"""Validate that a package looks like a plausible ESPHome config fragment.
@@ -309,20 +321,23 @@ def _walk_packages(
return config
packages = config[CONF_PACKAGES]
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
with cv.prepend_path(CONF_PACKAGES):
if isinstance(packages, yaml_util.IncludeFile):
# If the packages key is an IncludeFile, resolve it first before processing.
packages, _ = resolve_include(packages, [], context, strict_undefined=False)
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
if not isinstance(packages, dict):
_walk_package_list(packages, callback, context)
elif (result := _walk_package_dict(packages, callback, context)) is not None:
if not validate_deprecated:
if not validate_deprecated or any(
is_package_definition(v) for v in packages.values()
):
raise result
# Fallback: treat the dict as a single deprecated package.
# Note: this catches *any* cv.Invalid from the callback, which may
# mask real validation errors in named package dicts.
# This block can be removed once the single-package
# deprecation period (2026.7.0) is over.
config[CONF_PACKAGES] = [packages]
@@ -461,6 +476,9 @@ class _PackageProcessor:
self, package_config: dict | str, context_vars: ContextVars | None
) -> dict:
"""Resolve a single package and recurse into any nested packages."""
from_remote = isinstance(package_config, dict) and is_remote_package(
package_config
)
package_config = self.resolve_package(package_config, context_vars)
self.collect_substitutions(package_config)
@@ -470,7 +488,18 @@ class _PackageProcessor:
# Push context from !include vars on the package root and on the packages key
context_vars = push_context(package_config, context_vars)
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
return _walk_packages(package_config, self.process_package, context_vars)
# Disable the deprecated single-package fallback for remote
# packages. _process_remote_package returns dicts with
# already-resolved values that is_package_definition cannot
# distinguish from config fragments, so the fallback would
# always fire and mask real errors with wrong paths
# (packages->0 instead of packages-><name>).
return _walk_packages(
package_config,
self.process_package,
context_vars,
validate_deprecated=not from_remote,
)
def do_packages_pass(
+9 -1
View File
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
@@ -25,7 +26,12 @@ PCA6416AGPIOPin = pca6416a_ns.class_(
CONF_PCA6416A = "pca6416a"
CONFIG_SCHEMA = (
cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent)})
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x21))
)
@@ -35,6 +41,8 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
def validate_mode(value):
+18
View File
@@ -49,11 +49,22 @@ void PCA6416AComponent::setup() {
ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(),
this->status_has_error());
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
this->set_invalidate_on_read_(false);
}
this->disable_loop();
}
void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); }
void PCA6416AComponent::loop() {
// Invalidate cache at the start of each loop
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
this->disable_loop();
}
}
void PCA6416AComponent::dump_config() {
@@ -62,6 +73,7 @@ void PCA6416AComponent::dump_config() {
} else {
ESP_LOGCONFIG(TAG, "PCA6416A:");
}
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
@@ -101,6 +113,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) {
this->update_register_(pin, true, pull_dir);
this->update_register_(pin, false, pull_en);
}
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) {
this->update_register_(pin, true, io_dir);
if (has_pullup_) {
@@ -109,6 +124,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) {
} else {
ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors");
}
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
this->update_register_(pin, false, io_dir);
}
+4
View File
@@ -24,7 +24,10 @@ class PCA6416AComponent : public Component,
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
protected:
static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg);
// Virtual methods from CachedGpioExpander
bool digital_read_hw(uint8_t pin) override;
bool digital_read_cache(uint8_t pin) override;
@@ -43,6 +46,7 @@ class PCA6416AComponent : public Component,
esphome::i2c::ErrorCode last_error_;
/// Only the PCAL6416A has pull-up resistors
bool has_pullup_{false};
InternalGPIOPin *interrupt_pin_{nullptr};
};
/// Helper class to expose a PCA6416A pin as an internal input GPIO pin.
+25 -6
View File
@@ -9,7 +9,7 @@
#include <WiFi.h>
#include <pico/cyw43_arch.h> // For cyw43_arch_lwip_begin/end (LwIPLock)
#elif defined(USE_ETHERNET)
#include <LwipEthernet.h> // For ethernet_arch_lwip_begin/end (LwIPLock)
#include <lwip_wrap.h> // For LWIPMutex — LwIPLock mirrors its semantics (see below)
#include "esphome/components/ethernet/ethernet_component.h"
#endif
#include <hardware/structs/rosc.h>
@@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
// main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat
// assertion failures). See esphome#10681.
//
// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end.
// Both acquire the async_context recursive mutex to prevent IRQ callbacks from
// firing during critical sections.
// WiFi uses cyw43_arch_lwip_begin/end.
//
// For wired Ethernet, taking only the async_context lock is NOT enough. The
// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP`
// counter to decide whether to defer packet processing. If we hold the
// async_context lock without bumping `__inLWIP`, an interrupt-driven packet
// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat`
// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's
// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the
// lock, and on release re-unmask any GPIO IRQs that were deferred while we
// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because
// pulling lwip_wrap.h there poisons many TUs with lwIP types.
//
// When neither WiFi nor Ethernet is configured, this is a no-op since
// there's no network stack and no lwip callbacks to race with.
@@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); }
LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); }
#elif defined(USE_ETHERNET)
LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); }
LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); }
LwIPLock::LwIPLock() {
__inLWIP++;
ethernet_arch_lwip_begin();
}
LwIPLock::~LwIPLock() {
ethernet_arch_lwip_end();
__inLWIP--;
if (__needsIRQEN && !__inLWIP) {
__needsIRQEN = false;
ethernet_arch_lwip_gpio_unmask();
}
}
#else
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}
+8 -5
View File
@@ -55,11 +55,13 @@ void SafeModeComponent::dump_config() {
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition();
if (last_invalid != nullptr) {
ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label);
ESP_LOGW(TAG, "The device reset before the boot was marked successful");
ESP_LOGW(TAG,
"OTA rollback detected! Rolled back from partition '%s'\n"
" The device reset before the boot was marked successful",
last_invalid->label);
if (esp_reset_reason() == ESP_RST_BROWNOUT) {
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!");
ESP_LOGW(TAG, "See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!\n"
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
}
}
#endif
@@ -86,7 +88,8 @@ void SafeModeComponent::mark_successful() {
}
void SafeModeComponent::loop() {
if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
if (!this->boot_successful_ &&
(App.get_loop_component_start_time() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
// successful boot, reset counter
ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter");
this->mark_successful();
@@ -112,6 +112,8 @@ class BSDSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }
@@ -11,6 +11,10 @@
#include "esphome/core/wake.h"
#include "esphome/core/log.h"
#ifdef USE_OTA_PLATFORM_ESPHOME
extern "C" void esphome_wake_ota_component_any_context();
#endif
#ifdef USE_ESP8266
#include <coredecls.h> // For esp_schedule()
#elif defined(USE_RP2040)
@@ -854,6 +858,10 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) {
tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn);
tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn);
LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_);
#ifdef USE_OTA_PLATFORM_ESPHOME
// Must run before wake_loop_any_context() so flags are visible when the main task wakes.
esphome_wake_ota_component_any_context();
#endif
// Wake the main loop immediately so it can accept the new connection.
esphome::wake_loop_any_context();
return ERR_OK;
@@ -96,6 +96,8 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = ENOSYS;
return -1;
}
// Check if the socket has buffered data ready to read.
// See the ready() contract in socket.h — callers must drain or track remaining data.
// Intentionally unlocked — this is a polling check called every loop iteration.
// A stale read at worst delays processing by one loop tick; the actual I/O in
// read() holds the lwip lock and re-checks properly. See esphome#10681.
@@ -78,6 +78,8 @@ class LwIPSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }
+13
View File
@@ -53,6 +53,19 @@ bool socket_ready_fd(int fd, bool loop_monitored);
// Inline ready() — defined here because it depends on socket_ready/socket_ready_fd
// declared above, while the impl headers are included before those declarations.
//
// Contract (applies to ALL socket implementations — each platform implements
// ready() differently, but this contract holds regardless of the mechanism):
// ready() checks if the socket has buffered data ready to read. When it returns
// true, the caller MUST read until it would block (EAGAIN/EWOULDBLOCK), or until
// read() returns 0 to indicate EOF / connection closed, or track that it stopped
// early and retry without calling ready(). The next call to ready() will only
// report new data correctly if all callers fulfill this contract. Failing to
// drain the socket may cause ready() to return false while data remains readable.
//
// In practice each socket is owned by a single component, so this contract is
// straightforward to fulfill — but the owning component must be aware of it,
// especially if it limits how many messages it processes per loop iteration.
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
inline bool Socket::ready() const {
#ifdef USE_LWIP_FAST_SELECT
+9
View File
@@ -104,11 +104,17 @@ void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
delayMicroseconds(SWITCHING_DELAY_US);
}
void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
void SX126x::setup() {
// setup pins
this->busy_pin_->setup();
this->rst_pin_->setup();
this->dio1_pin_->setup();
if (this->dio1_pin_->is_internal()) {
static_cast<InternalGPIOPin *>(this->dio1_pin_)
->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
this->spi_setup();
@@ -348,6 +354,9 @@ void SX126x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
}
void SX126x::loop() {
if (this->dio1_pin_->is_internal()) {
this->disable_loop();
}
if (!this->dio1_pin_->digital_read()) {
return;
}
+2
View File
@@ -3,6 +3,7 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "sx126x_reg.h"
#include <utility>
#include <vector>
@@ -100,6 +101,7 @@ class SX126x : public Component,
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
static void IRAM_ATTR gpio_intr(SX126x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_packet_params_(uint8_t payload_length);
+5 -1
View File
@@ -53,6 +53,8 @@ void SX127x::write_fifo_(const std::vector<uint8_t> &packet) {
this->disable();
}
void IRAM_ATTR SX127x::gpio_intr(SX127x *arg) { arg->enable_loop_soon_any_context(); }
void SX127x::setup() {
// setup reset
this->rst_pin_->setup();
@@ -60,6 +62,7 @@ void SX127x::setup() {
// setup dio0
if (this->dio0_pin_) {
this->dio0_pin_->setup();
this->dio0_pin_->attach_interrupt(&SX127x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
@@ -313,6 +316,7 @@ void SX127x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
}
void SX127x::loop() {
this->disable_loop();
if (this->dio0_pin_ == nullptr || !this->dio0_pin_->digital_read()) {
return;
}
@@ -383,7 +387,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) {
if (millis() - start > 20) {
ESP_LOGE(TAG, "Set mode failure");
this->mark_failed();
break;
return;
}
}
}
+2
View File
@@ -4,6 +4,7 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include <vector>
namespace esphome {
@@ -86,6 +87,7 @@ class SX127x : public Component,
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
static void IRAM_ATTR gpio_intr(SX127x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_mode_(uint8_t modulation, uint8_t mode);
+4
View File
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
@@ -27,6 +28,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(TCA9555Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -38,6 +40,8 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
def validate_mode(value):
+18 -1
View File
@@ -24,9 +24,18 @@ void TCA9555Component::setup() {
this->mark_failed();
return;
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
this->set_invalidate_on_read_(false);
}
this->disable_loop();
}
void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); }
void TCA9555Component::dump_config() {
ESP_LOGCONFIG(TAG, "TCA9555:");
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
@@ -36,6 +45,9 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) {
if (flags == gpio::FLAG_INPUT) {
// Set mode mask bit
this->mode_mask_ |= 1 << pin;
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
// Clear mode mask bit
this->mode_mask_ &= ~(1 << pin);
@@ -43,7 +55,12 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) {
// Write GPIO to enable input mode
this->write_gpio_modes_();
}
void TCA9555Component::loop() { this->reset_pin_cache_(); }
void TCA9555Component::loop() {
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
this->disable_loop();
}
}
bool TCA9555Component::read_gpio_outputs_() {
if (this->is_failed())
+5
View File
@@ -24,7 +24,10 @@ class TCA9555Component : public Component,
void loop() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
protected:
static void IRAM_ATTR gpio_intr(TCA9555Component *arg);
bool digital_read_hw(uint8_t pin) override;
bool digital_read_cache(uint8_t pin) override;
void digital_write_hw(uint8_t pin, bool value) override;
@@ -39,6 +42,8 @@ class TCA9555Component : public Component,
bool read_gpio_modes_();
bool write_gpio_modes_();
bool read_gpio_outputs_();
InternalGPIOPin *interrupt_pin_{nullptr};
};
/// Helper class to expose a TCA9555 pin as an internal input GPIO pin.
+1 -1
View File
@@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration
my_integration_time_regval = integration_time;
this->integration_time_auto_ = false;
}
this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f;
this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f;
ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_);
}
void TCS34725Component::set_gain(TCS34725Gain gain) {
@@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
if (index == 0 && !this->ota_backend_) {
// First byte of a new upload: index==0 with actual data. (web_server_idf
// fires a separate start-marker call with data==nullptr/len==0 before the
// first real chunk; gate on len>0 so we only trigger once per upload.)
if (index == 0 && len > 0) {
// If a previous upload was interrupted (e.g. client closed the tab, TCP
// reset) the backend from that session may still be open. Tear it down
// so flash state doesn't get concatenated with the new image (which can
// produce a technically-valid-sized but corrupted firmware that bricks
// the device once it reboots).
if (this->ota_backend_) {
ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session");
this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER
// Notify listeners that the previous session was aborted before the new one starts.
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
#endif
this->ota_backend_.reset();
}
// Initialize OTA on first call
this->ota_init_(filename.c_str());
-1
View File
@@ -99,7 +99,6 @@ int main() {
setup();
while (true) {
loop();
esphome::yield();
}
return 0;
}
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.4.0-dev"
__version__ = "2026.5.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+50 -18
View File
@@ -85,8 +85,12 @@ void Application::setup() {
if (component->can_proceed())
continue;
// Force the status LED to blink WARNING while we wait for a slow
// component to come up. Cleared after setup() finishes if no real
// component has warning set.
this->app_state_ |= STATUS_LED_WARNING;
do {
uint8_t new_app_state = STATUS_LED_WARNING;
uint32_t now = millis();
// Process pending loop enables to handle GPIO interrupts during setup
@@ -96,17 +100,26 @@ void Application::setup() {
// Update loop_component_start_time_ right before calling each component
this->loop_component_start_time_ = millis();
this->components_[j]->call();
new_app_state |= this->components_[j]->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt();
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
yield();
} while (!component->can_proceed() && !component->is_failed());
}
// Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path
// above may have forced it on, and any status_clear_warning() calls
// from components during setup were intentional no-ops (gated by
// APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the
// real state. STATUS_LED_ERROR is never artificially forced, so its
// clear path always works and needs no reconciliation. Finally, set
// APP_STATE_SETUP_COMPLETE so subsequent warning clears go through
// the normal walk-and-clear path.
if (!this->any_component_has_status_flag_(STATUS_LED_WARNING))
this->app_state_ &= ~STATUS_LED_WARNING;
this->app_state_ |= APP_STATE_SETUP_COMPLETE;
ESP_LOGI(TAG, "setup() finished successfully!");
#ifdef USE_SETUP_PRIORITY_OVERRIDE
@@ -196,21 +209,40 @@ void Application::process_dump_config_() {
this->dump_config_at_++;
}
void HOT Application::feed_wdt(uint32_t time) {
static uint32_t last_feed = 0;
// Use provided time if available, otherwise get current time
uint32_t now = time ? time : millis();
// Compare in milliseconds (3ms threshold)
if (now - last_feed > 3) {
arch_feed_wdt();
last_feed = now;
#ifdef USE_STATUS_LED
if (status_led::global_status_led != nullptr) {
status_led::global_status_led->call();
}
#endif
void Application::feed_wdt() {
// Cold entry: callers without a millis() timestamp in hand. Fetches the
// time and takes the same rate-limit path as feed_wdt_with_time().
uint32_t now = millis();
if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) {
this->feed_wdt_slow_(now);
}
}
void HOT Application::feed_wdt_slow_(uint32_t time) {
// Callers (both feed_wdt() and feed_wdt_with_time()) have already
// confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded.
arch_feed_wdt();
this->last_wdt_feed_ = time;
#ifdef USE_STATUS_LED
if (status_led::global_status_led != nullptr) {
status_led::global_status_led->call();
}
#endif
}
bool Application::any_component_has_status_flag_(uint8_t flag) const {
// Walk all components (not just looping ones) so non-looping components'
// status bits are respected. Only called from the slow-path clear helpers
// (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an
// actual set→clear transition, so walking O(N) here is paid once per
// transition — not once per loop iteration.
for (auto *component : this->components_) {
if ((component->get_component_state() & flag) != 0)
return true;
}
return false;
}
void Application::reboot() {
ESP_LOGI(TAG, "Forcing a reboot");
for (auto &component : std::ranges::reverse_view(this->components_)) {
@@ -299,7 +331,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
while (pending_count > 0 && (now - start_time) < timeout_ms) {
// Feed watchdog during teardown to prevent triggering
this->feed_wdt(now);
this->feed_wdt_with_time(now);
// Process components and compact the array, keeping only those still pending
size_t still_pending = 0;
+47 -13
View File
@@ -385,7 +385,24 @@ class Application {
void schedule_dump_config() { this->dump_config_at_ = 0; }
void feed_wdt(uint32_t time = 0);
/// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the
/// rate of HAL pokes low while still being small enough that any plausible
/// watchdog timeout (seconds) has orders of magnitude of safety margin.
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3;
/// Feed the task watchdog. Cold entry — callers without a millis()
/// timestamp in hand. Out of line to keep call sites tiny.
void feed_wdt();
/// Feed the task watchdog, hot entry. Callers that already have a
/// millis() timestamp pay only a load + sub + branch on the common
/// (no-op) path. The actual arch feed + status LED update live in
/// feed_wdt_slow_.
void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) {
if (static_cast<uint32_t>(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] {
this->feed_wdt_slow_(time);
}
}
void reboot();
@@ -401,7 +418,18 @@ class Application {
*/
void teardown_components(uint32_t timeout_ms);
uint8_t get_app_state() const { return this->app_state_; }
/// Return the public app state status bits (STATUS_LED_* only).
/// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked
/// out so external readers (status_led components, etc.) never see them.
uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; }
/// True once Application::setup() has finished walking all components
/// and finalized the initial status flags. Before this point, the
/// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and
/// status_clear_* intentionally skips its walk-and-clear step so the
/// forced bit doesn't get wiped. Stored as a free bit on app_state_
/// (bit 6) to avoid costing additional RAM.
bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; }
// Helper macro for entity getter method declarations
#ifdef USE_DEVICES
@@ -577,6 +605,12 @@ class Application {
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
#endif
/// Walk all registered components looking for any whose component_state_
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
/// (which is a friend) to decide whether to clear the corresponding bit on
/// this->app_state_ (the app-wide "any component has this status" indicator).
bool any_component_has_status_flag_(uint8_t flag) const;
/// Register a component, detecting loop() override at compile time.
/// Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance.
template<typename T> void register_component_(T *comp) {
@@ -615,7 +649,10 @@ class Application {
/// Caller must ensure dump_config_at_ < components_.size().
void __attribute__((noinline)) process_dump_config_();
void feed_wdt_arch_();
/// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates
/// last_wdt_feed_, and re-dispatches the status LED. Out of line so the
/// inline wrapper stays tiny.
void feed_wdt_slow_(uint32_t time);
/// Perform a delay while also monitoring socket file descriptors for readiness
#ifdef USE_HOST
@@ -669,6 +706,7 @@ class Application {
// 4-byte members
uint32_t last_loop_{0};
uint32_t loop_component_start_time_{0};
uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path
#ifdef USE_HOST
int max_fd_{-1}; // Highest file descriptor number for select()
@@ -813,12 +851,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
this->drain_wake_notifications_();
#endif
// Process scheduled tasks
// Process scheduled tasks. Scheduler::call now feeds the watchdog itself
// after each scheduled item that actually runs, so we no longer need an
// unconditional feed here — when Scheduler::call has no work to do, the
// only elapsed time is a sleep wake + a few instructions, and when it does
// have work, it fed the wdt as it went.
this->scheduler.call(loop_start_time);
// Feed the watchdog timer
this->feed_wdt(loop_start_time);
// Process any pending enable_loop requests from ISRs
// This must be done before marking in_loop_ = true to avoid race conditions
if (this->has_pending_enable_loop_requests_) {
@@ -838,8 +877,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
}
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
uint8_t new_app_state = 0;
// Get the initial loop time at the start
uint32_t last_op_end_time = millis();
@@ -859,13 +896,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// Use the finish method to get the current time as the end time
last_op_end_time = guard.finish();
}
new_app_state |= component->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt(last_op_end_time);
this->feed_wdt_with_time(last_op_end_time);
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
#ifdef USE_RUNTIME_STATS
// Process any pending runtime stats printing after all components have run
+13
View File
@@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) {
}
void Component::status_clear_warning_slow_path_() {
this->component_state_ &= ~STATUS_LED_WARNING;
// Clear the app-wide STATUS_LED_WARNING bit only if setup has finished
// AND no other component still has it set. During setup the forced
// STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped
// by a transient component clear — Application::setup() reconciles
// the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE.
// The set path is unchanged (set_status_flag_ still writes directly).
if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING))
App.app_state_ &= ~STATUS_LED_WARNING;
ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_clear_error_slow_path_() {
this->component_state_ &= ~STATUS_LED_ERROR;
// STATUS_LED_ERROR is never artificially forced — it only ever lands
// in app_state_ via a real set_status_flag_ call. So the walk-and-clear
// path is always safe, including during setup.
if (!App.any_component_has_status_flag_(STATUS_LED_ERROR))
App.app_state_ &= ~STATUS_LED_ERROR;
ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_momentary_warning(const char *name, uint32_t length) {
+5
View File
@@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Bit 6 on Application::app_state_ (ONLY) — set at the end of
// Application::setup(). Component::status_clear_*_slow_path_() uses this to
// decide whether to propagate clears to App.app_state_. Never set on a
// Component's component_state_.
inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };

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