mirror of
https://github.com/esphome/esphome.git
synced 2026-06-29 09:10:25 +00:00
Compare commits
43 Commits
core-block
...
socket-lwi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2e1f8658 | ||
|
|
e855ddb1f1 | ||
|
|
e191f5fb4b | ||
|
|
e7c126d3dc | ||
|
|
af7b3821b8 | ||
|
|
d6c48e2d64 | ||
|
|
c01699f2a4 | ||
|
|
abc4069657 | ||
|
|
c3827423ba | ||
|
|
41a8e7f61b | ||
|
|
0a3f8c6d67 | ||
|
|
2375faee88 | ||
|
|
3da3a66d09 | ||
|
|
cf22559af0 | ||
|
|
17c2557cca | ||
|
|
012dadbf77 | ||
|
|
97e4bb71c3 | ||
|
|
b333bb76e4 | ||
|
|
273637b6d7 | ||
|
|
75efdd8662 | ||
|
|
86e4341a52 | ||
|
|
402398b389 | ||
|
|
dde81d3f63 | ||
|
|
eef806c806 | ||
|
|
cdbbcfb87d | ||
|
|
71da3dc2de | ||
|
|
0176305d24 | ||
|
|
c81e9fd154 | ||
|
|
556ef1894f | ||
|
|
519be06e73 | ||
|
|
1e935c128a | ||
|
|
a0e162912c | ||
|
|
1131af1690 | ||
|
|
6fb00baa29 | ||
|
|
fccfab8083 | ||
|
|
f54756ae2d | ||
|
|
49ba08cec9 | ||
|
|
81d12fd14a | ||
|
|
cc05bf3ed2 | ||
|
|
c182c0c74f | ||
|
|
a88e9b8146 | ||
|
|
7dea3756e9 | ||
|
|
fa0bff3374 |
@@ -116,6 +116,7 @@ Checks: >-
|
||||
-portability-template-virtual-member-function,
|
||||
-readability-ambiguous-smartptr-reset-call,
|
||||
-readability-avoid-nested-conditional-operator,
|
||||
-readability-container-contains,
|
||||
-readability-container-data-pointer,
|
||||
-readability-convert-member-functions-to-static,
|
||||
-readability-else-after-return,
|
||||
|
||||
@@ -1 +1 @@
|
||||
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
|
||||
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
|
||||
|
||||
@@ -29,7 +29,7 @@ Required fields:
|
||||
- **What does this implement/fix?**: Brief description of changes
|
||||
- **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.)
|
||||
- **Related issue**: Use `fixes <link>` syntax if applicable
|
||||
- **Pull request in esphome.io**: Link if docs are needed
|
||||
- **Pull request in esphome-docs**: Link if docs are needed
|
||||
- **Test Environment**: Check platforms you tested on
|
||||
- **Example config.yaml**: Include working example YAML
|
||||
- **Checklist**: Verify code is tested and tests added
|
||||
@@ -54,9 +54,9 @@ Required fields:
|
||||
|
||||
- fixes https://github.com/esphome/esphome/issues/XXX
|
||||
|
||||
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
|
||||
**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
|
||||
|
||||
- esphome/esphome.io#XXX
|
||||
- esphome/esphome-docs#XXX
|
||||
|
||||
## Test Environment
|
||||
|
||||
@@ -83,7 +83,7 @@ component_name:
|
||||
- [x] Tests have been added to verify that the new code works (under `tests/` folder).
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
|
||||
- [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
|
||||
```
|
||||
|
||||
## 5. Push and Create PR
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,7 +2,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report an issue with the ESPHome documentation
|
||||
url: https://github.com/esphome/esphome.io/issues/new/choose
|
||||
url: https://github.com/esphome/esphome-docs/issues/new/choose
|
||||
about: Report an issue with the ESPHome documentation.
|
||||
- name: Report an issue with the ESPHome web server
|
||||
url: https://github.com/esphome/esphome-webserver/issues/new/choose
|
||||
|
||||
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -16,9 +16,9 @@
|
||||
|
||||
- fixes <link to issue>
|
||||
|
||||
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
|
||||
**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
- esphome/esphome-docs#<esphome-docs PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
@@ -43,4 +43,4 @@
|
||||
- [ ] Tests have been added to verify that the new code works (under `tests/` folder).
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
|
||||
- [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
|
||||
|
||||
4
.github/actions/build-image/action.yaml
vendored
4
.github/actions/build-image/action.yaml
vendored
@@ -47,7 +47,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
20
.github/actions/restore-python/action.yml
vendored
20
.github/actions/restore-python/action.yml
vendored
@@ -27,18 +27,6 @@ runs:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv. ``uv pip install``
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
@@ -46,8 +34,8 @@ runs:
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -55,5 +43,5 @@ runs:
|
||||
python -m venv venv
|
||||
source ./venv/Scripts/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -5,7 +5,6 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
# Hypotehsis is only used for testing and is updated quite often
|
||||
- dependency-name: hypothesis
|
||||
|
||||
3
.github/scripts/auto-label-pr/constants.js
vendored
3
.github/scripts/auto-label-pr/constants.js
vendored
@@ -35,9 +35,6 @@ module.exports = {
|
||||
],
|
||||
|
||||
DOCS_PR_PATTERNS: [
|
||||
/https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/,
|
||||
/esphome\/esphome\.io#\d+/,
|
||||
// Keep matching the old esphome-docs name during the transition period
|
||||
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
|
||||
/esphome\/esphome-docs#\d+/
|
||||
]
|
||||
|
||||
8
.github/scripts/auto-label-pr/detectors.js
vendored
8
.github/scripts/auto-label-pr/detectors.js
vendored
@@ -107,8 +107,6 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
|
||||
/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/,
|
||||
];
|
||||
|
||||
const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename));
|
||||
|
||||
for (const file of addedFiles) {
|
||||
for (const re of platformPathPatterns) {
|
||||
const match = file.match(re);
|
||||
@@ -116,12 +114,6 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
|
||||
const platform = match[2];
|
||||
if (!apiData.platformComponents.includes(platform)) break;
|
||||
|
||||
// Skip if this is a restructure between flat and subdirectory forms (either direction):
|
||||
// <component>/<platform>.py <-> <component>/<platform>/__init__.py
|
||||
const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`;
|
||||
const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`;
|
||||
if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break;
|
||||
|
||||
labels.add('new-platform');
|
||||
const content = await fetchPrFileContent(github, context, file);
|
||||
if (content === null) {
|
||||
|
||||
7
.github/scripts/auto-label-pr/package.json
vendored
7
.github/scripts/auto-label-pr/package.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "auto-label-pr",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.js"
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { detectNewPlatforms, detectNewComponents } = require('../detectors');
|
||||
|
||||
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
|
||||
// to check for CONFIG_SCHEMA in newly added files.
|
||||
function makeGithub(content = '') {
|
||||
return {
|
||||
rest: {
|
||||
repos: {
|
||||
getContent: async () => ({
|
||||
data: { content: Buffer.from(content).toString('base64') }
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const CONTEXT = {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } }
|
||||
};
|
||||
|
||||
const API_DATA = {
|
||||
targetPlatforms: ['esp32', 'esp8266', 'rp2040'],
|
||||
platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve']
|
||||
};
|
||||
|
||||
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
|
||||
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectNewPlatforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectNewPlatforms', () => {
|
||||
describe('restructure detection (no false positives)', () => {
|
||||
it('flat .py -> subdir __init__.py is not a new platform', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/endstop/cover.py', status: 'removed' },
|
||||
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
|
||||
assert.equal(result.labels.size, 0);
|
||||
assert.equal(result.hasYamlLoadable, false);
|
||||
});
|
||||
|
||||
it('subdir __init__.py -> flat .py is not a new platform', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' },
|
||||
{ filename: 'esphome/components/endstop/cover.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
|
||||
assert.equal(result.labels.size, 0);
|
||||
assert.equal(result.hasYamlLoadable, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genuine new platforms', () => {
|
||||
it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
|
||||
assert.ok(result.labels.has('new-platform'));
|
||||
assert.equal(result.hasYamlLoadable, true);
|
||||
});
|
||||
|
||||
it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
|
||||
assert.ok(result.labels.has('new-platform'));
|
||||
assert.equal(result.hasYamlLoadable, true);
|
||||
});
|
||||
|
||||
it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA);
|
||||
assert.ok(result.labels.has('new-platform'));
|
||||
assert.equal(result.hasYamlLoadable, false);
|
||||
});
|
||||
|
||||
it('non-platform file addition produces no labels', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_sensor/sensor.py', status: 'added' },
|
||||
];
|
||||
// Override platformComponents so 'sensor' is not a recognized platform -> no label expected.
|
||||
const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] };
|
||||
const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData);
|
||||
assert.equal(result.labels.size, 0);
|
||||
assert.equal(result.hasYamlLoadable, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectNewComponents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectNewComponents', () => {
|
||||
it('new top-level __init__.py sets new-component', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/actuator/__init__.py', status: 'added', },
|
||||
];
|
||||
const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles);
|
||||
assert.ok(result.labels.has('new-component'));
|
||||
assert.equal(result.hasYamlLoadable, false);
|
||||
});
|
||||
|
||||
it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_component/__init__.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
|
||||
assert.ok(result.labels.has('new-component'));
|
||||
assert.equal(result.hasYamlLoadable, true);
|
||||
});
|
||||
|
||||
it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/my_platform/__init__.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles);
|
||||
assert.ok(result.labels.has('new-component'));
|
||||
assert.ok(result.labels.has('new-target-platform'));
|
||||
});
|
||||
|
||||
it('modified __init__.py does not set new-component', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/existing/__init__.py', status: 'modified' },
|
||||
];
|
||||
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
|
||||
assert.equal(result.labels.size, 0);
|
||||
});
|
||||
|
||||
it('nested __init__.py does not set new-component', async () => {
|
||||
const prFiles = [
|
||||
{ filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
|
||||
];
|
||||
const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
|
||||
assert.equal(result.labels.size, 0);
|
||||
});
|
||||
});
|
||||
2
.github/workflows/auto-label-pr.yml
vendored
2
.github/workflows/auto-label-pr.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
12
.github/workflows/ci-api-proto.yml
vendored
12
.github/workflows/ci-api-proto.yml
vendored
@@ -26,16 +26,6 @@ jobs:
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
@@ -44,7 +34,7 @@ jobs:
|
||||
sudo apt install -y protobuf-compiler
|
||||
protoc --version
|
||||
- name: Install python dependencies
|
||||
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
- name: Generate files
|
||||
run: script/api_protobuf/api_protobuf.py
|
||||
- name: Check for changes
|
||||
|
||||
2
.github/workflows/ci-docker.yml
vendored
2
.github/workflows/ci-docker.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
27
.github/workflows/ci-github-scripts.yml
vendored
27
.github/workflows/ci-github-scripts.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: CI - GitHub Scripts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, beta, release]
|
||||
paths:
|
||||
- ".github/scripts/**"
|
||||
- ".github/workflows/ci-github-scripts.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/scripts/**"
|
||||
- ".github/workflows/ci-github-scripts.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-auto-label-pr:
|
||||
name: Test auto-label-pr scripts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run tests
|
||||
working-directory: .github/scripts/auto-label-pr
|
||||
run: npm test
|
||||
119
.github/workflows/ci.yml
vendored
119
.github/workflows/ci.yml
vendored
@@ -6,6 +6,14 @@ on:
|
||||
branches: [dev, beta, release]
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "**"
|
||||
- "!.github/workflows/*.yml"
|
||||
- "!.github/actions/build-image/*"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "!.yamllint"
|
||||
- "!.github/dependabot.yml"
|
||||
- "!docker/**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
@@ -44,26 +52,14 @@ jobs:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv. ``uv pip install``
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
uv pip install -e .
|
||||
pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
pip install -e .
|
||||
|
||||
pylint:
|
||||
name: Check pylint
|
||||
@@ -93,8 +89,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -173,10 +167,6 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
@@ -191,7 +181,7 @@ jobs:
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
|
||||
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
@@ -217,8 +207,6 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -234,14 +222,14 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
. ./venv/Scripts/activate.ps1
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
@@ -257,17 +245,13 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
outputs:
|
||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
import-time: ${{ steps.determine.outputs.import-time }}
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
native-idf: ${{ steps.determine.outputs.native-idf }}
|
||||
native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -301,27 +285,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
EXTRA_ARGS=""
|
||||
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
|
||||
EXTRA_ARGS="--force-all"
|
||||
echo "::notice::ci-run-all label detected -- forcing every CI job to run"
|
||||
fi
|
||||
output=$(python script/determine-jobs.py $EXTRA_ARGS)
|
||||
output=$(python script/determine-jobs.py)
|
||||
echo "Test determination output:"
|
||||
echo "$output" | jq
|
||||
|
||||
# Extract individual fields
|
||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
|
||||
echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -365,24 +340,14 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
@@ -394,7 +359,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
|
||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
|
||||
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
@@ -452,12 +417,9 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
|
||||
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
${{ steps.build.outputs.binary }}
|
||||
pytest tests/benchmarks/python/ --codspeed --no-cov
|
||||
run: ${{ steps.build.outputs.binary }}
|
||||
mode: simulation
|
||||
|
||||
clang-tidy-single:
|
||||
@@ -531,13 +493,7 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -549,7 +505,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -609,13 +565,7 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -627,7 +577,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -704,13 +654,7 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -722,7 +666,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
script/clang-tidy --all-headers --fix ${{ matrix.options }}
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -879,14 +823,10 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
# Comma-joined subset of the native-IDF representative component list,
|
||||
# computed by script/determine-jobs.py (native_idf_components_to_test).
|
||||
# Single source of truth -- the full list lives in
|
||||
# script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
|
||||
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -967,8 +907,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -12,16 +12,10 @@ jobs:
|
||||
dashboard-deprecation-comment:
|
||||
name: Dashboard deprecation comment
|
||||
runs-on: ubuntu-latest
|
||||
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
|
||||
# roll up everything merged into dev since the last cut, which can
|
||||
# include dashboard changes that have already been reviewed once.
|
||||
# The bot's purpose is to warn new contributors before they invest
|
||||
# time -- that only applies to PRs entering dev.
|
||||
if: github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/external-component-bot.yml
vendored
2
.github/workflows/external-component-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
20
.github/workflows/pr-title-check.yml
vendored
20
.github/workflows/pr-title-check.yml
vendored
@@ -29,11 +29,10 @@ jobs:
|
||||
} = require('./.github/scripts/detect-tags.js');
|
||||
|
||||
const title = context.payload.pull_request.title;
|
||||
const user = context.payload.pull_request.user;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
// Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
|
||||
// they have their own title formats.
|
||||
if (user.type === 'Bot') {
|
||||
// Skip bot PRs (e.g. dependabot) - they have their own title format
|
||||
if (author === 'dependabot[bot]') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,15 +68,14 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for MDX syntax characters not wrapped in backticks.
|
||||
// Astro docs MDX treats bare `<` as JSX component opening tags and
|
||||
// bare `{` as JS expressions, so both must be escaped in changelog entries.
|
||||
// Check for angle brackets not wrapped in backticks.
|
||||
// Astro docs MDX treats bare < as JSX component opening tags.
|
||||
const stripped = title.replace(/`[^`]*`/g, '');
|
||||
if (/[<>{}]/.test(stripped)) {
|
||||
if (/[<>]/.test(stripped)) {
|
||||
core.setFailed(
|
||||
'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
|
||||
'Please wrap these characters with backticks, e.g.: [component] Add `<feature>` support'
|
||||
'PR title contains `<` or `>` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components.\n' +
|
||||
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
84
.github/workflows/release.yml
vendored
84
.github/workflows/release.yml
vendored
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -178,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -212,6 +212,74 @@ jobs:
|
||||
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
|
||||
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
|
||||
|
||||
deploy-ha-addon-repo:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- init
|
||||
- deploy-manifest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: home-assistant-addon
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
let description = "ESPHome";
|
||||
if (context.eventName == "release") {
|
||||
description = ${{ toJSON(github.event.release.body) }};
|
||||
}
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "home-assistant-addon",
|
||||
workflow_id: "bump-version.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
content: description
|
||||
}
|
||||
})
|
||||
|
||||
deploy-esphome-schema:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [init]
|
||||
environment: ${{ needs.init.outputs.deploy_env }}
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: esphome-schema
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "esphome-schema",
|
||||
workflow_id: "generate-schemas.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
}
|
||||
})
|
||||
|
||||
version-notifier:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -221,7 +289,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -234,7 +302,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "version-notifier",
|
||||
workflow_id: "notify.yml",
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
51
.github/workflows/sync-device-classes.yml
vendored
51
.github/workflows/sync-device-classes.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -41,56 +41,19 @@ jobs:
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e lib/home-assistant
|
||||
pip install -r requirements_test.txt pre-commit
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Apply pre-commit auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
# - pylint surfaces import-error / relative-beyond-top-level
|
||||
# noise here because this workflow installs only a subset of
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
|
||||
- name: Verify pre-commit clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
- name: Run pre-commit hooks
|
||||
run: |
|
||||
python script/run-in-env.py pre-commit run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.15
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -462,7 +462,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
* Documentation is hosted in the separate `esphome/esphome-docs` repository.
|
||||
* The contribution workflow is the same as for the codebase.
|
||||
* When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync.
|
||||
|
||||
@@ -681,7 +681,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
- [ ] Explored non-breaking alternatives
|
||||
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
|
||||
- [ ] Documented migration path in PR description with before/after examples
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Updated all internal usage and esphome-docs
|
||||
- [ ] Tested backward compatibility during deprecation period
|
||||
|
||||
* **Deprecation Pattern (C++):**
|
||||
|
||||
@@ -417,7 +417,6 @@ esphome/components/restart/* @esphome/core
|
||||
esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/ring_buffer/* @kahrendt
|
||||
esphome/components/router/speaker/* @kahrendt
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.6.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
|
||||
|
||||
@@ -13,16 +13,12 @@ RUN git config --system --add safe.directory "*" \
|
||||
&& git config --system advice.detachedHead false
|
||||
|
||||
# Install build tools for Python packages that require compilation
|
||||
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
|
||||
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
|
||||
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
|
||||
# it idf_tools.py rejects the openocd install with exit 127 and aborts
|
||||
# the whole framework setup.
|
||||
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
|
||||
RUN if command -v apk > /dev/null; then \
|
||||
apk add --no-cache build-base libusb; \
|
||||
apk add --no-cache build-base; \
|
||||
else \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ from esphome.const import (
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
@@ -608,7 +607,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = module.process_stacktrace
|
||||
process_stacktrace = getattr(module, "process_stacktrace")
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
@@ -639,7 +638,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
chunk = ser.read(ser.in_waiting or 1)
|
||||
if not chunk:
|
||||
continue
|
||||
time_ = datetime.now().astimezone()
|
||||
time_ = datetime.now()
|
||||
milliseconds = time_.microsecond // 1000
|
||||
time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
|
||||
|
||||
@@ -734,13 +733,6 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
from esphome.components.wifi import check_placeholder_credentials
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -760,7 +752,6 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
toolchain.get_idedata()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -795,7 +786,7 @@ def _check_and_emit_build_info() -> None:
|
||||
|
||||
# Read build_info from JSON
|
||||
try:
|
||||
with build_info_json_path.open(encoding="utf-8") as f:
|
||||
with open(build_info_json_path, encoding="utf-8") as f:
|
||||
build_info = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_LOGGER.debug("Failed to read build_info: %s", e)
|
||||
@@ -1057,7 +1048,7 @@ def _wait_for_serial_port(
|
||||
def _port_found() -> bool:
|
||||
if port is not None:
|
||||
if os.name == "posix":
|
||||
return Path(port).exists()
|
||||
return os.path.exists(port)
|
||||
return any(p.path == port for p in get_serial_ports())
|
||||
ports = get_serial_ports()
|
||||
if known_ports is not None:
|
||||
@@ -1102,7 +1093,7 @@ def upload_program(
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.upload_program(config, args, host):
|
||||
if getattr(module, "upload_program")(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -1351,23 +1342,10 @@ def _validate_bootloader_binary(binary: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _should_subscribe_states(args: ArgsProtocol) -> bool:
|
||||
"""Determine whether entity state changes should be shown in log output.
|
||||
|
||||
The ``--states``/``--no-states`` command line flags take precedence. When
|
||||
neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls
|
||||
the behavior, defaulting to showing states.
|
||||
"""
|
||||
states = getattr(args, "states", None)
|
||||
if states is not None:
|
||||
return states
|
||||
return get_bool_env("ESPHOME_LOG_STATES", True)
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.show_logs(config, args, devices):
|
||||
if getattr(module, "show_logs")(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -1393,7 +1371,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
subscribe_states=not getattr(args, "no_states", False),
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
@@ -1426,47 +1404,17 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if not CORE.verbose:
|
||||
config = strip_default_ids(config)
|
||||
output = yaml_util.dump(config, args.show_secrets)
|
||||
# add the console decoration so the front-end can hide the secrets
|
||||
if not args.show_secrets:
|
||||
output = _redact_with_legacy_fallback(output)
|
||||
output = re.sub(
|
||||
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
|
||||
)
|
||||
if not CORE.quiet:
|
||||
safe_print(output)
|
||||
_LOGGER.info("Configuration is valid!")
|
||||
return 0
|
||||
|
||||
|
||||
# Legacy substring redaction fallback for unmigrated schemas; removed in
|
||||
# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
|
||||
# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
|
||||
# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
|
||||
# block; first line is structural). The fragment must either start the
|
||||
# field name or follow ``_`` so the warning names a real field; this avoids
|
||||
# false positives like ``monkey:`` matching the ``key`` fragment.
|
||||
_LEGACY_REDACTION_RE = re.compile(
|
||||
r"(?P<key>\b(?:\w+_)?(?:password|key|psk|ssid))\: "
|
||||
r"(?!\\033\[8m|!secret\b|!lambda\b)(?P<val>.+)"
|
||||
)
|
||||
_LEGACY_REDACTION_REMOVAL = "2026.12.0"
|
||||
|
||||
|
||||
def _redact_with_legacy_fallback(output: str) -> str:
|
||||
unmarked: set[str] = set()
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
unmarked.add(m.group("key"))
|
||||
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
|
||||
|
||||
output = _LEGACY_REDACTION_RE.sub(_replace, output)
|
||||
for key in sorted(unmarked):
|
||||
_LOGGER.warning(
|
||||
"Field '%s' is being redacted by a legacy substring heuristic. "
|
||||
"Mark this field's schema validator with cv.sensitive(...) for "
|
||||
"deterministic redaction; the heuristic will be removed in %s.",
|
||||
key,
|
||||
_LEGACY_REDACTION_REMOVAL,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# generating code might modify config, so it must be done in order to generate
|
||||
# a hash that will match what was generated when compiling and then running
|
||||
@@ -1844,7 +1792,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
ram_report = ram_analyzer.generate_report()
|
||||
print()
|
||||
print(ram_report)
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.warning("RAM strings analysis failed: %s", e)
|
||||
|
||||
return 0
|
||||
@@ -2032,29 +1980,6 @@ SIMPLE_CONFIG_ACTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
|
||||
|
||||
When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable
|
||||
controls whether entity state changes are shown (defaulting to showing them).
|
||||
"""
|
||||
states_group = parser.add_mutually_exclusive_group()
|
||||
states_group.add_argument(
|
||||
"--states",
|
||||
dest="states",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).",
|
||||
)
|
||||
states_group.add_argument(
|
||||
"--no-states",
|
||||
dest="states",
|
||||
action="store_false",
|
||||
default=None,
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
options_parser = argparse.ArgumentParser(add_help=False)
|
||||
options_parser.add_argument(
|
||||
@@ -2231,7 +2156,11 @@ def parse_args(argv):
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
_add_states_args(parser_logs)
|
||||
parser_logs.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
|
||||
parser_discover = subparsers.add_parser(
|
||||
"discover",
|
||||
@@ -2263,7 +2192,11 @@ def parse_args(argv):
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
|
||||
_add_states_args(parser_run)
|
||||
parser_run.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
|
||||
parser_run.add_argument(
|
||||
"--reset",
|
||||
@@ -2501,41 +2434,10 @@ def run_esphome(argv):
|
||||
# Commands that don't need fresh external components: logs just connects
|
||||
# to the device, and clean is about to delete the build directory.
|
||||
skip_external = args.command in ("logs", "clean")
|
||||
command_line_substitutions = dict(args.substitution) if args.substitution else {}
|
||||
|
||||
# Fast path for upload/logs: reuse the validated-config cache the
|
||||
# last compile wrote. Falls back to read_config when missing/stale.
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
config = read_config(
|
||||
dict(args.substitution) if args.substitution else {},
|
||||
skip_external_update=skip_external,
|
||||
)
|
||||
if cache_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
if config is not None:
|
||||
_LOGGER.info(
|
||||
"Loaded validated config cache for %s, skipping validation.",
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
import heapq
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -510,7 +509,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):"
|
||||
)
|
||||
for i, (_symbol, demangled, size) in enumerate(large_core_symbols):
|
||||
for i, (symbol, demangled, size) in enumerate(large_core_symbols):
|
||||
# Core symbols only track (symbol, demangled, size) without section info,
|
||||
# so we don't show section labels here
|
||||
lines.append(
|
||||
@@ -602,7 +601,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
|
||||
)
|
||||
for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
|
||||
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
|
||||
lines.append(
|
||||
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
|
||||
)
|
||||
@@ -641,7 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
|
||||
)
|
||||
for _symbol, demangled, size, section in large_ram_syms[:10]:
|
||||
for symbol, demangled, size, section in large_ram_syms[:10]:
|
||||
# Format section label consistently by stripping leading dot
|
||||
section_label = section.lstrip(".") if section else ""
|
||||
display_name = _format_pstorage_name(demangled)
|
||||
@@ -700,7 +699,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
content = "\n".join(lines)
|
||||
|
||||
if output_file:
|
||||
with Path(output_file).open("w", encoding="utf-8") as f:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
print(content)
|
||||
@@ -738,6 +737,7 @@ def main():
|
||||
|
||||
# Load build directory
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
@@ -785,7 +785,7 @@ def main():
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
with open(idedata_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
|
||||
@@ -154,7 +154,7 @@ def batch_demangle(
|
||||
failed_count = 0
|
||||
|
||||
for original, stripped, prefix, demangled in zip(
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines
|
||||
):
|
||||
# Add back any prefix that was removed
|
||||
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -36,7 +37,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
Full path to the tool or None if not found
|
||||
"""
|
||||
# Get PlatformIO packages directory
|
||||
platformio_home = Path("~/.platformio/packages").expanduser()
|
||||
platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
|
||||
if not platformio_home.exists():
|
||||
return None
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
self.exception = exc
|
||||
|
||||
@@ -3,28 +3,24 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
import esphome.config_validation as cv
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.writer import update_storage_json
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""Get list of built-in ESP-IDF components from project_description.json.
|
||||
"""Get list of available ESP-IDF components from project_description.json.
|
||||
|
||||
Excludes ``src``, IDF-managed components (``managed_components/``), and
|
||||
converted PIO libs (``pio_components/``). Returns ``None`` if the build
|
||||
dir or ``project_description.json`` isn't ready yet.
|
||||
Returns only internal ESP-IDF components, excluding external/managed
|
||||
components (from idf_component.yml).
|
||||
"""
|
||||
if CORE.build_path is None:
|
||||
return None
|
||||
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
|
||||
if not project_desc.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with project_desc.open(encoding="utf-8") as f:
|
||||
with open(project_desc, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
component_info = data.get("build_component_info", {})
|
||||
@@ -35,9 +31,9 @@ def get_available_components() -> list[str] | None:
|
||||
if name == "src":
|
||||
continue
|
||||
|
||||
# Exclude IDF-managed and converted-PIO components (external).
|
||||
# Exclude managed/external components
|
||||
comp_dir = info.get("dir", "")
|
||||
if "managed_components" in comp_dir or "pio_components" in comp_dir:
|
||||
if "managed_components" in comp_dir:
|
||||
continue
|
||||
|
||||
result.append(name)
|
||||
@@ -52,68 +48,17 @@ def has_discovered_components() -> bool:
|
||||
return get_available_components() is not None
|
||||
|
||||
|
||||
def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
||||
|
||||
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
||||
since ``project_description.json`` may be stale on the first write.
|
||||
"""
|
||||
def get_project_cmakelists() -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project."""
|
||||
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
|
||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||
# --format=raw because the legacy mode doesn't support it.
|
||||
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
|
||||
|
||||
# Project-wide compile options: -D defines and -W warning flags (skip
|
||||
# -Wl, linker flags — those go on the src component via
|
||||
# target_link_options below). Emitted via idf_build_set_property so the
|
||||
# flags propagate to every IDF component (including managed ones like
|
||||
# esphome__micro-mp3) rather than just src/. Required so suppressions
|
||||
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
|
||||
# third-party components we don't author.
|
||||
project_compile_opts = [
|
||||
flag
|
||||
for flag in sorted(CORE.build_flags)
|
||||
if flag.startswith("-D")
|
||||
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
|
||||
]
|
||||
# Extract compile definitions from build flags (-DXXX -> XXX)
|
||||
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
|
||||
extra_compile_options = "\n".join(
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
||||
# project-specific names into their cached CMakeLists.
|
||||
#
|
||||
# Emit via idf_build_set_property (not plain set()) so the value is
|
||||
# serialised into build_properties.temp.cmake and visible to IDF's
|
||||
# early requirements-expansion pass (component_get_requirements.cmake
|
||||
# runs as a separate CMake script invocation that doesn't load the
|
||||
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
||||
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
||||
from esphome.components.esp32 import get_managed_component_require_names
|
||||
|
||||
managed_components_property = "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
||||
for name in get_managed_component_require_names()
|
||||
)
|
||||
|
||||
# Built-in IDF components exposed via our own property (not IDF's
|
||||
# __COMPONENT_REQUIRES_COMMON, which would append them to every
|
||||
# component's REQUIRES including real IDF components). Referenced by
|
||||
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
||||
# on minimal writes because project_description.json may be stale.
|
||||
builtin_components_property = (
|
||||
""
|
||||
if minimal
|
||||
else "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
||||
for name in sorted(get_available_components() or [])
|
||||
)
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
|
||||
for compile_def in compile_defs
|
||||
)
|
||||
|
||||
return f"""\
|
||||
@@ -143,16 +88,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
{managed_components_property}
|
||||
|
||||
{builtin_components_property}
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
@@ -161,49 +102,46 @@ add_custom_command(
|
||||
"""
|
||||
|
||||
|
||||
def get_component_cmakelists() -> str:
|
||||
"""Generate the main component CMakeLists.txt.
|
||||
def get_component_cmakelists(minimal: bool = False) -> str:
|
||||
"""Generate the main component CMakeLists.txt."""
|
||||
idf_requires = [] if minimal else (get_available_components() or [])
|
||||
requires_str = " ".join(idf_requires)
|
||||
|
||||
REQUIRES pulls in the discovered built-in IDF components via the
|
||||
project-level variables set in the top-level CMakeLists.
|
||||
"""
|
||||
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
|
||||
# emitted project-wide via idf_build_set_property in
|
||||
# get_project_cmakelists so they reach every component, not just src/.
|
||||
# Extract compile options (-W flags, excluding linker flags)
|
||||
compile_opts = [
|
||||
flag
|
||||
for flag in CORE.build_flags
|
||||
if flag.startswith("-W") and not flag.startswith("-Wl,")
|
||||
]
|
||||
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
|
||||
|
||||
# Extract linker options (-Wl, flags)
|
||||
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
|
||||
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
|
||||
|
||||
return f"""\
|
||||
# Auto-generated by ESPHome
|
||||
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
|
||||
# runs that reuse the build dir don't compile stale source paths. It's
|
||||
# invalid in script mode (cmake -P), which is how IDF's
|
||||
# component_get_requirements.cmake includes us, so skip it there.
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${{app_sources}}
|
||||
INCLUDE_DIRS "." "esphome"
|
||||
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
REQUIRES {requires_str}
|
||||
)
|
||||
|
||||
# Apply C++ standard
|
||||
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
|
||||
|
||||
# ESPHome compile options
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{compile_opts_str}
|
||||
)
|
||||
|
||||
# ESPHome linker options
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
@@ -224,11 +162,11 @@ def write_project(minimal: bool = False) -> None:
|
||||
# Write top-level CMakeLists.txt
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("CMakeLists.txt"),
|
||||
get_project_cmakelists(minimal=minimal),
|
||||
get_project_cmakelists(),
|
||||
)
|
||||
|
||||
# Write component CMakeLists.txt in src/
|
||||
write_file_if_changed(
|
||||
CORE.relative_src_path("CMakeLists.txt"),
|
||||
get_component_cmakelists(),
|
||||
get_component_cmakelists(minimal=minimal),
|
||||
)
|
||||
|
||||
@@ -260,20 +260,42 @@ class ConfigBundleCreator:
|
||||
def _discover_yaml_includes(self) -> None:
|
||||
"""Discover YAML files loaded during config parsing.
|
||||
|
||||
Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
|
||||
fresh re-parse and force-loads every deferred ``IncludeFile`` so that
|
||||
*all* potentially-reachable includes are captured (even branches not
|
||||
selected by local substitutions). Bundles are meant to be compiled on
|
||||
another system where command-line substitution overrides may choose a
|
||||
different branch — e.g. ``!include network/${eth_model}/config.yaml``
|
||||
must ship every candidate so the remote build can pick any one.
|
||||
Deliberately uses a fresh re-parse and force-loads every deferred
|
||||
``IncludeFile`` to include *all* potentially-reachable includes,
|
||||
even branches not selected by the local substitutions. Bundles are
|
||||
meant to be compiled on another system where command-line
|
||||
substitution overrides may choose a different branch — e.g.
|
||||
``!include network/${eth_model}/config.yaml`` must ship every
|
||||
candidate so the remote build can pick any one.
|
||||
|
||||
Entries with unresolved substitution variables in the filename
|
||||
path are skipped with a warning (they cannot be resolved without
|
||||
the substitution pass).
|
||||
|
||||
Secrets files are tracked separately so we can filter them to
|
||||
only include the keys this config actually references.
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
self._secrets_paths.update(discovered.secrets)
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
# Must be a fresh parse: IncludeFile.load() caches its result in
|
||||
# _content, and we discover files by listening for loader calls. On
|
||||
# an already-parsed tree the cache is populated, .load() returns
|
||||
# without calling the loader, the listener never fires, and the
|
||||
# referenced files would be silently dropped from the bundle.
|
||||
with yaml_util.track_yaml_loads() as loaded_files:
|
||||
try:
|
||||
data = yaml_util.load_yaml(self._config_path)
|
||||
except EsphomeError:
|
||||
_LOGGER.debug(
|
||||
"Bundle: re-loading YAML for include discovery failed, "
|
||||
"proceeding with partial file list"
|
||||
)
|
||||
else:
|
||||
_force_load_include_files(data)
|
||||
|
||||
for fpath in loaded_files:
|
||||
if fpath == self._config_path.resolve():
|
||||
continue # Already added as config
|
||||
if fpath.name in const.SECRETS_FILES:
|
||||
self._secrets_paths.add(fpath)
|
||||
self._add_file(fpath)
|
||||
|
||||
def _discover_component_files(self) -> None:
|
||||
@@ -412,7 +434,7 @@ class ConfigBundleCreator:
|
||||
@staticmethod
|
||||
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
|
||||
"""Add a BundleFile to the tar archive with deterministic metadata."""
|
||||
with bf.source.open("rb") as f:
|
||||
with open(bf.source, "rb") as f:
|
||||
_add_bytes_to_tar(tar, bf.path, f.read())
|
||||
|
||||
|
||||
@@ -603,6 +625,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
|
||||
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
|
||||
|
||||
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
|
||||
resolved during the substitution pass. During bundle discovery we need
|
||||
the referenced files to actually load so the ``track_yaml_loads``
|
||||
listener fires for them.
|
||||
|
||||
``IncludeFile`` instances with unresolved substitution variables in the
|
||||
filename cannot be loaded — we skip and warn about those.
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
|
||||
if isinstance(obj, yaml_util.IncludeFile):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
if obj.has_unresolved_expressions():
|
||||
_LOGGER.warning(
|
||||
"Bundle: cannot resolve !include %s (referenced from %s) "
|
||||
"with substitutions in path",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
)
|
||||
return
|
||||
try:
|
||||
loaded = obj.load()
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Bundle: failed to load !include %s (referenced from %s): %s",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
err,
|
||||
)
|
||||
return
|
||||
_force_load_include_files(loaded, _seen)
|
||||
elif isinstance(obj, dict):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for value in obj.values():
|
||||
_force_load_include_files(value, _seen)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for item in obj:
|
||||
_force_load_include_files(item, _seen)
|
||||
|
||||
|
||||
def _resolve_include_path(include_path: Any) -> Path | None:
|
||||
"""Resolve an include path to absolute, skipping system includes."""
|
||||
if isinstance(include_path, str) and include_path.startswith("<"):
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
"""True iff the cache file exists and isn't older than the source."""
|
||||
try:
|
||||
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
@@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
|
||||
|
||||
ENCRYPTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||
cv.Optional(CONF_KEY): validate_encryption_key,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API
|
||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
@@ -1169,7 +1168,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
|
||||
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
|
||||
if (homeassistant::global_homeassistant_time != nullptr) {
|
||||
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
||||
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
if (!value.timezone.empty()) {
|
||||
// Check if the sender provided pre-parsed timezone data.
|
||||
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
|
||||
@@ -1306,9 +1305,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
|
||||
VoiceAssistantConfigurationResponse resp;
|
||||
if (!this->check_voice_assistant_api_connection_()) {
|
||||
// send_message encodes synchronously, so this stack local outlives the encode
|
||||
const std::vector<std::string> empty_wake_words;
|
||||
resp.active_wake_words = &empty_wake_words;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
@@ -37,9 +36,6 @@ class ComponentIterator;
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -415,10 +411,44 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn, uint32_t remaining_size);
|
||||
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
|
||||
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
|
||||
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
|
||||
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
|
||||
@@ -762,8 +792,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
uint8_t batch_header_size_{0};
|
||||
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
uint32_t get_batch_delay_ms_() const;
|
||||
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
// Message will use 8 more bytes than the minimum size, and typical
|
||||
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
|
||||
// If its IPv6 the header is 40 bytes, and if its IPv4
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
|
||||
// Inline APIConnection methods that need APIServer complete. Include this
|
||||
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
|
||||
MessageEncodeFn encode_fn, const void *msg,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "api_server.h"
|
||||
#ifdef USE_API
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include "api_connection.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -31,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
|
||||
|
||||
APIServer::APIServer() { global_api_server = this; }
|
||||
|
||||
// Custom deleter defined here so `delete` sees the complete APIConnection type.
|
||||
// This prevents libc++ from emitting an "incomplete type" error when other
|
||||
// translation units only have the forward declaration of APIConnection.
|
||||
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
|
||||
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
@@ -678,7 +682,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
|
||||
// Schedule automatic cleanup after timeout (client will have given up by then)
|
||||
// Uses numeric ID overload to avoid heap allocation from str_sprintf
|
||||
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
|
||||
ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
|
||||
ESP_LOGD(TAG, "Action call %u timed out", action_call_id);
|
||||
this->unregister_active_action_call(action_call_id);
|
||||
});
|
||||
|
||||
@@ -722,7 +726,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message,
|
||||
@@ -734,7 +738,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_buffer.h"
|
||||
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
|
||||
#include "api_connection.h"
|
||||
#include "api_noise_context.h"
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
@@ -14,6 +12,8 @@
|
||||
#include "esphome/core/controller.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#endif
|
||||
@@ -191,9 +191,15 @@ class APIServer final : public Component,
|
||||
bool is_connected_with_state_subscription() const;
|
||||
|
||||
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
|
||||
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// APIConnection but cannot reset/move the slot and break the count invariant.
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection>;
|
||||
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
|
||||
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
|
||||
// only the forward declaration of APIConnection is visible (incomplete type).
|
||||
struct APIConnectionDeleter {
|
||||
void operator()(APIConnection *p) const;
|
||||
};
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
|
||||
class ActiveClientsView {
|
||||
const APIConnectionPtr *begin_;
|
||||
const APIConnectionPtr *end_;
|
||||
|
||||
@@ -101,14 +101,13 @@ async def async_run_logs(
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Try platform-specific stacktrace handler first, fall back to generic
|
||||
platform_process_stacktrace = None
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_process_stacktrace = module.process_stacktrace
|
||||
platform_process_stacktrace = getattr(module, "process_stacktrace")
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
@@ -119,7 +118,7 @@ async def async_run_logs(
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
time_ = datetime.now()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
|
||||
@@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
|
||||
if isinstance(value, str) and value.endswith("%"):
|
||||
value = percent_to_position(value)
|
||||
|
||||
if isinstance(value, str) and value.endswith(("°", "deg")):
|
||||
if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
|
||||
return angle_to_position(
|
||||
value,
|
||||
min=round(min * POSITION_TO_ANGLE),
|
||||
|
||||
@@ -335,7 +335,7 @@ async def to_code(config):
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.1.0",
|
||||
ref="3.0.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
@@ -395,7 +395,7 @@ async def to_code(config):
|
||||
)
|
||||
if data.mp3_support:
|
||||
cg.add_define("USE_AUDIO_MP3_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
|
||||
_emit_memory_pair(
|
||||
data.mp3.buffer_memory,
|
||||
"CONFIG_MP3_DECODER_PREFER_PSRAM",
|
||||
|
||||
@@ -9,12 +9,9 @@ namespace esphome::audio {
|
||||
|
||||
static const char *const TAG = "audio.decoder";
|
||||
|
||||
static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
|
||||
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
|
||||
|
||||
// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block,
|
||||
// before yielding and returning.
|
||||
static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32;
|
||||
|
||||
static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10;
|
||||
|
||||
AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
@@ -23,13 +20,11 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
}
|
||||
|
||||
esp_err_t AudioDecoder::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
|
||||
// Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte
|
||||
// aligned, so no frame alignment is required.
|
||||
auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_);
|
||||
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
|
||||
if (source == nullptr) {
|
||||
// create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
source->set_source(input_ring_buffer);
|
||||
this->input_buffer_ = std::move(source);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -146,7 +141,13 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
}
|
||||
|
||||
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
|
||||
uint8_t no_output_iterations = 0;
|
||||
|
||||
uint32_t decoding_start = millis();
|
||||
|
||||
bool first_loop_iteration = true;
|
||||
|
||||
size_t bytes_processed = 0;
|
||||
size_t bytes_available_before_processing = 0;
|
||||
|
||||
while (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
// Transfer decoded out
|
||||
@@ -160,39 +161,45 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
this->playback_ms_ +=
|
||||
this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_);
|
||||
}
|
||||
|
||||
if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) {
|
||||
// All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before
|
||||
// decoding the next batch
|
||||
return AudioDecoderState::DECODING;
|
||||
}
|
||||
} else {
|
||||
// If paused, block to avoid wasting CPU resources
|
||||
delay(READ_WRITE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (this->output_transfer_buffer_->available() > 0) {
|
||||
// Output transfer buffer indicates backpressure, return so caller can handle other events;
|
||||
// e.g., stop/pause, before trying again
|
||||
// Verify there is enough space to store more decoded audio and that the function hasn't been running too long
|
||||
if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) ||
|
||||
(millis() - decoding_start > DECODING_TIMEOUT_MS)) {
|
||||
return AudioDecoderState::DECODING;
|
||||
}
|
||||
|
||||
// Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output
|
||||
// stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this
|
||||
// loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and
|
||||
// the return keeps stop/pause responsive.
|
||||
if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) {
|
||||
delay(1);
|
||||
return AudioDecoderState::DECODING;
|
||||
// Decode more audio
|
||||
|
||||
// Never shift the input buffer; every decoder buffers internally and consumes only what it processed.
|
||||
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
|
||||
if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
|
||||
// Less data is available than what was processed in last iteration, so don't attempt to decode.
|
||||
// This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer
|
||||
// will shift the remaining data to the start and copy more from the source the next time the decode function is
|
||||
// called
|
||||
break;
|
||||
}
|
||||
|
||||
// Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
|
||||
// processed, so the source does not need to accumulate or stitch chunks across fill() calls.
|
||||
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
bytes_available_before_processing = this->input_buffer_->available();
|
||||
|
||||
const size_t available_before_decode = this->input_buffer_->available();
|
||||
if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
|
||||
// Failed to decode in last attempt and there is no new data
|
||||
|
||||
if (available_before_decode == 0) {
|
||||
if ((this->input_buffer_->free() == 0) && first_loop_iteration) {
|
||||
// The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact
|
||||
// same data, we can never recover. For const sources this is correct: the entire file is already available, so
|
||||
// a decode failure is genuine, not a transient out-of-data condition.
|
||||
state = FileDecoderState::FAILED;
|
||||
} else {
|
||||
// Attempt to get more data next time
|
||||
state = FileDecoderState::IDLE;
|
||||
}
|
||||
} else if (this->input_buffer_->available() == 0) {
|
||||
// No data to decode, attempt to get more data next time
|
||||
state = FileDecoderState::IDLE;
|
||||
} else {
|
||||
@@ -224,6 +231,9 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
}
|
||||
}
|
||||
|
||||
first_loop_iteration = false;
|
||||
bytes_processed = bytes_available_before_processing - this->input_buffer_->available();
|
||||
|
||||
if (state == FileDecoderState::POTENTIALLY_FAILED) {
|
||||
++this->potentially_failed_count_;
|
||||
} else if (state == FileDecoderState::END_OF_FILE) {
|
||||
@@ -231,16 +241,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
} else if (state == FileDecoderState::FAILED) {
|
||||
return AudioDecoderState::FAILED;
|
||||
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
// Reset the failsafe only when the iteration made forward progress: input was consumed or output was
|
||||
// produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A
|
||||
// MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it
|
||||
// toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever.
|
||||
if ((this->input_buffer_->available() < available_before_decode) ||
|
||||
(this->output_transfer_buffer_->available() > 0)) {
|
||||
this->potentially_failed_count_ = 0;
|
||||
} else {
|
||||
++this->potentially_failed_count_;
|
||||
}
|
||||
this->potentially_failed_count_ = 0;
|
||||
}
|
||||
}
|
||||
return AudioDecoderState::DECODING;
|
||||
|
||||
@@ -61,16 +61,15 @@ class AudioDecoder {
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
|
||||
/// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes.
|
||||
/// @param input_buffer_size Size of the input transfer buffer in bytes.
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
~AudioDecoder() = default;
|
||||
|
||||
/// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr.
|
||||
/// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource.
|
||||
/// @param input_ring_buffer weak_ptr of the source ring buffer to read from
|
||||
/// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero
|
||||
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
|
||||
|
||||
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
|
||||
@@ -12,17 +12,16 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
|
||||
|
||||
AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size)
|
||||
: input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) {
|
||||
this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size);
|
||||
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
|
||||
}
|
||||
|
||||
esp_err_t AudioResampler::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
|
||||
// The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring
|
||||
// buffer (stored here) and the input stream info (set by start()) are available, in either order.
|
||||
this->source_ring_buffer_ = input_ring_buffer.lock();
|
||||
if (this->source_ring_buffer_ == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
if (this->input_transfer_buffer_ != nullptr) {
|
||||
this->input_transfer_buffer_->set_source(input_ring_buffer);
|
||||
return ESP_OK;
|
||||
}
|
||||
return ESP_OK;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_err_t AudioResampler::add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
|
||||
@@ -48,7 +47,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
this->input_stream_info_ = input_stream_info;
|
||||
this->output_stream_info_ = output_stream_info;
|
||||
|
||||
if (this->output_transfer_buffer_ == nullptr) {
|
||||
if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -57,13 +56,6 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks
|
||||
// the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast.
|
||||
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
|
||||
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) ||
|
||||
(input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) {
|
||||
this->resampler_ = make_unique<esp_audio_libs::resampler::Resampler>(
|
||||
@@ -95,27 +87,8 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
}
|
||||
|
||||
AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) {
|
||||
if (this->audio_source_ == nullptr) {
|
||||
// Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are
|
||||
// never split across the ring buffer's wrap boundary.
|
||||
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
|
||||
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
|
||||
// Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would
|
||||
// truncate it and could yield a source that tears frames.
|
||||
return AudioResamplerState::FAILED;
|
||||
}
|
||||
// Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our
|
||||
// reference only after the source has taken ownership.
|
||||
this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_,
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (this->audio_source_ == nullptr) {
|
||||
return AudioResamplerState::FAILED;
|
||||
}
|
||||
this->source_ring_buffer_.reset();
|
||||
}
|
||||
|
||||
if (stop_gracefully) {
|
||||
if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
return AudioResamplerState::FINISHED;
|
||||
}
|
||||
}
|
||||
@@ -129,11 +102,9 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
delay(READ_WRITE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
|
||||
// (there is no intermediate transfer buffer to compact).
|
||||
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
|
||||
|
||||
if (this->audio_source_->available() == 0) {
|
||||
if (this->input_transfer_buffer_->available() == 0) {
|
||||
// No samples available to process
|
||||
return AudioResamplerState::RESAMPLING;
|
||||
}
|
||||
@@ -141,17 +112,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
const size_t bytes_free = this->output_transfer_buffer_->free();
|
||||
const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free);
|
||||
|
||||
const size_t bytes_available = this->audio_source_->available();
|
||||
const size_t bytes_available = this->input_transfer_buffer_->available();
|
||||
const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available);
|
||||
|
||||
if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) ||
|
||||
(this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) {
|
||||
// Adjust gain by -3 dB to avoid clipping due to the resampling process
|
||||
esp_audio_libs::resampler::ResamplerResults results =
|
||||
this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
frames_available, frames_free, -3);
|
||||
this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(),
|
||||
this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
|
||||
|
||||
this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used));
|
||||
this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->output_stream_info_.frames_to_bytes(results.frames_generated));
|
||||
|
||||
@@ -175,10 +146,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free),
|
||||
this->input_stream_info_.frames_to_bytes(frames_available));
|
||||
|
||||
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
|
||||
bytes_to_transfer);
|
||||
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(),
|
||||
(void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
|
||||
|
||||
this->audio_source_->consume(bytes_to_transfer);
|
||||
this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
|
||||
this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace esphome::audio {
|
||||
enum class AudioResamplerState : uint8_t {
|
||||
RESAMPLING, // More data is available to resample
|
||||
FINISHED, // All file data has been resampled and transferred
|
||||
FAILED, // Failed to allocate the audio source
|
||||
FAILED, // Unused state included for consistency among Audio classes
|
||||
};
|
||||
|
||||
class AudioResampler {
|
||||
@@ -32,16 +32,14 @@ class AudioResampler {
|
||||
* component). Also supports converting bits per sample.
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the output transfer buffer. The input source is created later in resample().
|
||||
/// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source.
|
||||
/// @brief Allocates the input and output transfer buffers
|
||||
/// @param input_buffer_size Size of the input transfer buffer in bytes.
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
/// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy
|
||||
/// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first
|
||||
/// resample() call, so add_source() and start() may be called in any order.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive
|
||||
/// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
|
||||
|
||||
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
@@ -80,8 +78,7 @@ class AudioResampler {
|
||||
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
|
||||
|
||||
protected:
|
||||
std::shared_ptr<ring_buffer::RingBuffer> source_ring_buffer_;
|
||||
std::unique_ptr<RingBufferAudioSource> audio_source_;
|
||||
std::unique_ptr<AudioSourceTransferBuffer> input_transfer_buffer_;
|
||||
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
|
||||
|
||||
size_t input_buffer_size_;
|
||||
|
||||
@@ -252,22 +252,6 @@ void RingBufferAudioSource::consume(size_t bytes) {
|
||||
}
|
||||
}
|
||||
|
||||
void RingBufferAudioSource::clear_buffered_data() {
|
||||
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
|
||||
if (this->acquired_item_ != nullptr) {
|
||||
this->ring_buffer_->receive_release(this->acquired_item_);
|
||||
this->acquired_item_ = nullptr;
|
||||
}
|
||||
this->current_data_ = nullptr;
|
||||
this->current_available_ = 0;
|
||||
this->queued_data_ = nullptr;
|
||||
this->queued_length_ = 0;
|
||||
this->item_trailing_ptr_ = nullptr;
|
||||
this->item_trailing_length_ = 0;
|
||||
this->splice_length_ = 0;
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
|
||||
bool RingBufferAudioSource::has_buffered_data() const {
|
||||
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
|
||||
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
|
||||
|
||||
@@ -250,10 +250,6 @@ class RingBufferAudioSource : public AudioReadableBuffer {
|
||||
/// exposure stays in place and fill() returns 0 until it is fully consumed.
|
||||
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
|
||||
|
||||
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
|
||||
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
|
||||
void clear_buffered_data();
|
||||
|
||||
/// @brief Returns a mutable pointer to the currently exposed audio data.
|
||||
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
|
||||
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
|
||||
|
||||
@@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType:
|
||||
|
||||
def _validate_file_shorthand(value: str) -> ConfigType:
|
||||
value = cv.string_strict(value)
|
||||
if value.startswith(("http://", "https://")):
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
return _file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
@@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
|
||||
else:
|
||||
raise cv.Invalid("Unsupported file source")
|
||||
|
||||
with path.open("rb") as f:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, media_source, psram
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
@@ -19,13 +21,19 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioFileMediaSource,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -41,4 +49,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, media_source, psram
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
@@ -18,6 +20,14 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
# Only require the psram component when actually enabling PSRAM stacks; validating
|
||||
# the boolean first means `false` doesn't trigger the requires_component check.
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
@@ -27,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
|
||||
min=5000, max=1000000
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -43,5 +53,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
|
||||
@@ -135,26 +135,12 @@ void BluetoothConnection::loop() {
|
||||
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
|
||||
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
|
||||
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
|
||||
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
|
||||
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
|
||||
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
|
||||
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
|
||||
this->send_service_ == DONE_SENDING_SERVICES)) {
|
||||
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
|
||||
this->send_service_ == DONE_SENDING_SERVICES)) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
|
||||
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
|
||||
// base class. Free the proxy slot, notify the API client, and reset send_service_.
|
||||
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
|
||||
if (this->address_ == 0) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
|
||||
this->reset_connection_(reason);
|
||||
}
|
||||
|
||||
void BluetoothConnection::reset_connection_(esp_err_t reason) {
|
||||
// Send disconnection notification
|
||||
this->proxy_->send_device_connection(this->address_, false, 0, reason);
|
||||
@@ -386,6 +372,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_CLOSE_EVT: {
|
||||
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
|
||||
param->close.reason);
|
||||
// Now the GATT connection is fully closed and controller resources are freed
|
||||
// Safe to mark the connection slot as available
|
||||
this->reset_connection_(param->close.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
|
||||
this->reset_connection_(param->open.status);
|
||||
|
||||
@@ -33,8 +33,6 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
|
||||
protected:
|
||||
friend class BluetoothProxy;
|
||||
|
||||
void on_disconnect_complete(esp_err_t reason) override;
|
||||
|
||||
bool supports_efficient_uuids_() const;
|
||||
void send_service_for_discovery_();
|
||||
void reset_connection_(esp_err_t reason);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "bluetooth_proxy.h"
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
@@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() {
|
||||
" IAQ Mode: %s\n"
|
||||
" Supply Voltage: %sV\n"
|
||||
" Sample Rate: %s\n"
|
||||
" State Save Interval: %" PRIu32 "ms",
|
||||
" State Save Interval: %ims",
|
||||
this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile",
|
||||
this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8",
|
||||
BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_);
|
||||
@@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe
|
||||
}
|
||||
|
||||
void BME680BSECComponent::delay_ms(uint32_t period) {
|
||||
ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period);
|
||||
ESP_LOGV(TAG, "Delaying for %ums", period);
|
||||
delay(period);
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ async def to_code_base(config):
|
||||
path = _compute_local_file_path(_compute_url(config))
|
||||
|
||||
try:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
bsec2_iaq_config = f.read()
|
||||
except Exception as e:
|
||||
raise core.EsphomeError(
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
require_libc_picolibc_newlib_compat,
|
||||
)
|
||||
from esphome.components.esp32 import add_idf_component
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE
|
||||
from esphome.types import ConfigType
|
||||
@@ -54,8 +51,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
if config[CONF_TYPE] == ESP32_CAMERA_ENCODER:
|
||||
add_idf_component(name="espressif/esp32-camera", ref="2.1.5")
|
||||
# esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream
|
||||
require_libc_picolibc_newlib_compat()
|
||||
cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER")
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
|
||||
@@ -22,7 +22,6 @@ CONF_PARITY = "parity"
|
||||
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
|
||||
CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_STOP_BITS = "stop_bits"
|
||||
CONF_USE_PSRAM = "use_psram"
|
||||
CONF_VOLUME_INCREMENT = "volume_increment"
|
||||
|
||||
@@ -16,14 +16,9 @@
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can
|
||||
// still be in scope when a sibling component (e.g. wireguard) pulls in
|
||||
// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the
|
||||
// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to
|
||||
// avoid that linker error.
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
|
||||
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
#if __has_include(<mbedtls/esp_config.h>)
|
||||
#include <mbedtls/esp_config.h>
|
||||
#endif
|
||||
@@ -38,7 +33,7 @@ namespace esphome::dsmr {
|
||||
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
|
||||
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
|
||||
#else
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
|
||||
|
||||
@@ -52,8 +52,6 @@ class E131Component : public esphome::Component {
|
||||
if (!this->udp_.parsePacket())
|
||||
return -1;
|
||||
return this->udp_.read(buf, len);
|
||||
#else
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet);
|
||||
|
||||
@@ -46,7 +46,7 @@ from esphome.const import (
|
||||
Toolchain,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, HexInt, Library
|
||||
from esphome.core import CORE, HexInt, Library
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_component
|
||||
@@ -56,7 +56,7 @@ from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
|
||||
from .boards import BOARDS, STANDARD_BOARDS
|
||||
from .const import (
|
||||
from .const import ( # noqa
|
||||
KEY_ARDUINO_LIBRARIES,
|
||||
KEY_BOARD,
|
||||
KEY_COMPONENTS,
|
||||
@@ -78,18 +78,15 @@ from .const import (
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32H21,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
VARIANT_FRIENDLY,
|
||||
VARIANTS,
|
||||
)
|
||||
|
||||
# force import gpio to register pin schema
|
||||
from .gpio import esp32_pin_to_code # noqa: F401
|
||||
from .gpio import esp32_pin_to_code # noqa
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
AUTO_LOAD = ["preferences"]
|
||||
@@ -116,7 +113,6 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
|
||||
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
|
||||
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
|
||||
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
|
||||
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
|
||||
|
||||
LOG_LEVELS_IDF = [
|
||||
"NONE",
|
||||
@@ -406,12 +402,9 @@ CPU_FREQUENCIES = {
|
||||
VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160),
|
||||
VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160),
|
||||
VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96),
|
||||
VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96),
|
||||
VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96),
|
||||
VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400),
|
||||
VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240),
|
||||
VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240),
|
||||
VARIANT_ESP32S31: get_cpu_frequencies(240, 320),
|
||||
}
|
||||
|
||||
# Make sure not missed here if a new variant added.
|
||||
@@ -470,20 +463,21 @@ def set_core_data(config):
|
||||
framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION])
|
||||
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver
|
||||
|
||||
# Store the underlying IDF version for framework-agnostic checks.
|
||||
# Store the underlying IDF version for framework-agnostic checks
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
idf_ver = framework_ver
|
||||
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None:
|
||||
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver
|
||||
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Official ESP-IDF frameworks don't use extra
|
||||
idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch)
|
||||
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver
|
||||
else:
|
||||
raise cv.Invalid(
|
||||
f"Arduino version {framework_ver} has no known ESP-IDF version mapping. "
|
||||
"Please update ARDUINO_IDF_VERSION_LOOKUP.",
|
||||
path=[CONF_FRAMEWORK, CONF_VERSION],
|
||||
)
|
||||
# The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
idf_ver = _strip_pioarduino_revision(idf_ver)
|
||||
|
||||
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver
|
||||
CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD]
|
||||
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE]
|
||||
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
|
||||
@@ -594,18 +588,6 @@ def add_idf_component(
|
||||
}
|
||||
|
||||
|
||||
def get_managed_component_require_names() -> list[str]:
|
||||
"""Return sorted IDF require names for components added via
|
||||
``add_idf_component`` (``owner/name`` -> ``owner__name``).
|
||||
|
||||
The build_gen layer (``build_gen.espidf.get_project_cmakelists``)
|
||||
feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so
|
||||
converted PIO libraries can REQUIRE them by name at configure time.
|
||||
"""
|
||||
components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {})
|
||||
return sorted(name.replace("/", "__") for name in components_registry)
|
||||
|
||||
|
||||
def exclude_builtin_idf_component(name: str) -> None:
|
||||
"""Exclude an ESP-IDF component from the build.
|
||||
|
||||
@@ -720,9 +702,6 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"dev": cv.Version(3, 3, 8),
|
||||
}
|
||||
ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
4, 0, 0, "alpha1"
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
||||
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),
|
||||
@@ -743,7 +722,6 @@ 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(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
|
||||
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),
|
||||
@@ -802,15 +780,19 @@ PLATFORM_VERSION_LOOKUP = {
|
||||
}
|
||||
|
||||
|
||||
def _resolve_framework_version(value: ConfigType) -> cv.Version:
|
||||
"""Resolve a named or raw framework version and validate the minimum.
|
||||
def _check_pio_versions(config):
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
Normalises value[CONF_VERSION] to its string form and returns the parsed
|
||||
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
|
||||
specific concerns (source defaults, platform_version) live in the per-
|
||||
toolchain functions.
|
||||
"""
|
||||
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
|
||||
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
|
||||
raise cv.Invalid(
|
||||
"Version needs to be explicitly set when a custom source or platform_version is used."
|
||||
)
|
||||
|
||||
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
|
||||
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
|
||||
else:
|
||||
@@ -823,48 +805,7 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
if version < cv.Version(3, 0, 0):
|
||||
raise cv.Invalid("Only Arduino 3.0+ is supported.")
|
||||
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
else:
|
||||
if version < cv.Version(5, 0, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
|
||||
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
|
||||
if version != recommended:
|
||||
_LOGGER.warning(
|
||||
"The selected framework version is not the recommended one. "
|
||||
"If there are connectivity or build issues please remove the manual version."
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version:
|
||||
"""Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1").
|
||||
|
||||
Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept.
|
||||
"""
|
||||
if ver.extra.isdigit():
|
||||
return cv.Version(ver.major, ver.minor, ver.patch)
|
||||
return ver
|
||||
|
||||
|
||||
def _check_pio_versions(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
|
||||
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
|
||||
raise cv.Invalid(
|
||||
"Version needs to be explicitly set when a custom source or platform_version is used."
|
||||
)
|
||||
if is_named_version:
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
|
||||
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
|
||||
)
|
||||
|
||||
version = _resolve_framework_version(value)
|
||||
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
|
||||
value[CONF_SOURCE] = value.get(
|
||||
CONF_SOURCE, _format_framework_arduino_version(version)
|
||||
@@ -872,6 +813,9 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
|
||||
if _is_framework_url(value[CONF_SOURCE]):
|
||||
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
|
||||
else:
|
||||
if version < cv.Version(5, 0, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
|
||||
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
|
||||
value[CONF_SOURCE] = value.get(
|
||||
CONF_SOURCE,
|
||||
@@ -887,6 +831,12 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
|
||||
)
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
|
||||
|
||||
if version != recommended_version:
|
||||
_LOGGER.warning(
|
||||
"The selected framework version is not the recommended one. "
|
||||
"If there are connectivity or build issues please remove the manual version."
|
||||
)
|
||||
|
||||
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
|
||||
str(PLATFORM_VERSION_LOOKUP["recommended"])
|
||||
):
|
||||
@@ -898,28 +848,19 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
def _check_esp_idf_versions(config):
|
||||
config = _check_pio_versions(config)
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
# platform_version is a PlatformIO concept; drop it if a user carried it
|
||||
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
|
||||
# it lets a user override the framework tarball URL under the esp-idf
|
||||
# toolchain (the espidf framework downloader consults it).
|
||||
value.pop(CONF_PLATFORM_VERSION, None)
|
||||
# Remove unwanted keys if present
|
||||
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
|
||||
value.pop(key, None)
|
||||
|
||||
version = _resolve_framework_version(value)
|
||||
# Official ESP-IDF frameworks don't use extra
|
||||
version = cv.Version.parse(value[CONF_VERSION])
|
||||
version = cv.Version(version.major, version.minor, version.patch)
|
||||
|
||||
if CONF_SOURCE in value:
|
||||
_LOGGER.warning(
|
||||
"A custom framework source is set. "
|
||||
"If there are connectivity or build issues please remove the manual source."
|
||||
)
|
||||
|
||||
# esp-idf framework only: drop pioarduino's packaging revision (config + download).
|
||||
# Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key).
|
||||
if value[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
value[CONF_VERSION] = str(_strip_pioarduino_revision(version))
|
||||
value[CONF_VERSION] = str(version)
|
||||
|
||||
return config
|
||||
|
||||
@@ -928,16 +869,11 @@ def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
|
||||
|
||||
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
def _check_versions(config):
|
||||
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
|
||||
return value
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
|
||||
|
||||
|
||||
def _check_versions(config: ConfigType) -> ConfigType:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
return _check_esp_idf_versions(config)
|
||||
return _check_pio_versions(config)
|
||||
@@ -959,21 +895,7 @@ def _detect_variant(value):
|
||||
variant = value.get(CONF_VARIANT)
|
||||
if variant and board is None:
|
||||
# If variant is set, we can derive the board from it
|
||||
# variant has already been validated against the known set.
|
||||
# PlatformIO needs a real board name to find its board file; the
|
||||
# ESP-IDF toolchain only uses CONF_BOARD as the informational
|
||||
# ESPHOME_BOARD string, so synthesize one from the friendly variant
|
||||
# name rather than carrying a PIO board name through the IDF build.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
value = value.copy()
|
||||
value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower()
|
||||
return value
|
||||
if variant not in STANDARD_BOARDS:
|
||||
raise cv.Invalid(
|
||||
f"No default board is known for {variant}. "
|
||||
f"Please specify the `board:` option explicitly.",
|
||||
path=[CONF_VARIANT],
|
||||
)
|
||||
# variant has already been validated against the known set
|
||||
value = value.copy()
|
||||
value[CONF_BOARD] = STANDARD_BOARDS[variant]
|
||||
if variant == VARIANT_ESP32P4:
|
||||
@@ -1260,7 +1182,6 @@ 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"
|
||||
KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required"
|
||||
|
||||
|
||||
def require_vfs_select() -> None:
|
||||
@@ -1369,15 +1290,6 @@ def require_adc_oneshot_iram() -> None:
|
||||
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
|
||||
|
||||
|
||||
def require_libc_picolibc_newlib_compat() -> None:
|
||||
"""Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+.
|
||||
|
||||
Call this from components that link against precompiled Newlib binaries
|
||||
referencing types/symbols the shim provides (e.g. esp32-camera).
|
||||
"""
|
||||
CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_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 *)
|
||||
@@ -1656,7 +1568,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
}
|
||||
),
|
||||
_resolve_toolchain,
|
||||
_detect_variant,
|
||||
_set_default_framework,
|
||||
_check_versions,
|
||||
@@ -1783,26 +1694,6 @@ async def _write_arduino_libraries_sdkconfig() -> None:
|
||||
add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _set_libc_picolibc_newlib_compat() -> None:
|
||||
"""Apply the PicolibC Newlib compatibility shim option on IDF 6.0+.
|
||||
|
||||
IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default.
|
||||
Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call
|
||||
(default priority) is seen before the option is written. A user-supplied
|
||||
sdkconfig_options value takes precedence.
|
||||
"""
|
||||
if idf_version() < cv.Version(6, 0, 0):
|
||||
return
|
||||
option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY"
|
||||
if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]:
|
||||
return
|
||||
add_idf_sdkconfig_option(
|
||||
option,
|
||||
CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False),
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_yaml_idf_components(components: list[ConfigType]):
|
||||
"""Add IDF components from YAML config with final priority to override code-added components."""
|
||||
@@ -1815,31 +1706,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL - 1)
|
||||
async def _finalize_arduino_aware_flags():
|
||||
"""Build flags that depend on whether arduino-esp32 is linked in.
|
||||
|
||||
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
|
||||
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
|
||||
by then ``KEY_COMPONENTS`` is fully populated.
|
||||
|
||||
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
|
||||
wraps the same symbol and the linker errors on the duplicate.
|
||||
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
|
||||
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
|
||||
framework=arduino branch already adds it inline in to_code.
|
||||
"""
|
||||
arduino_linked = (
|
||||
CORE.using_arduino
|
||||
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
)
|
||||
if not arduino_linked:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
elif not CORE.using_arduino:
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
@@ -1887,21 +1753,21 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
# Demote IDF's blanket -Werror to warnings so third-party libs
|
||||
# and user lambdas don't need a -Wno-error=<class> per warning.
|
||||
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
|
||||
# can't be globally undone); -Wno-error then handles the demotion.
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
|
||||
cg.add_build_flag("-Wno-error")
|
||||
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
|
||||
cg.add_build_flag("-Wno-missing-field-initializers")
|
||||
cg.add_build_flag("-Wno-error=format")
|
||||
cg.add_build_flag("-Wno-error=maybe-uninitialized")
|
||||
cg.add_build_flag("-Wno-error=missing-field-initializers")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
|
||||
CORE.add_job(_finalize_arduino_aware_flags)
|
||||
# Arduino already wraps esp_panic_handler for its own backtrace handler,
|
||||
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
variant = config[CONF_VARIANT]
|
||||
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
|
||||
@@ -2082,7 +1948,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
|
||||
|
||||
# Setup watchdog
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
@@ -2124,8 +1990,7 @@ async def to_code(config):
|
||||
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
|
||||
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
|
||||
# Kconfig range is [1,63]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
|
||||
|
||||
_configure_lwip_max_sockets(conf)
|
||||
|
||||
@@ -2217,6 +2082,7 @@ async def to_code(config):
|
||||
for key, flag in ASSERTION_LEVELS.items():
|
||||
add_idf_sdkconfig_option(flag, assertion_level == key)
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
|
||||
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
|
||||
for key, flag in COMPILER_OPTIMIZATIONS.items():
|
||||
add_idf_sdkconfig_option(flag, compiler_optimization == key)
|
||||
@@ -2336,8 +2202,17 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False)
|
||||
|
||||
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
|
||||
CORE.add_job(_set_libc_picolibc_newlib_compat)
|
||||
# Disable PicolibC Newlib compatibility shim on IDF 6.0+
|
||||
# IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local
|
||||
# stdin/stdout/stderr and getreent() for code compiled against Newlib.
|
||||
# ESPHome doesn't link against Newlib-built libraries that use stdio.
|
||||
# If a component needs it (e.g. precompiled Newlib binaries), re-enable via:
|
||||
# esp32:
|
||||
# framework:
|
||||
# sdkconfig_options:
|
||||
# CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y"
|
||||
if idf_version() >= cv.Version(6, 0, 0):
|
||||
add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False)
|
||||
|
||||
# Disable regi2c control functions in IRAM
|
||||
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
|
||||
@@ -2362,8 +2237,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
|
||||
elif advanced[CONF_DISABLE_FATFS]:
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
|
||||
# Kconfig range is [1,10]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
|
||||
|
||||
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
|
||||
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
|
||||
@@ -2578,14 +2452,8 @@ def _write_sdkconfig():
|
||||
)
|
||||
|
||||
want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
# Include the resolved framework version as a Kconfig comment so a
|
||||
# version switch that happens to leave the option set unchanged still
|
||||
# bumps this file's content -- which is what has_outdated_files()
|
||||
# uses to decide whether to reconfigure.
|
||||
framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
contents = (
|
||||
f"# ESPHOME_IDF_VERSION={framework_version}\n"
|
||||
+ "\n".join(
|
||||
"\n".join(
|
||||
f"{name}={_format_sdkconfig_val(value)}"
|
||||
for name, value in sorted(want_opts.items())
|
||||
)
|
||||
@@ -2600,8 +2468,9 @@ def _write_sdkconfig():
|
||||
|
||||
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
|
||||
dependency: dict[str, str] = {}
|
||||
name, _version, path = generate_idf_component(library)
|
||||
name, version, path = generate_idf_component(library)
|
||||
dependency["override_path"] = str(path)
|
||||
dependency["version"] = version
|
||||
return name, dependency
|
||||
|
||||
|
||||
@@ -2628,12 +2497,7 @@ def _write_idf_component_yml():
|
||||
|
||||
stubs_dir = CORE.relative_build_path("component_stubs")
|
||||
stubs_dir.mkdir(exist_ok=True)
|
||||
# Sort so the dict insertion order (and thus the generated
|
||||
# src/idf_component.yml) is deterministic across runs; otherwise
|
||||
# the manifest content shuffles every build, write_file_if_changed
|
||||
# always writes, and ninja keeps triggering CMake re-runs on
|
||||
# otherwise-cached rebuilds.
|
||||
for component_name in sorted(components_to_stub):
|
||||
for component_name in components_to_stub:
|
||||
# Create stub directory with minimal CMakeLists.txt
|
||||
stub_path = stubs_dir / _idf_component_stub_name(component_name)
|
||||
stub_path.mkdir(exist_ok=True)
|
||||
@@ -2645,26 +2509,6 @@ def _write_idf_component_yml():
|
||||
"override_path": str(stub_path),
|
||||
}
|
||||
|
||||
# On the PlatformIO toolchain, framework-arduinoespressif32 already
|
||||
# ships arduino-esp32. Stub the managed component so anything that
|
||||
# `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a
|
||||
# CMake target that re-exports the framework's INTERFACE properties
|
||||
# (INCLUDE_DIRS, public compile options like -DESP32, transitive
|
||||
# REQUIRES) instead of triggering a duplicate download/rebuild.
|
||||
if CORE.using_toolchain_platformio:
|
||||
arduino_stub = stubs_dir / "arduino-esp32"
|
||||
arduino_stub.mkdir(exist_ok=True)
|
||||
write_file_if_changed(
|
||||
arduino_stub / "CMakeLists.txt",
|
||||
"idf_component_register()\n"
|
||||
"target_link_libraries(${COMPONENT_LIB} "
|
||||
f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n",
|
||||
)
|
||||
dependencies[ARDUINO_ESP32_COMPONENT_NAME] = {
|
||||
"version": "*",
|
||||
"override_path": str(arduino_stub),
|
||||
}
|
||||
|
||||
# Remove stubs for components that are now required by enabled libraries
|
||||
for component_name in required_idf_components:
|
||||
stub_path = stubs_dir / _idf_component_stub_name(component_name)
|
||||
@@ -2673,7 +2517,7 @@ def _write_idf_component_yml():
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
add_idf_component(
|
||||
name=ARDUINO_ESP32_COMPONENT_NAME,
|
||||
name="espressif/arduino-esp32",
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
@@ -2740,32 +2584,16 @@ def copy_files():
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
# _decode_pc runs from the api log processor's asyncio callback, which
|
||||
# only catches EsphomeError. Any other exception escaping here tears down
|
||||
# the protocol and triggers an infinite reconnect/replay loop. Convert
|
||||
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
|
||||
# EsphomeError so the caller can disable decoding cleanly.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain as idf_toolchain
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
try:
|
||||
addr2line_path = idf_toolchain.get_addr2line_path()
|
||||
firmware_elf_path = idf_toolchain.get_elf_path()
|
||||
except RuntimeError as err:
|
||||
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
addr2line_path = idedata.addr2line_path
|
||||
firmware_elf_path = idedata.firmware_elf_path
|
||||
if not addr2line_path or not firmware_elf_path:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .const import (
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANTS,
|
||||
)
|
||||
|
||||
STANDARD_BOARDS = {
|
||||
@@ -24,6 +25,9 @@ STANDARD_BOARDS = {
|
||||
VARIANT_ESP32S3: "esp32-s3-devkitc-1",
|
||||
}
|
||||
|
||||
# Make sure not missed here if a new variant added.
|
||||
assert all(v in STANDARD_BOARDS for v in VARIANTS)
|
||||
|
||||
ESP32_BASE_PINS = {
|
||||
"TX": 1,
|
||||
"RX": 3,
|
||||
|
||||
@@ -24,12 +24,9 @@ VARIANT_ESP32C5 = "ESP32C5"
|
||||
VARIANT_ESP32C6 = "ESP32C6"
|
||||
VARIANT_ESP32C61 = "ESP32C61"
|
||||
VARIANT_ESP32H2 = "ESP32H2"
|
||||
VARIANT_ESP32H4 = "ESP32H4"
|
||||
VARIANT_ESP32H21 = "ESP32H21"
|
||||
VARIANT_ESP32P4 = "ESP32P4"
|
||||
VARIANT_ESP32S2 = "ESP32S2"
|
||||
VARIANT_ESP32S3 = "ESP32S3"
|
||||
VARIANT_ESP32S31 = "ESP32S31"
|
||||
VARIANTS = [
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32C2,
|
||||
@@ -38,12 +35,9 @@ VARIANTS = [
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32H21,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
]
|
||||
|
||||
VARIANT_FRIENDLY = {
|
||||
@@ -54,12 +48,9 @@ VARIANT_FRIENDLY = {
|
||||
VARIANT_ESP32C6: "ESP32-C6",
|
||||
VARIANT_ESP32C61: "ESP32-C61",
|
||||
VARIANT_ESP32H2: "ESP32-H2",
|
||||
VARIANT_ESP32H4: "ESP32-H4",
|
||||
VARIANT_ESP32H21: "ESP32-H21",
|
||||
VARIANT_ESP32P4: "ESP32-P4",
|
||||
VARIANT_ESP32S2: "ESP32-S2",
|
||||
VARIANT_ESP32S3: "ESP32-S3",
|
||||
VARIANT_ESP32S31: "ESP32-S31",
|
||||
}
|
||||
|
||||
esp32_ns = cg.esphome_ns.namespace("esp32")
|
||||
|
||||
@@ -31,12 +31,9 @@ from .const import (
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32H21,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
esp32_ns,
|
||||
)
|
||||
from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports
|
||||
@@ -46,12 +43,9 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support
|
||||
from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports
|
||||
from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports
|
||||
from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports
|
||||
from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports
|
||||
from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports
|
||||
from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports
|
||||
from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports
|
||||
from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports
|
||||
from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports
|
||||
|
||||
ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin)
|
||||
|
||||
@@ -126,14 +120,6 @@ _esp32_validations = {
|
||||
pin_validation=esp32_h2_validate_gpio_pin,
|
||||
usage_validation=esp32_h2_validate_supports,
|
||||
),
|
||||
VARIANT_ESP32H4: ESP32ValidationFunctions(
|
||||
pin_validation=esp32_h4_validate_gpio_pin,
|
||||
usage_validation=esp32_h4_validate_supports,
|
||||
),
|
||||
VARIANT_ESP32H21: ESP32ValidationFunctions(
|
||||
pin_validation=esp32_h21_validate_gpio_pin,
|
||||
usage_validation=esp32_h21_validate_supports,
|
||||
),
|
||||
VARIANT_ESP32P4: ESP32ValidationFunctions(
|
||||
pin_validation=esp32_p4_validate_gpio_pin,
|
||||
usage_validation=esp32_p4_validate_supports,
|
||||
@@ -146,10 +132,6 @@ _esp32_validations = {
|
||||
pin_validation=esp32_s3_validate_gpio_pin,
|
||||
usage_validation=esp32_s3_validate_supports,
|
||||
),
|
||||
VARIANT_ESP32S31: ESP32ValidationFunctions(
|
||||
pin_validation=esp32_s31_validate_gpio_pin,
|
||||
usage_validation=esp32_s31_validate_supports,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
|
||||
from esphome.pins import check_strapping_pin
|
||||
|
||||
# Partial set from the ESP-IDF / esptool boot-mode docs:
|
||||
# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html
|
||||
# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section.
|
||||
_ESP32H21_STRAPPING_PINS: set[int] = {13, 14}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def esp32_h21_validate_gpio_pin(value: int) -> int:
|
||||
if value < 0 or value > 25:
|
||||
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)")
|
||||
return value
|
||||
|
||||
|
||||
def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
|
||||
num = value[CONF_NUMBER]
|
||||
mode = value[CONF_MODE]
|
||||
is_input = mode[CONF_INPUT]
|
||||
|
||||
if num < 0 or num > 25:
|
||||
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)")
|
||||
if is_input:
|
||||
# All ESP32 pins support input mode
|
||||
pass
|
||||
|
||||
check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER)
|
||||
return value
|
||||
@@ -1,34 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
|
||||
from esphome.pins import check_strapping_pin
|
||||
|
||||
# Partial set from the ESP-IDF / esptool boot-mode docs:
|
||||
# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html
|
||||
# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section.
|
||||
_ESP32H4_STRAPPING_PINS: set[int] = {13, 14}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def esp32_h4_validate_gpio_pin(value: int) -> int:
|
||||
if value < 0 or value > 39:
|
||||
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)")
|
||||
return value
|
||||
|
||||
|
||||
def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
|
||||
num = value[CONF_NUMBER]
|
||||
mode = value[CONF_MODE]
|
||||
is_input = mode[CONF_INPUT]
|
||||
|
||||
if num < 0 or num > 39:
|
||||
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)")
|
||||
if is_input:
|
||||
# All ESP32 pins support input mode
|
||||
pass
|
||||
|
||||
check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER)
|
||||
return value
|
||||
@@ -1,38 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER
|
||||
from esphome.pins import check_strapping_pin
|
||||
|
||||
# Per the ESP32-S31 datasheet (page 96):
|
||||
# https://documentation.espressif.com/esp32-s31_datasheet_en.pdf
|
||||
_ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33}
|
||||
_ESP32S31_STRAPPING_PINS: set[int] = {60, 61}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def esp32_s31_validate_gpio_pin(value: int) -> int:
|
||||
if value < 0 or value > 61:
|
||||
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)")
|
||||
if value in _ESP32S31_SPI_FLASH_PINS:
|
||||
raise cv.Invalid(
|
||||
f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]:
|
||||
num = value[CONF_NUMBER]
|
||||
mode = value[CONF_MODE]
|
||||
is_input = mode[CONF_INPUT]
|
||||
|
||||
if num < 0 or num > 61:
|
||||
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)")
|
||||
if is_input:
|
||||
# All ESP32 pins support input mode
|
||||
pass
|
||||
|
||||
check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER)
|
||||
return value
|
||||
@@ -72,7 +72,6 @@ void BLEClientBase::loop() {
|
||||
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +418,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
this->log_gattc_lifecycle_event_("CLOSE");
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->on_disconnect_complete(param->close.reason);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_RES_EVT: {
|
||||
|
||||
@@ -140,12 +140,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
|
||||
void log_gattc_warning_(const char *operation, esp_err_t err);
|
||||
void log_connection_params_(const char *param_type);
|
||||
void handle_connection_result_(esp_err_t ret);
|
||||
/// Hook called once a connection has been fully torn down (after release_services() and
|
||||
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
|
||||
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
|
||||
/// override this to release that state. `reason` is the controller reason code, or
|
||||
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
|
||||
virtual void on_disconnect_complete(esp_err_t reason) {}
|
||||
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
|
||||
void set_idle_() {
|
||||
this->set_state(espbt::ClientState::IDLE);
|
||||
@@ -155,10 +149,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
|
||||
void set_disconnecting_() {
|
||||
this->disconnecting_started_ = millis();
|
||||
this->set_state(espbt::ClientState::DISCONNECTING);
|
||||
// BluetoothConnection::loop() disables the component loop after service discovery
|
||||
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
|
||||
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
|
||||
this->enable_loop();
|
||||
}
|
||||
// Compact error logging helpers to reduce flash usage
|
||||
void log_error_(const char *message);
|
||||
|
||||
@@ -196,35 +196,42 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
|
||||
(*this->on_read_callback_)(param->read.conn_id);
|
||||
}
|
||||
|
||||
// Use the client-supplied offset for long reads; short reads always start at 0.
|
||||
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
|
||||
// just provide as much data as we have from the requested offset and let the stack
|
||||
// handle framing. The client issues subsequent blob reads with increasing offsets
|
||||
// until it has received the whole value.
|
||||
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
|
||||
esp_gatt_status_t status = ESP_GATT_OK;
|
||||
esp_gatt_rsp_t response;
|
||||
response.attr_value.offset = offset;
|
||||
uint16_t max_offset = 22;
|
||||
|
||||
if (offset > this->value_.size()) {
|
||||
status = ESP_GATT_INVALID_OFFSET;
|
||||
response.attr_value.len = 0;
|
||||
} else {
|
||||
size_t remaining = this->value_.size() - offset;
|
||||
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
|
||||
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
|
||||
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
|
||||
remaining = ESP_GATT_MAX_ATTR_LEN;
|
||||
esp_gatt_rsp_t response;
|
||||
if (param->read.is_long) {
|
||||
if (this->value_read_offset_ >= this->value_.size()) {
|
||||
response.attr_value.len = 0;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
this->value_read_offset_ = 0;
|
||||
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
|
||||
// Last message in the chain
|
||||
response.attr_value.len = this->value_.size() - this->value_read_offset_;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ = 0;
|
||||
} else {
|
||||
response.attr_value.len = max_offset;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ += max_offset;
|
||||
}
|
||||
response.attr_value.len = remaining;
|
||||
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
|
||||
} else {
|
||||
response.attr_value.offset = 0;
|
||||
if (this->value_.size() + 1 > max_offset) {
|
||||
response.attr_value.len = max_offset;
|
||||
this->value_read_offset_ = max_offset;
|
||||
} else {
|
||||
response.attr_value.len = this->value_.size();
|
||||
}
|
||||
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
|
||||
}
|
||||
|
||||
response.attr_value.handle = this->handle_;
|
||||
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
|
||||
|
||||
esp_err_t err =
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class BLECharacteristic {
|
||||
esp_gatt_char_prop_t properties_;
|
||||
uint16_t handle_{0xFFFF};
|
||||
|
||||
uint16_t value_read_offset_{0};
|
||||
std::vector<uint8_t> value_;
|
||||
std::vector<BLEDescriptor *> descriptors_;
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@ import logging
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
require_libc_picolibc_newlib_compat,
|
||||
)
|
||||
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
|
||||
from esphome.components.psram import DOMAIN as psram_domain
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -406,8 +402,6 @@ async def to_code(config):
|
||||
add_idf_component(name="espressif/esp32-camera", ref="2.1.5")
|
||||
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True)
|
||||
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False)
|
||||
# esp32-camera 2.1.5 needs the Newlib shim on IDF 6.0+; remove when fixed upstream
|
||||
require_libc_picolibc_newlib_compat()
|
||||
|
||||
for conf in config.get(CONF_ON_STREAM_START, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components import esp32
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -40,7 +39,6 @@ BASE_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
|
||||
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
|
||||
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -244,12 +242,6 @@ async def to_code(config):
|
||||
else:
|
||||
_configure_spi(config)
|
||||
|
||||
# Place the transport mempool in PSRAM. Required on memory-tight host
|
||||
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
|
||||
# mempool allocation fails at boot with `sdio_mempool_create` assert.
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
@@ -257,7 +249,7 @@ async def to_code(config):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, update
|
||||
from esphome.components.const import CONF_SHA256
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
@@ -12,6 +11,7 @@ CODEOWNERS = ["@swoboda1337"]
|
||||
AUTO_LOAD = ["sha256", "watchdog", "json"]
|
||||
DEPENDENCIES = ["esp32_hosted"]
|
||||
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_HTTP_REQUEST_ID = "http_request_id"
|
||||
|
||||
TYPE_EMBEDDED = "embedded"
|
||||
@@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
path = CORE.relative_config_path(config[CONF_PATH])
|
||||
with path.open("rb") as f:
|
||||
with open(path, "rb") as f:
|
||||
firmware_data = f.read()
|
||||
calculated = hashlib.sha256(firmware_data).hexdigest()
|
||||
expected = config[CONF_SHA256].lower()
|
||||
@@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None:
|
||||
|
||||
if config[CONF_TYPE] == TYPE_EMBEDDED:
|
||||
path = config[CONF_PATH]
|
||||
with CORE.relative_config_path(path).open("rb") as f:
|
||||
with open(CORE.relative_config_path(path), "rb") as f:
|
||||
firmware_data = f.read()
|
||||
rhs = [HexInt(x) for x in firmware_data]
|
||||
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)
|
||||
|
||||
@@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() {
|
||||
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {
|
||||
// 16 bytes: "255.255.255" (11 chars) + null + safety margin
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1);
|
||||
snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1);
|
||||
this->update_info_.current_version = buf;
|
||||
} else {
|
||||
this->update_info_.current_version = "unknown";
|
||||
@@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() {
|
||||
this->state_ = update::UPDATE_STATE_NO_UPDATE;
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
|
||||
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
|
||||
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word,
|
||||
ESP_APP_DESC_MAGIC_WORD);
|
||||
this->state_ = update::UPDATE_STATE_NO_UPDATE;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -472,7 +472,7 @@ def _decode_pc(config, addr):
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <coredecls.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
@@ -72,22 +71,23 @@ uint32_t IRAM_ATTR HOT millis() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
|
||||
// suspend the cont task for `ms` milliseconds without polling millis(). This
|
||||
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
|
||||
// and lets the SDK run freely while we wait, which timing-sensitive
|
||||
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
|
||||
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
|
||||
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
|
||||
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
|
||||
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
|
||||
// by this path (the --wrap=millis goal of #15662 is preserved).
|
||||
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
|
||||
// call to the original millis() that --wrap can't intercept, so calling ::delay()
|
||||
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
|
||||
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
|
||||
// WiFi run correctly. Theoretically less power-efficient than Arduino's
|
||||
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
|
||||
// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is
|
||||
// negligible.
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
esp_delay(ms);
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
|
||||
@@ -58,12 +58,6 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
|
||||
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
|
||||
return pgm_read_word(addr); // NOLINT
|
||||
}
|
||||
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
|
||||
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
|
||||
// ESP8266 is ~4x as many flash accesses as the bulk path.
|
||||
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
|
||||
memcpy_P(dst, src, len); // NOLINT
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
|
||||
@@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
host=8082,
|
||||
): cv.port,
|
||||
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PASSWORD): cv.sensitive(),
|
||||
cv.Optional(CONF_PASSWORD): cv.string,
|
||||
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
|
||||
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
|
||||
),
|
||||
|
||||
@@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition access allowed\n"
|
||||
" Running app:\n"
|
||||
" Partition address: 0x%" PRIX32 "\n"
|
||||
" Used size: %zu bytes (0x%zX)",
|
||||
" Partition address: 0x%X\n"
|
||||
" Used size: %zu bytes (0x%X)",
|
||||
this->running_app_offset_, this->running_app_size_, this->running_app_size_);
|
||||
|
||||
#ifdef USE_ESP32
|
||||
@@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
|
||||
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
@@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
|
||||
this->auth_buf_[0] = this->auth_type_;
|
||||
hasher.get_hex(buf);
|
||||
|
||||
ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf);
|
||||
ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf);
|
||||
}
|
||||
|
||||
// Try to write auth_type + nonce
|
||||
@@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() {
|
||||
hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer)
|
||||
hasher.calculate();
|
||||
|
||||
ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce);
|
||||
ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator)
|
||||
hasher.get_hex(computed_hash);
|
||||
ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash);
|
||||
ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash);
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response);
|
||||
ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response);
|
||||
|
||||
// Compare response
|
||||
bool matches = hasher.equals_hex(response);
|
||||
|
||||
@@ -17,7 +17,7 @@ from esphome.core import HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
AUTO_LOAD = ["network"]
|
||||
|
||||
|
||||
byte_vector = cg.std_vector.template(cg.uint8)
|
||||
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
|
||||
|
||||
@@ -149,6 +149,12 @@ bool ESPNowComponent::is_wifi_enabled() {
|
||||
}
|
||||
|
||||
void ESPNowComponent::setup() {
|
||||
#ifndef USE_WIFI
|
||||
// Initialize LwIP stack for wake_loop_threadsafe() socket support
|
||||
// When WiFi component is present, it handles esp_netif_init()
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
#endif
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable_();
|
||||
} else {
|
||||
@@ -168,6 +174,8 @@ void ESPNowComponent::enable() {
|
||||
|
||||
void ESPNowComponent::enable_() {
|
||||
if (!this->is_wifi_enabled()) {
|
||||
esp_event_loop_create_default();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
|
||||
@@ -2,7 +2,6 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import automation, pins
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.network import ip_address_literal
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
@@ -14,7 +13,6 @@ from esphome.const import (
|
||||
CONF_DNS1,
|
||||
CONF_DNS2,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
CONF_GATEWAY,
|
||||
CONF_ID,
|
||||
CONF_INTERRUPT_PIN,
|
||||
@@ -165,7 +163,7 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = {
|
||||
"KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"),
|
||||
"KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"),
|
||||
"W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"),
|
||||
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"),
|
||||
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"),
|
||||
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
|
||||
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
|
||||
}
|
||||
@@ -219,10 +217,6 @@ MANUAL_IP_SCHEMA = cv.Schema(
|
||||
|
||||
EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component)
|
||||
ManualIP = ethernet_ns.struct("ManualIP")
|
||||
EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition)
|
||||
EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition)
|
||||
EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action)
|
||||
EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action)
|
||||
|
||||
|
||||
def _is_framework_spi_polling_mode_supported() -> bool:
|
||||
@@ -354,7 +348,6 @@ BASE_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
|
||||
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
|
||||
}
|
||||
@@ -501,9 +494,6 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
|
||||
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
|
||||
# enable_on_boot defaults to true in C++ - only set if false
|
||||
if not config[CONF_ENABLE_ON_BOOT]:
|
||||
cg.add(var.set_enable_on_boot(False))
|
||||
CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE]
|
||||
|
||||
if CONF_MANUAL_IP in config:
|
||||
@@ -725,21 +715,3 @@ def _filter_source_files() -> list[str]:
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = _filter_source_files
|
||||
|
||||
|
||||
async def _new_pvariable_to_code(config, id_, template_arg, args):
|
||||
return cg.new_Pvariable(id_, template_arg)
|
||||
|
||||
|
||||
for _name, _cls in (
|
||||
("ethernet.connected", EthernetConnectedCondition),
|
||||
("ethernet.enabled", EthernetEnabledCondition),
|
||||
):
|
||||
automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code)
|
||||
for _name, _cls in (
|
||||
("ethernet.enable", EthernetEnableAction),
|
||||
("ethernet.disable", EthernetDisableAction),
|
||||
):
|
||||
automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)(
|
||||
_new_pvariable_to_code
|
||||
)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ETHERNET
|
||||
#include "ethernet_component.h"
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
template<typename... Ts> class EthernetConnectedCondition : public Condition<Ts...> {
|
||||
public:
|
||||
bool check(const Ts &...x) override { return global_eth_component->is_connected(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class EthernetEnabledCondition : public Condition<Ts...> {
|
||||
public:
|
||||
bool check(const Ts &...x) override { return global_eth_component->is_enabled(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class EthernetEnableAction : public Action<Ts...> {
|
||||
public:
|
||||
void play(const Ts &...x) override { global_eth_component->enable(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class EthernetDisableAction : public Action<Ts...> {
|
||||
public:
|
||||
void play(const Ts &...x) override { global_eth_component->disable(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
#endif
|
||||
@@ -124,17 +124,6 @@ class EthernetComponent final : public Component {
|
||||
void on_powerdown() override { powerdown(); }
|
||||
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
|
||||
|
||||
// Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled).
|
||||
// enable_on_boot defaults to true; when false, setup() runs all the driver/netif
|
||||
// installation but skips esp_eth_start(), keeping the link cold until enable() is
|
||||
// called. This is the primary lever for memory reclamation in multi-interface
|
||||
// configurations where only one interface should carry traffic at a time.
|
||||
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
|
||||
void enable();
|
||||
void disable();
|
||||
bool is_disabled() { return this->disabled_; }
|
||||
bool is_enabled() { return !this->disabled_; }
|
||||
|
||||
void set_type(EthernetType type);
|
||||
#ifdef USE_ETHERNET_MANUAL_IP
|
||||
void set_manual_ip(const ManualIP &manual_ip);
|
||||
@@ -205,16 +194,6 @@ class EthernetComponent final : public Component {
|
||||
void finish_connect_();
|
||||
void dump_connect_params_();
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver
|
||||
// install, netif attach, and event handler registration (which together allocate
|
||||
// ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue)
|
||||
// until ethernet actually needs to come up. Idempotent — guarded by the
|
||||
// ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or
|
||||
// from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi.
|
||||
void ethernet_lazy_init_();
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
|
||||
void notify_ip_state_listeners_();
|
||||
#endif
|
||||
@@ -308,17 +287,6 @@ class EthernetComponent final : public Component {
|
||||
bool started_{false};
|
||||
bool connected_{false};
|
||||
bool got_ipv4_address_{false};
|
||||
// Codegen-time YAML option. When false, setup() defers esp_eth_start().
|
||||
bool enable_on_boot_{true};
|
||||
// Mirror of "is the link intentionally stopped" — set when setup() honors
|
||||
// enable_on_boot=false, cleared by enable(), set again by disable().
|
||||
bool disabled_{false};
|
||||
#ifdef USE_ESP32
|
||||
// Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable()
|
||||
// to be called at runtime after enable_on_boot:false without re-allocating, and
|
||||
// ensures setup() skips the heavy init when enable_on_boot_ is false.
|
||||
bool ethernet_initialized_{false};
|
||||
#endif
|
||||
#if LWIP_IPV6
|
||||
uint8_t ipv6_count_{0};
|
||||
bool ipv6_setup_done_{false};
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
@@ -138,24 +137,6 @@ void EthernetComponent::setup() {
|
||||
delay(300); // NOLINT
|
||||
}
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
this->ethernet_lazy_init_();
|
||||
if (!this->ethernet_initialized_) {
|
||||
// lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do.
|
||||
return;
|
||||
}
|
||||
esp_err_t err = esp_eth_start(this->eth_handle_);
|
||||
ESPHL_ERROR_CHECK(err, "ETH start error");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)");
|
||||
this->disabled_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void EthernetComponent::ethernet_lazy_init_() {
|
||||
if (this->ethernet_initialized_)
|
||||
return;
|
||||
|
||||
esp_err_t err;
|
||||
|
||||
#ifdef USE_ETHERNET_SPI
|
||||
@@ -182,7 +163,11 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
|
||||
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
|
||||
#endif
|
||||
// Network interface setup handled by network component
|
||||
|
||||
err = esp_netif_init();
|
||||
ESPHL_ERROR_CHECK(err, "ETH netif init error");
|
||||
err = esp_event_loop_create_default();
|
||||
ESPHL_ERROR_CHECK(err, "ETH event loop error");
|
||||
|
||||
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
|
||||
this->eth_netif_ = esp_netif_new(&cfg);
|
||||
@@ -222,10 +207,6 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
w5500_config.poll_period_ms = this->polling_interval_;
|
||||
#endif
|
||||
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
|
||||
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
|
||||
// runs the driver's init().
|
||||
install_w5500_async_spi(w5500_config);
|
||||
#elif defined(USE_ETHERNET_DM9051)
|
||||
dm9051_config.int_gpio_num = this->interrupt_pin_;
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
@@ -389,41 +370,9 @@ void EthernetComponent::ethernet_lazy_init_() {
|
||||
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
|
||||
this->ethernet_initialized_ = true;
|
||||
}
|
||||
|
||||
void EthernetComponent::enable() {
|
||||
if (!this->disabled_)
|
||||
return;
|
||||
|
||||
ESP_LOGD(TAG, "Enabling");
|
||||
this->ethernet_lazy_init_();
|
||||
if (!this->ethernet_initialized_) {
|
||||
ESP_LOGE(TAG, "Cannot enable - init failed");
|
||||
return;
|
||||
}
|
||||
esp_err_t err = esp_eth_start(this->eth_handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
this->disabled_ = false;
|
||||
// The ETH_EVENT_START handler will set started_=true; the loop state machine
|
||||
// will then drive the STOPPED -> CONNECTING -> CONNECTED transitions.
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
void EthernetComponent::disable() {
|
||||
if (this->disabled_)
|
||||
return;
|
||||
|
||||
ESP_LOGD(TAG, "Disabling");
|
||||
esp_err_t err = esp_eth_stop(this->eth_handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err));
|
||||
}
|
||||
this->disabled_ = true;
|
||||
// ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED.
|
||||
/* start Ethernet driver state machine */
|
||||
err = esp_eth_start(this->eth_handle_);
|
||||
ESPHL_ERROR_CHECK(err, "ETH start error");
|
||||
}
|
||||
|
||||
void EthernetComponent::dump_config() {
|
||||
@@ -537,8 +486,6 @@ void EthernetComponent::dump_config() {
|
||||
|
||||
network::IPAddresses EthernetComponent::get_ip_addresses() {
|
||||
network::IPAddresses addresses;
|
||||
if (!this->ethernet_initialized_)
|
||||
return addresses; // all-zero IPs
|
||||
esp_netif_ip_info_t ip;
|
||||
esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip);
|
||||
if (err != ESP_OK) {
|
||||
@@ -761,10 +708,6 @@ void EthernetComponent::start_connect_() {
|
||||
}
|
||||
|
||||
void EthernetComponent::dump_connect_params_() {
|
||||
if (!this->ethernet_initialized_) {
|
||||
ESP_LOGCONFIG(TAG, " uninitialized/disabled");
|
||||
return;
|
||||
}
|
||||
esp_netif_ip_info_t ip;
|
||||
esp_netif_get_ip_info(this->eth_netif_, &ip);
|
||||
const ip_addr_t *dns_ip1;
|
||||
@@ -832,16 +775,6 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
|
||||
#endif
|
||||
|
||||
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (!this->ethernet_initialized_) {
|
||||
// External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
|
||||
// of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC.
|
||||
if (this->fixed_mac_.has_value()) {
|
||||
memcpy(mac, this->fixed_mac_->data(), 6);
|
||||
} else {
|
||||
esp_read_mac(mac, ESP_MAC_ETH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
esp_err_t err;
|
||||
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac);
|
||||
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
|
||||
@@ -861,8 +794,6 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
}
|
||||
|
||||
eth_duplex_t EthernetComponent::get_duplex_mode() {
|
||||
if (!this->ethernet_initialized_)
|
||||
return ETH_DUPLEX_HALF;
|
||||
esp_err_t err;
|
||||
eth_duplex_t duplex_mode;
|
||||
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode);
|
||||
@@ -871,8 +802,6 @@ eth_duplex_t EthernetComponent::get_duplex_mode() {
|
||||
}
|
||||
|
||||
eth_speed_t EthernetComponent::get_link_speed() {
|
||||
if (!this->ethernet_initialized_)
|
||||
return ETH_SPEED_10M;
|
||||
esp_err_t err;
|
||||
eth_speed_t speed;
|
||||
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed);
|
||||
|
||||
@@ -361,23 +361,6 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
|
||||
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
|
||||
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
|
||||
|
||||
void EthernetComponent::enable() {
|
||||
// RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
|
||||
// there is no clean enable/disable hook today. The YAML option is accepted on
|
||||
// RP2040 for schema parity but has no effect.
|
||||
if (!this->disabled_)
|
||||
return;
|
||||
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
|
||||
this->disabled_ = false;
|
||||
}
|
||||
|
||||
void EthernetComponent::disable() {
|
||||
if (this->disabled_)
|
||||
return;
|
||||
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
|
||||
this->disabled_ = true;
|
||||
}
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ETHERNET && USE_RP2040
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <driver/spi_master.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
namespace {
|
||||
|
||||
// Per-device context returned by init() and handed back to read/write/deinit.
|
||||
struct W5500CustomSpiContext {
|
||||
spi_device_handle_t handle;
|
||||
SemaphoreHandle_t lock;
|
||||
};
|
||||
|
||||
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
|
||||
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
|
||||
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
|
||||
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
|
||||
|
||||
void *w5500_custom_spi_init(const void *spi_config) {
|
||||
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
|
||||
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
|
||||
if (ctx == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
|
||||
// byte in the address phase; mirror what the stock driver configures.
|
||||
spi_device_interface_config_t devcfg = *config->spi_devcfg;
|
||||
devcfg.command_bits = 16;
|
||||
devcfg.address_bits = 8;
|
||||
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
ctx->lock = xSemaphoreCreateMutex();
|
||||
if (ctx->lock == nullptr) {
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
vSemaphoreDelete(ctx->lock);
|
||||
delete ctx;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
|
||||
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
|
||||
// accesses stay on the cheaper polling path. Used by both read and write.
|
||||
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
|
||||
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t ret;
|
||||
if (len > W5500_SPI_BULK_THRESHOLD) {
|
||||
ret = spi_device_transmit(ctx->handle, trans);
|
||||
} else {
|
||||
ret = spi_device_polling_transmit(ctx->handle, trans);
|
||||
}
|
||||
xSemaphoreGive(ctx->lock);
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.tx_buffer = data;
|
||||
return w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
|
||||
// overwrites of adjacent registers (same guard the stock driver uses).
|
||||
const bool use_rxdata = len <= 4;
|
||||
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.rx_buffer = data;
|
||||
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
if (use_rxdata && (ret == ESP_OK)) {
|
||||
memcpy(data, trans.rx_data, len);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config) {
|
||||
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
|
||||
// spi_devcfg back out of it. The self-reference is valid because both the config and the
|
||||
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
|
||||
config.custom_spi_driver.config = &config;
|
||||
config.custom_spi_driver.init = w5500_custom_spi_init;
|
||||
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
|
||||
config.custom_spi_driver.read = w5500_custom_spi_read;
|
||||
config.custom_spi_driver.write = w5500_custom_spi_write;
|
||||
}
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
@@ -1,35 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
|
||||
// is no longer reachable through esp_eth.h and needs the explicit header.
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#include <esp_eth_mac_w5500.h>
|
||||
#else
|
||||
#include <esp_eth.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
|
||||
//
|
||||
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
|
||||
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
|
||||
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
|
||||
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
|
||||
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
|
||||
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
|
||||
// is cheaper than an interrupt round-trip.
|
||||
//
|
||||
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
|
||||
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
|
||||
// alive until esp_eth_mac_new_w5500() returns.
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config);
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
@@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None:
|
||||
elif conf[CONF_TYPE] == TYPE_LOCAL:
|
||||
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
|
||||
else:
|
||||
raise NotImplementedError
|
||||
raise NotImplementedError()
|
||||
|
||||
if config[CONF_COMPONENTS] == "all":
|
||||
num_components = len(list(components_dir.glob("*/__init__.py")))
|
||||
|
||||
@@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"FastLED light:\n"
|
||||
" Num LEDs: %u\n"
|
||||
" Max refresh rate: %" PRIu32,
|
||||
" Max refresh rate: %u",
|
||||
this->num_leds_, this->max_refresh_rate_.value_or(0));
|
||||
}
|
||||
void FastLEDLightOutput::write_state(light::LightState *state) {
|
||||
|
||||
@@ -206,7 +206,6 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
|
||||
break;
|
||||
case ENROLL_MISMATCH:
|
||||
ESP_LOGE(TAG, "Scans do not match");
|
||||
[[fallthrough]];
|
||||
default:
|
||||
return this->data_[0];
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ def validate_file_shorthand(value):
|
||||
data[CONF_WEIGHT] = weight[1:]
|
||||
return font_file_schema(data)
|
||||
|
||||
if value.startswith(("http://", "https://")):
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
@@ -563,13 +563,13 @@ async def to_code(config):
|
||||
point_set.update(flatten(config[CONF_GLYPHS]))
|
||||
# Create the codepoint to font file map
|
||||
base_font = FONT_CACHE[config[CONF_FILE]]
|
||||
point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font)
|
||||
point_font_map: dict[str, Face] = {c: base_font for c in point_set}
|
||||
# process extras, updating the map and extending the codepoint list
|
||||
for extra in config[CONF_EXTRAS]:
|
||||
extra_points = flatten(extra[CONF_GLYPHS])
|
||||
point_set.update(extra_points)
|
||||
extra_font = FONT_CACHE[extra[CONF_FILE]]
|
||||
point_font_map.update(dict.fromkeys(extra_points, extra_font))
|
||||
point_font_map.update({c: extra_font for c in extra_points})
|
||||
|
||||
codepoints = list(point_set)
|
||||
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
|
||||
@@ -594,9 +594,7 @@ async def to_code(config):
|
||||
x.height,
|
||||
]
|
||||
for (x, y) in zip(
|
||||
glyph_args,
|
||||
list(accumulate([len(x.bitmap_data) for x in glyph_args])),
|
||||
strict=True,
|
||||
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -15,16 +15,6 @@ void FT5x06Touchscreen::setup() {
|
||||
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
|
||||
// wait 200ms after reset.
|
||||
this->set_timeout(200, [this] { this->continue_setup_(); });
|
||||
}
|
||||
@@ -49,6 +39,15 @@ void FT5x06Touchscreen::continue_setup_() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FT5x06Touchscreen::update_touches() {
|
||||
@@ -72,7 +71,7 @@ void FT5x06Touchscreen::update_touches() {
|
||||
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
|
||||
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
|
||||
|
||||
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
if (status == 0 || status == 2) {
|
||||
this->add_raw_touch_position_(id, x, y);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ def _final_validate(config):
|
||||
if not use_interrupt:
|
||||
return config
|
||||
|
||||
pin_num = config[CONF_PIN][CONF_NUMBER]
|
||||
|
||||
# Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt
|
||||
# attachment — only internal/native GPIO pins do.
|
||||
if pins.PIN_SCHEMA_REGISTRY.get_key(config[CONF_PIN]) != CORE.target_platform:
|
||||
@@ -85,8 +87,6 @@ def _final_validate(config):
|
||||
config[CONF_USE_INTERRUPT] = False
|
||||
return config
|
||||
|
||||
pin_num = config[CONF_PIN][CONF_NUMBER]
|
||||
|
||||
# GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt().
|
||||
if CORE.is_esp8266 and pin_num == 16:
|
||||
_LOGGER.warning(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user