mirror of
https://github.com/esphome/esphome.git
synced 2026-06-25 00:29:33 +00:00
Compare commits
127 Commits
multi-inte
...
core-block
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4f67def8 | ||
|
|
5b728f19c3 | ||
|
|
063770bcf4 | ||
|
|
6197282f1a | ||
|
|
9c0ffee020 | ||
|
|
1740e54105 | ||
|
|
070c14b04a | ||
|
|
559cfd1555 | ||
|
|
571a12ffe5 | ||
|
|
a4d247fa0a | ||
|
|
8e57894af7 | ||
|
|
f9aba18f8e | ||
|
|
a04f6da814 | ||
|
|
3f57117efd | ||
|
|
d7f809181a | ||
|
|
d7d20f4f6b | ||
|
|
ab46f8bd74 | ||
|
|
2454ad1645 | ||
|
|
4e48682468 | ||
|
|
805aa252d5 | ||
|
|
6116d10ab1 | ||
|
|
48844a68ba | ||
|
|
7865dc33bc | ||
|
|
bf62124032 | ||
|
|
95397948b9 | ||
|
|
f0202155b3 | ||
|
|
07a57d7557 | ||
|
|
091a05ccde | ||
|
|
dd961156d0 | ||
|
|
10abb0647c | ||
|
|
a85f8ad935 | ||
|
|
8945550c6c | ||
|
|
4b8e06b5bc | ||
|
|
f41866a9b8 | ||
|
|
5732d7135f | ||
|
|
ec597bfc03 | ||
|
|
9a6157b469 | ||
|
|
ac29fad120 | ||
|
|
e87190edb4 | ||
|
|
911e330c09 | ||
|
|
e64b6bc398 | ||
|
|
21e548f1d7 | ||
|
|
3cc875c40b | ||
|
|
7463a15c7e | ||
|
|
8d19c55be2 | ||
|
|
87d0e24d19 | ||
|
|
91ead4ff54 | ||
|
|
a6ef67aa65 | ||
|
|
e174c44b28 | ||
|
|
f728cb4373 | ||
|
|
6c4a8a3245 | ||
|
|
eb1196c6b2 | ||
|
|
fb0b73980b | ||
|
|
171ded35a5 | ||
|
|
b71d445e79 | ||
|
|
4d908798bc | ||
|
|
62b3b1cc75 | ||
|
|
52ead52ef2 | ||
|
|
96816e2491 | ||
|
|
bac62cb7de | ||
|
|
722cbfe843 | ||
|
|
88b12a1c45 | ||
|
|
ceb9d406e1 | ||
|
|
8b62cfded7 | ||
|
|
423b60c90c | ||
|
|
ae74920b81 | ||
|
|
ae814cff5c | ||
|
|
489cf483d0 | ||
|
|
dd0028c1b5 | ||
|
|
e492f8f8b6 | ||
|
|
b39b34bfe1 | ||
|
|
bbc24ab546 | ||
|
|
f1839489dd | ||
|
|
5172227931 | ||
|
|
97267105e1 | ||
|
|
8645f3672d | ||
|
|
a257edba62 | ||
|
|
61e8830a3c | ||
|
|
fc0a4e2201 | ||
|
|
0b780f1fd2 | ||
|
|
dcc30f8651 | ||
|
|
892e116680 | ||
|
|
1c7ae96e42 | ||
|
|
684bce8b9a | ||
|
|
7c494fd3ef | ||
|
|
cf1fabe6d4 | ||
|
|
cde52ef75e | ||
|
|
98e7213387 | ||
|
|
e7ab78366d | ||
|
|
e0167e9bdf | ||
|
|
62b0a93e5e | ||
|
|
1fb8c26704 | ||
|
|
3d1a614e55 | ||
|
|
917ffc3797 | ||
|
|
090f5a486a | ||
|
|
03e2eb4b4a | ||
|
|
ddd353d105 | ||
|
|
9a34a6aabb | ||
|
|
0babc52472 | ||
|
|
adde7681e8 | ||
|
|
8f6ea62628 | ||
|
|
4e7bc92061 | ||
|
|
1f4a061572 | ||
|
|
59db9a4673 | ||
|
|
7ae5566472 | ||
|
|
f247def4ac | ||
|
|
27d53ec117 | ||
|
|
0c94a173b6 | ||
|
|
ae2e372762 | ||
|
|
e6ed275746 | ||
|
|
878027ff50 | ||
|
|
858cfd5b94 | ||
|
|
5225416347 | ||
|
|
615d5aa827 | ||
|
|
e92a4c9472 | ||
|
|
32fa856bf0 | ||
|
|
cc88456ce7 | ||
|
|
79539cb85d | ||
|
|
16b6509a03 | ||
|
|
9fcb638f33 | ||
|
|
747787ae98 | ||
|
|
5cb7e62241 | ||
|
|
c17c4478ac | ||
|
|
750d52741a | ||
|
|
a37f27ee7f | ||
|
|
5f860ff5bd | ||
|
|
c951881eea |
@@ -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-docs**: Link if docs are needed
|
||||
- **Pull request in esphome.io**: 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-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
|
||||
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
|
||||
|
||||
- esphome/esphome-docs#XXX
|
||||
- esphome/esphome.io#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-docs](https://github.com/esphome/esphome-docs).
|
||||
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
|
||||
```
|
||||
|
||||
## 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-docs/issues/new/choose
|
||||
url: https://github.com/esphome/esphome.io/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-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
|
||||
**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
|
||||
|
||||
- esphome/esphome-docs#<esphome-docs PR number goes here>
|
||||
- esphome/esphome.io#<esphome.io 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-docs](https://github.com/esphome/esphome-docs).
|
||||
- [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -5,6 +5,7 @@ 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,6 +35,9 @@ 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,6 +107,8 @@ 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);
|
||||
@@ -114,6 +116,12 @@ 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
Normal file
7
.github/scripts/auto-label-pr/package.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "auto-label-pr",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.js"
|
||||
}
|
||||
}
|
||||
147
.github/scripts/auto-label-pr/tests/detectors.test.js
vendored
Normal file
147
.github/scripts/auto-label-pr/tests/detectors.test.js
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
27
.github/workflows/ci-github-scripts.yml
vendored
Normal file
27
.github/workflows/ci-github-scripts.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -452,7 +452,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
|
||||
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.14
|
||||
rev: v0.15.15
|
||||
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-docs` repository.
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` 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-docs
|
||||
- [ ] Updated all internal usage and esphome.io
|
||||
- [ ] Tested backward compatibility during deprecation period
|
||||
|
||||
* **Deprecation Pattern (C++):**
|
||||
|
||||
@@ -417,6 +417,7 @@ 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
|
||||
|
||||
@@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = getattr(module, "process_stacktrace")
|
||||
process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
@@ -639,7 +639,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()
|
||||
time_ = datetime.now().astimezone()
|
||||
milliseconds = time_.microsecond // 1000
|
||||
time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
|
||||
|
||||
@@ -760,6 +760,7 @@ 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
|
||||
|
||||
@@ -794,7 +795,7 @@ def _check_and_emit_build_info() -> None:
|
||||
|
||||
# Read build_info from JSON
|
||||
try:
|
||||
with open(build_info_json_path, encoding="utf-8") as f:
|
||||
with build_info_json_path.open(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)
|
||||
@@ -1056,7 +1057,7 @@ def _wait_for_serial_port(
|
||||
def _port_found() -> bool:
|
||||
if port is not None:
|
||||
if os.name == "posix":
|
||||
return os.path.exists(port)
|
||||
return Path(port).exists()
|
||||
return any(p.path == port for p in get_serial_ports())
|
||||
ports = get_serial_ports()
|
||||
if known_ports is not None:
|
||||
@@ -1101,7 +1102,7 @@ def upload_program(
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if getattr(module, "upload_program")(config, args, host):
|
||||
if module.upload_program(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -1350,10 +1351,23 @@ 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 getattr(module, "show_logs")(config, args, devices):
|
||||
if module.show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -1379,7 +1393,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=not getattr(args, "no_states", False),
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
@@ -1412,17 +1426,47 @@ 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 = re.sub(
|
||||
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
|
||||
)
|
||||
output = _redact_with_legacy_fallback(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
|
||||
@@ -1800,7 +1844,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
ram_report = ram_analyzer.generate_report()
|
||||
print()
|
||||
print(ram_report)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.warning("RAM strings analysis failed: %s", e)
|
||||
|
||||
return 0
|
||||
@@ -1988,6 +2032,29 @@ 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(
|
||||
@@ -2164,11 +2231,7 @@ def parse_args(argv):
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
parser_logs.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
_add_states_args(parser_logs)
|
||||
|
||||
parser_discover = subparsers.add_parser(
|
||||
"discover",
|
||||
@@ -2200,11 +2263,7 @@ def parse_args(argv):
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
|
||||
parser_run.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
_add_states_args(parser_run)
|
||||
|
||||
parser_run.add_argument(
|
||||
"--reset",
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -509,7 +510,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(
|
||||
@@ -601,7 +602,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)}"
|
||||
)
|
||||
@@ -640,7 +641,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)
|
||||
@@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
content = "\n".join(lines)
|
||||
|
||||
if output_file:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with Path(output_file).open("w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
print(content)
|
||||
@@ -737,7 +738,6 @@ 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 open(idedata_path, encoding="utf-8") as f:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
|
||||
@@ -154,7 +154,7 @@ def batch_demangle(
|
||||
failed_count = 0
|
||||
|
||||
for original, stripped, prefix, demangled in zip(
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
|
||||
):
|
||||
# Add back any prefix that was removed
|
||||
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -37,7 +36,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(os.path.expanduser("~/.platformio/packages"))
|
||||
platformio_home = Path("~/.platformio/packages").expanduser()
|
||||
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: # pylint: disable=broad-except
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
self.exception = exc
|
||||
|
||||
@@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(project_desc, encoding="utf-8") as f:
|
||||
with project_desc.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
component_info = data.get("build_component_info", {})
|
||||
|
||||
@@ -412,7 +412,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 open(bf.source, "rb") as f:
|
||||
with bf.source.open("rb") as f:
|
||||
_add_bytes_to_tar(tar, bf.path, f.read())
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def save_compiled_config(config: ConfigType) -> None:
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
|
||||
@@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
|
||||
|
||||
ENCRYPTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_KEY): validate_encryption_key,
|
||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1169,7 +1169,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);
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(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,6 +1306,9 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#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"
|
||||
@@ -677,7 +678,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 %u timed out", action_call_id);
|
||||
ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
|
||||
this->unregister_active_action_call(action_call_id);
|
||||
});
|
||||
|
||||
@@ -721,7 +722,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 %u", action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, 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,
|
||||
@@ -733,7 +734,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 %u", action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
|
||||
@@ -101,13 +101,14 @@ 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 = getattr(module, "process_stacktrace")
|
||||
platform_process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
@@ -118,7 +119,7 @@ async def async_run_logs(
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now()
|
||||
time_ = datetime.now().astimezone()
|
||||
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("°") or value.endswith("deg")):
|
||||
if isinstance(value, str) and value.endswith(("°", "deg")):
|
||||
return angle_to_position(
|
||||
value,
|
||||
min=round(min * POSITION_TO_ANGLE),
|
||||
|
||||
@@ -9,9 +9,12 @@ 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)
|
||||
@@ -20,11 +23,13 @@ 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) {
|
||||
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
|
||||
// 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_);
|
||||
if (source == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
// create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
source->set_source(input_ring_buffer);
|
||||
this->input_buffer_ = std::move(source);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
}
|
||||
|
||||
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
|
||||
|
||||
uint32_t decoding_start = millis();
|
||||
|
||||
bool first_loop_iteration = true;
|
||||
|
||||
size_t bytes_processed = 0;
|
||||
size_t bytes_available_before_processing = 0;
|
||||
uint8_t no_output_iterations = 0;
|
||||
|
||||
while (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
// Transfer decoded out
|
||||
@@ -161,45 +160,39 @@ 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);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
bytes_available_before_processing = this->input_buffer_->available();
|
||||
// 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);
|
||||
|
||||
if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
|
||||
// Failed to decode in last attempt and there is no new data
|
||||
const size_t available_before_decode = this->input_buffer_->available();
|
||||
|
||||
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) {
|
||||
if (available_before_decode == 0) {
|
||||
// No data to decode, attempt to get more data next time
|
||||
state = FileDecoderState::IDLE;
|
||||
} else {
|
||||
@@ -231,9 +224,6 @@ 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) {
|
||||
@@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
} else if (state == FileDecoderState::FAILED) {
|
||||
return AudioDecoderState::FAILED;
|
||||
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
this->potentially_failed_count_ = 0;
|
||||
// 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_;
|
||||
}
|
||||
}
|
||||
}
|
||||
return AudioDecoderState::DECODING;
|
||||
|
||||
@@ -61,15 +61,16 @@ 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 Size of the input transfer buffer in bytes.
|
||||
/// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, 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. 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
|
||||
/// @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
|
||||
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,16 +12,17 @@ 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) {
|
||||
if (this->input_transfer_buffer_ != nullptr) {
|
||||
this->input_transfer_buffer_->set_source(input_ring_buffer);
|
||||
return ESP_OK;
|
||||
// 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;
|
||||
}
|
||||
return ESP_ERR_NO_MEM;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t AudioResampler::add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
|
||||
@@ -47,7 +48,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->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
|
||||
if (this->output_transfer_buffer_ == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,13 @@ 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>(
|
||||
@@ -87,8 +95,27 @@ 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->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
return AudioResamplerState::FINISHED;
|
||||
}
|
||||
}
|
||||
@@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
delay(READ_WRITE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(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);
|
||||
|
||||
if (this->input_transfer_buffer_->available() == 0) {
|
||||
if (this->audio_source_->available() == 0) {
|
||||
// No samples available to process
|
||||
return AudioResamplerState::RESAMPLING;
|
||||
}
|
||||
@@ -112,17 +141,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->input_transfer_buffer_->available();
|
||||
const size_t bytes_available = this->audio_source_->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->input_transfer_buffer_->get_buffer_start(),
|
||||
this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
|
||||
this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
frames_available, frames_free, -3);
|
||||
|
||||
this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
|
||||
this->audio_source_->consume(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));
|
||||
|
||||
@@ -146,10 +175,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(),
|
||||
(void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
|
||||
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
|
||||
bytes_to_transfer);
|
||||
|
||||
this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
|
||||
this->audio_source_->consume(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, // Unused state included for consistency among Audio classes
|
||||
FAILED, // Failed to allocate the audio source
|
||||
};
|
||||
|
||||
class AudioResampler {
|
||||
@@ -32,14 +32,16 @@ class AudioResampler {
|
||||
* component). Also supports converting bits per sample.
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the input and output transfer buffers
|
||||
/// @param input_buffer_size Size of the input transfer buffer in bytes.
|
||||
/// @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.
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
/// @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
|
||||
/// @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
|
||||
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.
|
||||
@@ -78,7 +80,8 @@ class AudioResampler {
|
||||
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
|
||||
|
||||
protected:
|
||||
std::unique_ptr<AudioSourceTransferBuffer> input_transfer_buffer_;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> source_ring_buffer_;
|
||||
std::unique_ptr<RingBufferAudioSource> audio_source_;
|
||||
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
|
||||
|
||||
size_t input_buffer_size_;
|
||||
|
||||
@@ -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://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "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 open(path, "rb") as f:
|
||||
with path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
from esphome.components import audio, 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
|
||||
@@ -21,19 +19,13 @@ 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): _validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
psram.request_external_task_stack()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
from esphome.components import audio, 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
|
||||
@@ -20,14 +18,6 @@ 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,
|
||||
@@ -37,7 +27,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): _validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
psram.request_external_task_stack()
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
|
||||
@@ -169,7 +169,7 @@ async def to_code_base(config):
|
||||
path = _compute_local_file_path(_compute_url(config))
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
bsec2_iaq_config = f.read()
|
||||
except Exception as e:
|
||||
raise core.EsphomeError(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_component
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
require_libc_picolibc_newlib_compat,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE
|
||||
from esphome.types import ConfigType
|
||||
@@ -51,6 +54,8 @@ 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,6 +22,7 @@ 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,9 +16,14 @@
|
||||
#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 __has_include(<mbedtls/gcm.h>)
|
||||
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
|
||||
#if __has_include(<mbedtls/esp_config.h>)
|
||||
#include <mbedtls/esp_config.h>
|
||||
#endif
|
||||
@@ -33,7 +38,7 @@ namespace esphome::dsmr {
|
||||
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
|
||||
#else
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
|
||||
|
||||
@@ -52,6 +52,8 @@ 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, HexInt, Library
|
||||
from esphome.core import CORE, EsphomeError, 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 ( # noqa
|
||||
from .const import (
|
||||
KEY_ARDUINO_LIBRARIES,
|
||||
KEY_BOARD,
|
||||
KEY_COMPONENTS,
|
||||
@@ -78,15 +78,18 @@ from .const import ( # noqa
|
||||
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
|
||||
from .gpio import esp32_pin_to_code # noqa: F401
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
AUTO_LOAD = ["preferences"]
|
||||
@@ -403,9 +406,12 @@ 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.
|
||||
@@ -464,21 +470,20 @@ 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:
|
||||
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:
|
||||
idf_ver = framework_ver
|
||||
elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None:
|
||||
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
|
||||
@@ -715,6 +720,9 @@ 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),
|
||||
@@ -735,6 +743,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
# These versions correspond to pioarduino/esp-idf releases
|
||||
# See: https://github.com/pioarduino/esp-idf/releases
|
||||
ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
cv.Version(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),
|
||||
@@ -829,6 +838,16 @@ def _resolve_framework_version(value: ConfigType) -> cv.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]
|
||||
@@ -897,8 +916,10 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
"If there are connectivity or build issues please remove the manual source."
|
||||
)
|
||||
|
||||
# Official ESP-IDF frameworks don't use the 'extra' semver component.
|
||||
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
|
||||
# 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))
|
||||
|
||||
return config
|
||||
|
||||
@@ -907,11 +928,16 @@ def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
|
||||
|
||||
|
||||
def _check_versions(config):
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
# 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 = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
|
||||
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO)
|
||||
return value
|
||||
|
||||
|
||||
def _check_versions(config: ConfigType) -> ConfigType:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
return _check_esp_idf_versions(config)
|
||||
return _check_pio_versions(config)
|
||||
@@ -933,7 +959,21 @@ 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
|
||||
# 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],
|
||||
)
|
||||
value = value.copy()
|
||||
value[CONF_BOARD] = STANDARD_BOARDS[variant]
|
||||
if variant == VARIANT_ESP32P4:
|
||||
@@ -1220,6 +1260,7 @@ 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:
|
||||
@@ -1328,6 +1369,15 @@ 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 *)
|
||||
@@ -1606,6 +1656,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
}
|
||||
),
|
||||
_resolve_toolchain,
|
||||
_detect_variant,
|
||||
_set_default_framework,
|
||||
_check_versions,
|
||||
@@ -1732,6 +1783,26 @@ 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."""
|
||||
@@ -1816,8 +1887,11 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
# Undo IDF's blanket -Werror so third-party libraries and user
|
||||
# lambdas don't need a -Wno-error=<class> entry per warning class.
|
||||
# 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")
|
||||
@@ -2262,17 +2336,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False)
|
||||
|
||||
# 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)
|
||||
# FINAL priority: runs after every require_libc_picolibc_newlib_compat() call
|
||||
CORE.add_job(_set_libc_picolibc_newlib_compat)
|
||||
|
||||
# Disable regi2c control functions in IRAM
|
||||
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
|
||||
@@ -2580,6 +2645,26 @@ 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)
|
||||
@@ -2655,16 +2740,32 @@ def copy_files():
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
from esphome.platformio import toolchain
|
||||
# _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
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
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:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from .const import (
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANTS,
|
||||
)
|
||||
|
||||
STANDARD_BOARDS = {
|
||||
@@ -25,9 +24,6 @@ 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,9 +24,12 @@ 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,
|
||||
@@ -35,9 +38,12 @@ VARIANTS = [
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32H21,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
]
|
||||
|
||||
VARIANT_FRIENDLY = {
|
||||
@@ -48,9 +54,12 @@ 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,9 +31,12 @@ 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
|
||||
@@ -43,9 +46,12 @@ 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)
|
||||
|
||||
@@ -120,6 +126,14 @@ _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,
|
||||
@@ -132,6 +146,10 @@ _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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
34
esphome/components/esp32/gpio_esp32_h21.py
Normal file
34
esphome/components/esp32/gpio_esp32_h21.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
34
esphome/components/esp32/gpio_esp32_h4.py
Normal file
34
esphome/components/esp32/gpio_esp32_h4.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
38
esphome/components/esp32/gpio_esp32_s31.py
Normal file
38
esphome/components/esp32/gpio_esp32_s31.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
@@ -3,7 +3,11 @@ 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
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
require_libc_picolibc_newlib_compat,
|
||||
)
|
||||
from esphome.components.psram import DOMAIN as psram_domain
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -402,6 +406,8 @@ 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,6 +3,7 @@ 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,
|
||||
@@ -39,6 +40,7 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -242,6 +244,12 @@ 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}"
|
||||
@@ -249,7 +257,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.7")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
|
||||
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,6 +3,7 @@ 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
|
||||
@@ -11,7 +12,6 @@ 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 open(path, "rb") as f:
|
||||
with path.open("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 open(CORE.relative_config_path(path), "rb") as f:
|
||||
with CORE.relative_config_path(path).open("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)
|
||||
|
||||
@@ -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: # pylint: disable=broad-except
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
|
||||
@@ -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.string,
|
||||
cv.Optional(CONF_PASSWORD): cv.sensitive(),
|
||||
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"
|
||||
),
|
||||
|
||||
@@ -2,12 +2,9 @@ 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 (
|
||||
KEY_NETWORK_PRIORITY,
|
||||
get_network_priority,
|
||||
ip_address_literal,
|
||||
)
|
||||
from esphome.components.network import ip_address_literal
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -32,7 +29,6 @@ from esphome.const import (
|
||||
CONF_PAGE_ID,
|
||||
CONF_PIN,
|
||||
CONF_POLLING_INTERVAL,
|
||||
CONF_PRIORITY,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SPI,
|
||||
CONF_STATIC_IP,
|
||||
@@ -54,6 +50,7 @@ from esphome.core import (
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONFLICTS_WITH = ["wifi"]
|
||||
AUTO_LOAD = ["network"]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -168,7 +165,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.0.0"),
|
||||
"DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"),
|
||||
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
|
||||
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
|
||||
}
|
||||
@@ -222,6 +219,10 @@ 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:
|
||||
@@ -493,11 +494,6 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
# Apply network priority if configured, otherwise use the existing default
|
||||
prio = get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.add(var.set_setup_priority(prio))
|
||||
|
||||
if CORE.is_esp32:
|
||||
await _to_code_esp32(var, config)
|
||||
elif CORE.is_rp2040:
|
||||
@@ -590,16 +586,10 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
# Disable WiFi when using Ethernet alone to save memory.
|
||||
# When network: priority: lists both interfaces, WiFi must remain enabled.
|
||||
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
|
||||
priority_ifaces = {e["interface"] for e in net_priority}
|
||||
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
|
||||
if not running_with_wifi:
|
||||
# Disable WiFi when using Ethernet to save memory
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
|
||||
# Also disable WiFi/BT coexistence since WiFi is disabled
|
||||
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
|
||||
# Disable WiFi when using Ethernet to save memory
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
|
||||
# Also disable WiFi/BT coexistence since WiFi is disabled
|
||||
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
|
||||
|
||||
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_eth")
|
||||
@@ -685,17 +675,6 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""Final validation for Ethernet component."""
|
||||
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
|
||||
full = fv.full_config.get()
|
||||
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
|
||||
priority_ifaces = {e["interface"] for e in net_priority}
|
||||
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
|
||||
if "wifi" in full and not has_priority_config:
|
||||
raise cv.Invalid(
|
||||
"Component ethernet cannot be used together with component wifi "
|
||||
"unless both are listed under 'network: priority:'"
|
||||
)
|
||||
|
||||
_final_validate_spi(config)
|
||||
_final_validate_rmii_pins(config)
|
||||
return config
|
||||
@@ -746,3 +725,21 @@ 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
|
||||
)
|
||||
|
||||
30
esphome/components/ethernet/automation.h
Normal file
30
esphome/components/ethernet/automation.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#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
|
||||
@@ -833,10 +833,13 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
|
||||
|
||||
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
if (!this->ethernet_initialized_) {
|
||||
// External callers (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
|
||||
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
|
||||
// assigned to the ETH interface — same value the driver would have returned.
|
||||
esp_read_mac(mac, ESP_MAC_ETH);
|
||||
// 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;
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -401,7 +401,7 @@ def validate_file_shorthand(value):
|
||||
data[CONF_WEIGHT] = weight[1:]
|
||||
return font_file_schema(data)
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "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] = {c: base_font for c in point_set}
|
||||
point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font)
|
||||
# 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({c: extra_font for c in extra_points})
|
||||
point_font_map.update(dict.fromkeys(extra_points, extra_font))
|
||||
|
||||
codepoints = list(point_set)
|
||||
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
|
||||
@@ -594,7 +594,9 @@ 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]))
|
||||
glyph_args,
|
||||
list(accumulate([len(x.bitmap_data) for x in glyph_args])),
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -74,8 +74,6 @@ 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:
|
||||
@@ -87,6 +85,8 @@ 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(
|
||||
|
||||
@@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
|
||||
switch (this->protocol_version_) {
|
||||
case RTU: {
|
||||
publish_1_reg_sensor_state(this->inverter_status_, 0, 1);
|
||||
publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1);
|
||||
|
||||
publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER,
|
||||
RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER,
|
||||
RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER,
|
||||
RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION,
|
||||
RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
|
||||
break;
|
||||
}
|
||||
case RTU2: {
|
||||
publish_1_reg_sensor_state(this->inverter_status_, 0, 1);
|
||||
publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1);
|
||||
|
||||
publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
|
||||
publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1,
|
||||
ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER,
|
||||
RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER,
|
||||
RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER,
|
||||
RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1,
|
||||
ONE_DEC_UNIT);
|
||||
publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION,
|
||||
RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT);
|
||||
|
||||
publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT);
|
||||
publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,55 @@ enum GrowattProtocolVersion {
|
||||
RTU2,
|
||||
};
|
||||
|
||||
// Register addresses for the RTU protocol.
|
||||
constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1
|
||||
constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2
|
||||
constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1
|
||||
constexpr size_t RTU_PV1_CURRENT = 4; // length = 1
|
||||
constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2
|
||||
constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1
|
||||
constexpr size_t RTU_PV2_CURRENT = 8; // length = 1
|
||||
constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2
|
||||
constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2
|
||||
constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1
|
||||
constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1
|
||||
constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1
|
||||
constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2
|
||||
constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1
|
||||
constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1
|
||||
constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2
|
||||
constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1
|
||||
constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1
|
||||
constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2
|
||||
constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2
|
||||
constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2
|
||||
constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1
|
||||
|
||||
// Input register addresses for the RTU2 protocol as described
|
||||
// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document.
|
||||
constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1
|
||||
constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2
|
||||
constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1
|
||||
constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1
|
||||
constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2
|
||||
constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1
|
||||
constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1
|
||||
constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2
|
||||
constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2
|
||||
constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1
|
||||
constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1
|
||||
constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1
|
||||
constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2
|
||||
constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1
|
||||
constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1
|
||||
constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2
|
||||
constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1
|
||||
constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1
|
||||
constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2
|
||||
constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2
|
||||
constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
|
||||
constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
|
||||
|
||||
class GrowattSolar : public PollingComponent, public modbus::ModbusDevice {
|
||||
public:
|
||||
void loop() override;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time as time_
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.const import CONF_ID, CONF_TIMEZONE
|
||||
|
||||
from .. import homeassistant_ns
|
||||
|
||||
@@ -21,3 +21,5 @@ async def to_code(config):
|
||||
await time_.register_time(var, config)
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_HOMEASSISTANT_TIME")
|
||||
if CONF_TIMEZONE not in config:
|
||||
cg.add_define("USE_HOMEASSISTANT_TIMEZONE")
|
||||
|
||||
@@ -14,7 +14,7 @@ from esphome.core import CORE
|
||||
from .const import KEY_HOST
|
||||
|
||||
# force import gpio to register pin schema
|
||||
from .gpio import host_pin_to_code # noqa
|
||||
from .gpio import host_pin_to_code # noqa: F401
|
||||
|
||||
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
|
||||
AUTO_LOAD = ["network", "preferences"]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32
|
||||
@@ -63,7 +65,7 @@ CONF_JSON = "json"
|
||||
|
||||
def validate_url(value):
|
||||
value = cv.url(value)
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return value
|
||||
raise cv.Invalid("URL must start with 'http://' or 'https://'")
|
||||
|
||||
@@ -174,7 +176,7 @@ async def to_code(config):
|
||||
|
||||
if config.get(CONF_VERIFY_SSL):
|
||||
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
with open(ca_cert_path, encoding="utf-8") as f:
|
||||
with Path(ca_cert_path).open(encoding="utf-8") as f:
|
||||
ca_cert_content = f.read()
|
||||
cg.add(var.set_ca_certificate(ca_cert_content))
|
||||
else:
|
||||
|
||||
@@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_MD5): cv.templatable(
|
||||
cv.All(cv.string, cv.Length(min=32, max=32))
|
||||
),
|
||||
cv.Optional(CONF_PASSWORD): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)),
|
||||
cv.Optional(CONF_USERNAME): cv.templatable(cv.string),
|
||||
cv.Required(CONF_URL): cv.templatable(cv.url),
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ def i2s_audio_component_schema(
|
||||
min=1
|
||||
),
|
||||
cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All(
|
||||
_validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE)
|
||||
_validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE)
|
||||
),
|
||||
cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of(
|
||||
*I2S_MODE_OPTIONS, lower=True
|
||||
|
||||
@@ -98,11 +98,19 @@ def _set_stream_limits(config):
|
||||
min_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
max_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
elif config[CONF_I2S_MODE] == CONF_PRIMARY:
|
||||
# Primary mode has modifiable stream settings
|
||||
return config
|
||||
|
||||
# The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest
|
||||
# stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit.
|
||||
min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8
|
||||
|
||||
if config[CONF_I2S_MODE] == CONF_PRIMARY:
|
||||
# Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the
|
||||
# configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the
|
||||
# slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum.
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=8,
|
||||
max_bits_per_sample=32,
|
||||
min_bits_per_sample=min_bits_per_sample,
|
||||
max_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
|
||||
min_channels=1,
|
||||
max_channels=2,
|
||||
min_sample_rate=16000,
|
||||
@@ -111,13 +119,13 @@ def _set_stream_limits(config):
|
||||
else:
|
||||
# Secondary mode has unmodifiable max bits per sample and min/max sample rates
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=8,
|
||||
max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE),
|
||||
min_bits_per_sample=min_bits_per_sample,
|
||||
max_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
|
||||
min_channels=1,
|
||||
max_channels=2,
|
||||
min_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
max_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
@@ -134,12 +142,11 @@ def _validate_esp32_variant(config):
|
||||
if config[CONF_DAC_TYPE] == "internal":
|
||||
if variant not in INTERNAL_DAC_VARIANTS:
|
||||
raise cv.Invalid(f"{variant} does not have an internal DAC")
|
||||
elif (
|
||||
variant == esp32.VARIANT_ESP32
|
||||
and config.get(CONF_BITS_PER_SAMPLE) == 8
|
||||
and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT)
|
||||
):
|
||||
raise cv.Invalid("8-bit mono mode is not supported on ESP32")
|
||||
elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8:
|
||||
# The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an
|
||||
# 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it
|
||||
# at config time rather than emitting corrupted output at runtime.
|
||||
raise cv.Invalid("8-bit audio is not supported on the original ESP32")
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/i2s_std.h>
|
||||
#include <hal/dma_types.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/components/audio/audio_transfer_buffer.h"
|
||||
@@ -16,8 +17,16 @@ namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10;
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 5;
|
||||
// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in
|
||||
// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the
|
||||
// speaker task reads the size actually allocated back from the driver rather than relying on this value.
|
||||
#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE
|
||||
static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED;
|
||||
#else
|
||||
static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED;
|
||||
#endif
|
||||
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
|
||||
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
|
||||
// invariant between i2s_event_queue_ and write_records_queue_).
|
||||
@@ -27,6 +36,17 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
|
||||
// without masking real failures.
|
||||
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
|
||||
|
||||
// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF
|
||||
// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust
|
||||
// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back
|
||||
// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a
|
||||
// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths.
|
||||
static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) {
|
||||
const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1);
|
||||
return std::min(frames_from_duration, max_frames);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
I2SAudioSpeakerBase::dump_config();
|
||||
const char *fmt_str;
|
||||
@@ -57,8 +77,21 @@ void I2SAudioSpeaker::run_speaker_task() {
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
|
||||
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
|
||||
// ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it
|
||||
// clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through
|
||||
// the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually
|
||||
// allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is
|
||||
// in the READY state here because start_i2s_driver() initialized it before this task was created.
|
||||
size_t dma_buffer_bytes;
|
||||
i2s_chan_info_t chan_info;
|
||||
if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) {
|
||||
// total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count.
|
||||
dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT;
|
||||
} else {
|
||||
// Should not happen for a READY channel; fall back to the requested size.
|
||||
dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_));
|
||||
}
|
||||
const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes);
|
||||
|
||||
bool successful_setup = false;
|
||||
|
||||
@@ -308,12 +341,24 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample
|
||||
// occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples
|
||||
// tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple
|
||||
// of 16, so reject anything else rather than emit corrupted audio.
|
||||
if (audio_stream_info.get_bits_per_sample() % 16 != 0) {
|
||||
ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit",
|
||||
(unsigned) audio_stream_info.get_bits_per_sample());
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent bus is busy");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info);
|
||||
|
||||
i2s_role_t i2s_role = this->i2s_role_;
|
||||
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
|
||||
|
||||
@@ -395,7 +395,7 @@ def download_image(value):
|
||||
def is_svg_file(file):
|
||||
if not file:
|
||||
return False
|
||||
with open(file, "rb") as f:
|
||||
with Path(file).open("rb") as f:
|
||||
return "<svg" in str(f.read(1024))
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ def validate_file_shorthand(value):
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
|
||||
value = cv.file_(value)
|
||||
|
||||
@@ -53,7 +53,11 @@ static_assert(
|
||||
"re-evaluate for this target");
|
||||
|
||||
static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) {
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
|
||||
auto *hw = LEDC_LL_GET_HW(0);
|
||||
#else
|
||||
auto *hw = LEDC_LL_GET_HW();
|
||||
#endif
|
||||
return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0;
|
||||
}
|
||||
#endif
|
||||
@@ -161,7 +165,9 @@ void LEDCOutput::write_state(float state) {
|
||||
void LEDCOutput::setup() {
|
||||
if (!ledc_peripheral_reset_done) {
|
||||
ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot");
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
|
||||
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
|
||||
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
PERIPH_RCC_ATOMIC() {
|
||||
ledc_ll_enable_reset_reg(true);
|
||||
ledc_ll_enable_reset_reg(false);
|
||||
|
||||
@@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa
|
||||
from . import gpio # noqa: F401
|
||||
from .const import (
|
||||
COMPONENT_BK72XX,
|
||||
CONF_GPIO_RECOVER,
|
||||
@@ -513,13 +513,13 @@ async def component_to_code(config):
|
||||
|
||||
# apply LibreTiny options from framework: block
|
||||
# setup LT logger to work nicely with ESPHome logger
|
||||
lt_options = dict(
|
||||
LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL],
|
||||
LT_LOGGER_CALLER=0,
|
||||
LT_LOGGER_TASK=0,
|
||||
LT_LOGGER_COLOR=1,
|
||||
LT_USE_TIME=1,
|
||||
)
|
||||
lt_options = {
|
||||
"LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL],
|
||||
"LT_LOGGER_CALLER": 0,
|
||||
"LT_LOGGER_TASK": 0,
|
||||
"LT_LOGGER_COLOR": 1,
|
||||
"LT_USE_TIME": 1,
|
||||
}
|
||||
# enable/disable per-module debugging
|
||||
for module in framework[CONF_DEBUG]:
|
||||
if module == "NONE":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Kuba Szczodrzyński 2023-06-01.
|
||||
|
||||
# pylint: skip-file
|
||||
# flake8: noqa
|
||||
# ruff: noqa: C408, I001
|
||||
|
||||
import json
|
||||
import re
|
||||
@@ -313,8 +313,12 @@ def write_const(
|
||||
# build component constants
|
||||
comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components)
|
||||
# replace the 2nd regex group only
|
||||
repl = lambda m: m.group(1) + comp_str + m.group(3)
|
||||
code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
|
||||
code = re.sub(
|
||||
comp_regex,
|
||||
lambda m: m.group(1) + comp_str + m.group(3),
|
||||
code,
|
||||
flags=re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
|
||||
# regex for finding the family list block
|
||||
fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)"
|
||||
@@ -337,8 +341,12 @@ def write_const(
|
||||
]
|
||||
var_str = "\n".join(fam_lines)
|
||||
# replace the 2nd regex group only
|
||||
repl = lambda m: m.group(1) + var_str + m.group(3)
|
||||
code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
|
||||
code = re.sub(
|
||||
fam_regex,
|
||||
lambda m: m.group(1) + var_str + m.group(3),
|
||||
code,
|
||||
flags=re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
|
||||
# format with black
|
||||
code = format_str(code, mode=FileMode())
|
||||
|
||||
@@ -11,11 +11,19 @@
|
||||
#include "esphome/core/time_64.h"
|
||||
|
||||
// IRAM_ATTR places a function in executable RAM so it is callable from an
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
|
||||
// Each family uses a section its stock linker already routes to RAM:
|
||||
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
|
||||
// exception: its stock linker has no matching glob, so patch_linker.py
|
||||
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
|
||||
// LibreTiny families that need it share the same .sram.text input section
|
||||
// name; how that section is routed into RAM differs per family:
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
|
||||
// top of .ram_image2.data (which IS in ltchiptool's
|
||||
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
|
||||
// in .ram_image2.text but that output section is NOT in
|
||||
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
|
||||
// there is dropped from the flashed binary.
|
||||
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
|
||||
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
|
||||
//
|
||||
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
|
||||
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
|
||||
@@ -26,13 +34,7 @@
|
||||
// layer.
|
||||
#if defined(USE_BK72XX)
|
||||
#define IRAM_ATTR
|
||||
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
|
||||
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
|
||||
#else
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// LN882H: patch_linker.py.script injects *(.sram.text*) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
|
||||
#endif
|
||||
#define PROGMEM
|
||||
|
||||
@@ -6,12 +6,18 @@ import re
|
||||
import subprocess
|
||||
|
||||
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h).
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h). The
|
||||
# input section name is always .sram.text; only the output section it lands
|
||||
# in differs per family.
|
||||
#
|
||||
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
|
||||
# masks FIQ+IRQ around flash writes). On the remaining families:
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
|
||||
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
|
||||
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
|
||||
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
|
||||
# .ram_image2.text in sections_ram, so code there is silently dropped from
|
||||
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
|
||||
# .ram_image2.data (which IS extracted) instead.
|
||||
# - LN882H: stock linker has no glob for ".sram.text", so we inject
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
|
||||
# immediately after KEEP(*(.vectors)), so the vector table stays at
|
||||
@@ -34,6 +40,20 @@ _KEEP_LINE = (
|
||||
# aligned address; injecting before the vectors would push them to an
|
||||
# unaligned offset and mis-route every IRQ handler.
|
||||
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
|
||||
# Inject at the top of .ram_image2.data, before __data_start__ so our code
|
||||
# does not fall inside the data range markers. .ram_image2.data is one of the
|
||||
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
|
||||
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
|
||||
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
|
||||
#
|
||||
# The regex is intentionally strict (no attribute / ALIGN between the section
|
||||
# name and the opening brace, brace on its own line). If a future AmbZ SDK
|
||||
# linker template changes this format, _pre_link raises RuntimeError on the
|
||||
# unpatched .ld file(s), and the RTL8710B CI compile job in
|
||||
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
|
||||
# rather than silently shipping a binary with IRAM_ATTR code dropped from
|
||||
# one or both OTA slots.
|
||||
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -71,12 +91,11 @@ def _inject_keep(host_section):
|
||||
|
||||
|
||||
# Variants not listed here intentionally have no .ld patcher:
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
|
||||
# already routes into .ram_image2.text (> BD_RAM).
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*).
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
|
||||
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
|
||||
_PATCHERS_BY_VARIANT = {
|
||||
"LN882H": (_inject_keep(_LN_COPY),),
|
||||
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
|
||||
}
|
||||
|
||||
|
||||
@@ -87,13 +106,14 @@ def _patchers_for(variant):
|
||||
def _pre_link(target, source, env):
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
|
||||
patched = 0
|
||||
patched = []
|
||||
unpatched = []
|
||||
for name in ld_files:
|
||||
path = os.path.join(build_dir, name)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
original = fh.read()
|
||||
if _MARKER in original:
|
||||
patched += 1
|
||||
patched.append(name)
|
||||
continue
|
||||
content = original
|
||||
for fn in _patchers:
|
||||
@@ -102,7 +122,9 @@ def _pre_link(target, source, env):
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
|
||||
patched += 1
|
||||
patched.append(name)
|
||||
else:
|
||||
unpatched.append(name)
|
||||
if not patched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
|
||||
@@ -110,6 +132,20 @@ def _pre_link(target, source, env):
|
||||
build_dir
|
||||
)
|
||||
)
|
||||
# Every .ld in the build must be patched. RTL8710B generates one .ld per
|
||||
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
|
||||
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
|
||||
# boot after an OTA into that slot.
|
||||
if unpatched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
|
||||
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
|
||||
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
|
||||
"these. Update the regex to cover all linker scripts.".format(
|
||||
len(unpatched), len(ld_files), build_dir,
|
||||
", ".join(unpatched), _variant,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Substrings matched against demangled names as a fallback on RTL8720C,
|
||||
|
||||
@@ -58,7 +58,7 @@ from .effects import (
|
||||
RGB_EFFECTS,
|
||||
validate_effects,
|
||||
)
|
||||
from .types import ( # noqa
|
||||
from .types import ( # noqa: F401
|
||||
AddressableLight,
|
||||
AddressableLightState,
|
||||
ColorMode,
|
||||
|
||||
@@ -11,9 +11,12 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32C61,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32H4,
|
||||
VARIANT_ESP32H21,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
add_idf_sdkconfig_option,
|
||||
get_esp32_variant,
|
||||
require_usb_serial_jtag_secondary,
|
||||
@@ -113,9 +116,12 @@ UART_SELECTION_ESP32 = {
|
||||
VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32S2: [UART0, UART1, USB_CDC],
|
||||
VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG],
|
||||
}
|
||||
|
||||
UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1]
|
||||
@@ -270,9 +276,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
esp32_c6=USB_SERIAL_JTAG,
|
||||
esp32_c61=USB_SERIAL_JTAG,
|
||||
esp32_h2=USB_SERIAL_JTAG,
|
||||
esp32_h4=USB_SERIAL_JTAG,
|
||||
esp32_h21=USB_SERIAL_JTAG,
|
||||
esp32_p4=USB_SERIAL_JTAG,
|
||||
esp32_s2=USB_CDC,
|
||||
esp32_s3=USB_SERIAL_JTAG,
|
||||
esp32_s31=USB_SERIAL_JTAG,
|
||||
rp2040=USB_CDC,
|
||||
bk72xx=DEFAULT,
|
||||
ln882x=DEFAULT,
|
||||
@@ -514,7 +523,7 @@ def validate_printf(value):
|
||||
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
|
||||
[cCdiouxXeEfgGaAnpsSZ] # type
|
||||
)
|
||||
""" # noqa
|
||||
"""
|
||||
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
|
||||
if len(matches) != len(value[CONF_ARGS]):
|
||||
raise cv.Invalid(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
@@ -79,7 +80,7 @@ from .schemas import (
|
||||
WIDGET_TYPES,
|
||||
any_widget_schema,
|
||||
container_schema,
|
||||
obj_schema,
|
||||
obj_dict,
|
||||
)
|
||||
from .styles import styles_to_code, theme_to_code
|
||||
from .touchscreens import touchscreen_schema, touchscreens_to_code
|
||||
@@ -173,7 +174,7 @@ def generate_lv_conf_h():
|
||||
if clashes:
|
||||
LOGGER.warning(
|
||||
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
|
||||
sorted(list(clashes)),
|
||||
sorted(clashes),
|
||||
)
|
||||
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
|
||||
|
||||
@@ -518,16 +519,32 @@ def add_hello_world(config):
|
||||
return config
|
||||
|
||||
|
||||
def _theme_schema(value):
|
||||
@functools.cache
|
||||
def _build_theme_schema(
|
||||
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
|
||||
) -> cv.Schema:
|
||||
# The theme schema is value-independent: it depends only on the set of
|
||||
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
|
||||
# that an external component registering a new widget after the first
|
||||
# validation (legal per any_widget_schema's lazy-evaluation contract)
|
||||
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
|
||||
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
|
||||
# schemas.py for why chained .extend() is avoided here.
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
|
||||
for name, w in WIDGET_TYPES.items()
|
||||
cv.Optional(name): cv.Schema(
|
||||
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
|
||||
)
|
||||
for name, w in widget_types
|
||||
},
|
||||
}
|
||||
)(value)
|
||||
)
|
||||
|
||||
|
||||
def _theme_schema(value: dict) -> dict:
|
||||
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
@@ -335,7 +335,7 @@ TYPE_NONE = "none"
|
||||
|
||||
DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP")
|
||||
|
||||
LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [
|
||||
LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [
|
||||
"dejavu_16_persian_hebrew",
|
||||
"simsun_16_cjk",
|
||||
"unscii_8",
|
||||
|
||||
@@ -6,7 +6,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT
|
||||
CONF_IF_NAN = "if_nan"
|
||||
|
||||
|
||||
# noqa
|
||||
f_regex = re.compile(
|
||||
r"""
|
||||
( # start of capture group 1
|
||||
@@ -20,7 +19,6 @@ f_regex = re.compile(
|
||||
""",
|
||||
flags=re.VERBOSE,
|
||||
)
|
||||
# noqa
|
||||
c_regex = re.compile(
|
||||
r"""
|
||||
( # start of capture group 1
|
||||
|
||||
@@ -239,7 +239,7 @@ def color_retmapper(value):
|
||||
else:
|
||||
r, g, b, _ = from_rgbw(cval)
|
||||
return literal(f"lv_color_make({r}, {g}, {b})")
|
||||
assert False
|
||||
raise AssertionError(f"Unhandled lv_color value: {value!r}")
|
||||
|
||||
|
||||
def option_string(value):
|
||||
|
||||
@@ -22,6 +22,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core.config import StartupTrigger
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
|
||||
from . import defines as df, lv_validation as lvalid
|
||||
from .defines import (
|
||||
@@ -378,18 +379,63 @@ TRIGGER_EVENT_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def part_schema(parts):
|
||||
def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]:
|
||||
"""
|
||||
Return the raw mapping used by part_schema, so callers can merge it into a
|
||||
larger dict and avoid chained .extend() calls (each .extend() recompiles the
|
||||
whole mapping, turning the build into O(N^2)).
|
||||
|
||||
Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the
|
||||
nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and
|
||||
required=False and must not register any add_extra/prepend_extra
|
||||
validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps
|
||||
only the mapping; non-default extra/required and any _extra_schemas would
|
||||
be silently dropped.
|
||||
"""
|
||||
return {
|
||||
**STATE_SCHEMA.schema,
|
||||
**FLAG_SCHEMA.schema,
|
||||
**{cv.Optional(part): STATE_SCHEMA for part in parts},
|
||||
}
|
||||
|
||||
|
||||
def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema:
|
||||
"""
|
||||
Generate a schema for the various parts (e.g. main:, indicator:) of a widget type
|
||||
:param parts: The parts to include
|
||||
:return: The schema
|
||||
"""
|
||||
return STATE_SCHEMA.extend(FLAG_SCHEMA).extend(
|
||||
{cv.Optional(part): STATE_SCHEMA for part in parts}
|
||||
)
|
||||
return cv.Schema(part_dict(parts))
|
||||
|
||||
|
||||
def automation_schema(typ: LvType):
|
||||
def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]:
|
||||
"""Return a validator that defers building the validate_automation schema.
|
||||
|
||||
validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which
|
||||
voluptuous compiles eagerly. automation_schema() builds ~60 of these per
|
||||
widget type, and the vast majority of slots are never invoked by a given
|
||||
user config. Deferring the build to first use removes that work from
|
||||
schema-construction time.
|
||||
|
||||
When EnableSchemaExtraction is set (build_language_schema.py), fall back
|
||||
to eager construction so the @schema_extractor("automation") decoration
|
||||
inside validate_automation is registered.
|
||||
"""
|
||||
if EnableSchemaExtraction:
|
||||
return validate_automation(extra_schema)
|
||||
|
||||
cached: Callable[[Any], Any] | None = None
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
nonlocal cached
|
||||
if cached is None:
|
||||
cached = validate_automation(extra_schema)
|
||||
return cached(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def automation_schema(typ: LvType) -> dict[Any, Any]:
|
||||
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
|
||||
if typ.has_on_value:
|
||||
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
|
||||
@@ -404,7 +450,7 @@ def automation_schema(typ: LvType):
|
||||
|
||||
return {
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
cv.Optional(event): _lazy_validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(*get_trigger_args(event))
|
||||
@@ -413,7 +459,7 @@ def automation_schema(typ: LvType):
|
||||
)
|
||||
for event in events
|
||||
},
|
||||
cv.Optional(CONF_ON_BOOT): validate_automation(
|
||||
cv.Optional(CONF_ON_BOOT): _lazy_validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)}
|
||||
),
|
||||
}
|
||||
@@ -462,23 +508,62 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
|
||||
return schema
|
||||
|
||||
|
||||
def obj_schema(widget_type: WidgetType):
|
||||
# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema().
|
||||
# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on
|
||||
# every call, so without this cache _theme_schema pays that cost per widget
|
||||
# per validation. Callers must treat the returned dict as immutable. The
|
||||
# _theme_schema caller spreads it into a fresh dict, which is safe; the
|
||||
# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores
|
||||
# the mapping by reference but never mutates it (.extend() copies first), so
|
||||
# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema
|
||||
# would corrupt this cache.
|
||||
_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {}
|
||||
|
||||
|
||||
def obj_dict(widget_type: WidgetType) -> dict[Any, Any]:
|
||||
"""
|
||||
Return the raw mapping used by obj_schema, so callers can merge it into a
|
||||
larger dict and avoid chained .extend() calls.
|
||||
|
||||
Inherits the same source-schema invariant documented on part_dict: any
|
||||
schema spread into this mapping must use the default extra=PREVENT_EXTRA
|
||||
and required=False and must carry no add_extra/prepend_extra validators.
|
||||
|
||||
The returned mapping is cached and must be treated as immutable by callers.
|
||||
"""
|
||||
cached = _OBJ_DICT_CACHE.get(id(widget_type))
|
||||
if cached is not None and cached[0] is widget_type:
|
||||
return cached[1]
|
||||
built = {
|
||||
**part_dict(widget_type.parts),
|
||||
**ALIGN_TO_SCHEMA,
|
||||
**automation_schema(widget_type.w_type),
|
||||
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
|
||||
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
|
||||
}
|
||||
_OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built)
|
||||
return built
|
||||
|
||||
|
||||
# Widget types are module-level singletons populated at import time, so we
|
||||
# can cache compiled obj_schemas by widget_type identity for the lifetime of
|
||||
# the process. The strong reference in the value keeps the key (an id()
|
||||
# target) from being recycled.
|
||||
_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {}
|
||||
|
||||
|
||||
def obj_schema(widget_type: WidgetType) -> cv.Schema:
|
||||
"""
|
||||
Create a schema for a widget type itself i.e. no allowance for children
|
||||
:param widget_type:
|
||||
:return:
|
||||
"""
|
||||
return (
|
||||
part_schema(widget_type.parts)
|
||||
.extend(ALIGN_TO_SCHEMA)
|
||||
.extend(automation_schema(widget_type.w_type))
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
|
||||
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
|
||||
}
|
||||
)
|
||||
)
|
||||
cached = _OBJ_SCHEMA_CACHE.get(id(widget_type))
|
||||
if cached is not None and cached[0] is widget_type:
|
||||
return cached[1]
|
||||
schema = cv.Schema(obj_dict(widget_type))
|
||||
_OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema)
|
||||
return schema
|
||||
|
||||
|
||||
ALIGN_TO_SCHEMA = {
|
||||
|
||||
@@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema(
|
||||
cv.Optional(CONF_START_VALUE): lv_float,
|
||||
cv.Optional(CONF_END_VALUE): lv_float,
|
||||
cv.Optional(CONF_OPA, default=1.0): opacity,
|
||||
cv.Optional(CONF_ROUNDED, default=False): cv.boolean,
|
||||
}
|
||||
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
|
||||
|
||||
@@ -417,7 +418,7 @@ class MeterType(WidgetType):
|
||||
"arc_width": v[CONF_WIDTH],
|
||||
"arc_color": v[CONF_COLOR],
|
||||
"arc_opa": v[CONF_OPA],
|
||||
"arc_rounded": v.get("arc_rounded", False),
|
||||
"arc_rounded": v[CONF_ROUNDED],
|
||||
}
|
||||
if CONF_R_MOD in v:
|
||||
get_warnings().add(
|
||||
|
||||
@@ -97,7 +97,7 @@ class TabviewType(WidgetType):
|
||||
tab_bar = Widget(bar_obj, obj_spec)
|
||||
await set_obj_properties(tab_bar, tab_style)
|
||||
if tab_items_style:
|
||||
for index, tab_conf in enumerate(config[CONF_TABS]):
|
||||
for index, _tab_conf in enumerate(config[CONF_TABS]):
|
||||
await set_obj_properties(
|
||||
Widget(lv_obj.get_child(bar_obj, index), button_spec),
|
||||
tab_items_style,
|
||||
|
||||
@@ -280,5 +280,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
},
|
||||
"mdns_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR},
|
||||
}
|
||||
)
|
||||
|
||||
17
esphome/components/mdns/mdns_zephyr.cpp
Normal file
17
esphome/components/mdns/mdns_zephyr.cpp
Normal file
@@ -0,0 +1,17 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_ZEPHYR) && defined(USE_MDNS)
|
||||
|
||||
#include "mdns_component.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::mdns {
|
||||
|
||||
static const char *const TAG = "mdns.zephyr";
|
||||
|
||||
void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); }
|
||||
|
||||
void MDNSComponent::on_shutdown() {}
|
||||
|
||||
} // namespace esphome::mdns
|
||||
|
||||
#endif // USE_ZEPHYR && USE_MDNS
|
||||
@@ -7,7 +7,7 @@ from urllib.parse import urljoin
|
||||
from esphome import automation, external_files, git
|
||||
from esphome.automation import register_action, register_condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, microphone, ota
|
||||
from esphome.components import esp32, microphone, ota, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FILE,
|
||||
@@ -20,6 +20,7 @@ from esphome.const import (
|
||||
CONF_RAW_DATA_ID,
|
||||
CONF_REF,
|
||||
CONF_REFRESH,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
@@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_VAD): _maybe_empty_vad_schema,
|
||||
cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_MODEL): cv.invalid(
|
||||
f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter."
|
||||
),
|
||||
@@ -374,14 +376,14 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
def _load_model_data(manifest_path: Path):
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
with manifest_path.open(encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
_validate_manifest_version(manifest)
|
||||
|
||||
model_path = manifest_path.parent / manifest[CONF_MODEL]
|
||||
|
||||
with open(model_path, "rb") as f:
|
||||
with model_path.open("rb") as f:
|
||||
model = f.read()
|
||||
|
||||
if manifest.get(KEY_VERSION) == 1:
|
||||
@@ -451,6 +453,10 @@ async def to_code(config):
|
||||
cg.add_define("USE_MICRO_WAKE_WORD")
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
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_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
|
||||
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
|
||||
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
|
||||
|
||||
@@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
|
||||
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
|
||||
|
||||
enum EventGroupBits : uint32_t {
|
||||
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
|
||||
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
|
||||
COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio
|
||||
|
||||
TASK_STARTING = (1 << 3),
|
||||
TASK_RUNNING = (1 << 4),
|
||||
@@ -114,13 +115,13 @@ void MicroWakeWord::setup() {
|
||||
}
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (this->ring_buffer_.use_count() > 1) {
|
||||
size_t bytes_free = temp_ring_buffer->free();
|
||||
|
||||
if (bytes_free < data.size()) {
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER);
|
||||
temp_ring_buffer->reset();
|
||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||
if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) {
|
||||
xEventGroupSetBits(this->event_group_,
|
||||
EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER);
|
||||
}
|
||||
temp_ring_buffer->write((void *) data.data(), data.size());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) {
|
||||
|
||||
{ // Ensures any C++ objects fall out of scope to deallocate before deleting the task
|
||||
|
||||
const size_t new_bytes_to_process =
|
||||
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_);
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> audio_buffer;
|
||||
const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info();
|
||||
const size_t bytes_per_frame = stream_info.frames_to_bytes(1);
|
||||
const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_);
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE];
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
// Allocate audio transfer buffer
|
||||
audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process);
|
||||
|
||||
if (audio_buffer == nullptr) {
|
||||
// Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample.
|
||||
const size_t ring_buffer_size =
|
||||
(stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
} else {
|
||||
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes,
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (audio_source == nullptr) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
} else {
|
||||
this_mww->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
// Allocate ring buffer
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS));
|
||||
if (temp_ring_buffer.use_count() == 0) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
}
|
||||
audio_buffer->set_source(temp_ring_buffer);
|
||||
this_mww->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
this_mww->microphone_source_->start();
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING);
|
||||
|
||||
while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) {
|
||||
audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS));
|
||||
|
||||
if (audio_buffer->available() < new_bytes_to_process) {
|
||||
// Insufficient data to generate new spectrogram features, read more next iteration
|
||||
continue;
|
||||
while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) {
|
||||
if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) {
|
||||
// Producer asked us to drain; run the consumer-side reset from this thread.
|
||||
audio_source->clear_buffered_data();
|
||||
xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER);
|
||||
}
|
||||
|
||||
// Generate new spectrogram features
|
||||
uint32_t processed_samples = this_mww->generate_features_(
|
||||
(int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer);
|
||||
audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t));
|
||||
audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false);
|
||||
|
||||
// Run inference using the new spectorgram features
|
||||
if (!this_mww->update_model_probabilities_(features_buffer)) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
|
||||
break;
|
||||
// The frontend buffers samples internally and only emits a feature once it has a full window, so we can
|
||||
// hand it whatever the source exposes. The frontend consumes at least one sample per call, so available()
|
||||
// strictly decreases and this loop always terminates.
|
||||
while (audio_source->available() >= sizeof(int16_t)) {
|
||||
const size_t samples_available = audio_source->available() / sizeof(int16_t);
|
||||
const int16_t *audio_data = reinterpret_cast<const int16_t *>(audio_source->data());
|
||||
|
||||
size_t processed_samples = 0;
|
||||
const bool feature_generated =
|
||||
this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples);
|
||||
audio_source->consume(processed_samples * sizeof(int16_t));
|
||||
|
||||
if (feature_generated) {
|
||||
if (!this_mww->update_model_probabilities_(features_buffer)) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
|
||||
break;
|
||||
}
|
||||
|
||||
// Process each model's probabilities and possibly send a Detection Event to the queue
|
||||
this_mww->process_probabilities_();
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model's probabilities and possibly send a Detection Event to the queue
|
||||
this_mww->process_probabilities_();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) {
|
||||
FrontendFreeStateContents(&this_mww->frontend_state_);
|
||||
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED);
|
||||
while (true) {
|
||||
// Continuously delay until the main loop deletes the task
|
||||
delay(10);
|
||||
}
|
||||
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
|
||||
}
|
||||
|
||||
std::vector<WakeWordModel *> MicroWakeWord::get_wake_words() {
|
||||
@@ -233,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit
|
||||
#endif
|
||||
|
||||
void MicroWakeWord::suspend_task_() {
|
||||
if (this->inference_task_handle_ != nullptr) {
|
||||
vTaskSuspend(this->inference_task_handle_);
|
||||
if (this->inference_task_.is_created()) {
|
||||
vTaskSuspend(this->inference_task_.get_handle());
|
||||
}
|
||||
}
|
||||
|
||||
void MicroWakeWord::resume_task_() {
|
||||
if (this->inference_task_handle_ != nullptr) {
|
||||
vTaskResume(this->inference_task_handle_);
|
||||
if (this->inference_task_.is_created()) {
|
||||
vTaskResume(this->inference_task_.get_handle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,8 +289,7 @@ void MicroWakeWord::loop() {
|
||||
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||
vTaskDelete(this->inference_task_handle_);
|
||||
this->inference_task_handle_ = nullptr;
|
||||
this->inference_task_.deallocate();
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
xQueueReset(this->detection_queue_);
|
||||
this->set_state_(State::STOPPED);
|
||||
@@ -301,7 +307,7 @@ void MicroWakeWord::loop() {
|
||||
|
||||
switch (this->state_) {
|
||||
case State::STARTING:
|
||||
if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) {
|
||||
if (!this->inference_task_.is_created() && !this->status_has_error()) {
|
||||
// Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it
|
||||
// uses floating point operations.
|
||||
if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_,
|
||||
@@ -310,10 +316,8 @@ void MicroWakeWord::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this,
|
||||
INFERENCE_TASK_PRIORITY, &this->inference_task_handle_);
|
||||
|
||||
if (this->inference_task_handle_ == nullptr) {
|
||||
if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE,
|
||||
(void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) {
|
||||
FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state
|
||||
this->status_momentary_error("task_start", 1000);
|
||||
}
|
||||
@@ -386,11 +390,15 @@ void MicroWakeWord::set_state_(State state) {
|
||||
}
|
||||
}
|
||||
|
||||
size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) {
|
||||
size_t processed_samples = 0;
|
||||
bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) {
|
||||
*processed_samples = 0;
|
||||
struct FrontendOutput frontend_output =
|
||||
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples);
|
||||
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples);
|
||||
|
||||
if (frontend_output.size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < frontend_output.size; ++i) {
|
||||
// These scaling values are set to match the TFLite audio frontend int8 output.
|
||||
@@ -415,7 +423,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a
|
||||
features_buffer[i] = static_cast<int8_t>(clamp<int32_t>(value, INT8_MIN, INT8_MAX));
|
||||
}
|
||||
|
||||
return processed_samples;
|
||||
return true;
|
||||
}
|
||||
|
||||
void MicroWakeWord::process_probabilities_() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
@@ -59,6 +60,8 @@ class MicroWakeWord : public Component
|
||||
|
||||
void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; }
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
Trigger<std::string> *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; }
|
||||
|
||||
void add_wake_word_model(WakeWordModel *model);
|
||||
@@ -93,6 +96,8 @@ class MicroWakeWord : public Component
|
||||
|
||||
bool stop_after_detection_;
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
|
||||
uint8_t features_step_size_;
|
||||
|
||||
// Audio frontend handles generating spectrogram features
|
||||
@@ -105,8 +110,9 @@ class MicroWakeWord : public Component
|
||||
// Used to send messages about the models' states to the main loop
|
||||
QueueHandle_t detection_queue_;
|
||||
|
||||
StaticTask inference_task_;
|
||||
|
||||
static void inference_task(void *params);
|
||||
TaskHandle_t inference_task_handle_{nullptr};
|
||||
|
||||
/// @brief Suspends the inference task
|
||||
void suspend_task_();
|
||||
@@ -115,13 +121,16 @@ class MicroWakeWord : public Component
|
||||
|
||||
void set_state_(State state);
|
||||
|
||||
/// @brief Generates spectrogram features from an input buffer of audio samples
|
||||
/// @param audio_buffer (int16_t *) Buffer containing input audio samples
|
||||
/// @param samples_available (size_t) Number of samples avaiable in the input buffer
|
||||
/// @param features_buffer (int8_t *) Buffer to store generated features
|
||||
/// @return (size_t) Number of samples processed from the input buffer
|
||||
size_t generate_features_(int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]);
|
||||
/// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples
|
||||
/// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have
|
||||
/// accumulated to fill a full analysis window.
|
||||
/// @param audio_buffer (const int16_t *) Buffer containing input audio samples
|
||||
/// @param samples_available (size_t) Number of samples available in the input buffer
|
||||
/// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true
|
||||
/// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer
|
||||
/// @return True if a new feature was generated; false if more samples are required
|
||||
bool generate_features_(const int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples);
|
||||
|
||||
/// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent
|
||||
/// to the detection_queue_.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
namespace esphome::midea::ac {
|
||||
|
||||
const char *const Constants::TAG = "midea";
|
||||
const char *const Constants::FREEZE_PROTECTION = "freeze protection";
|
||||
const char *const Constants::SILENT = "silent";
|
||||
const char *const Constants::TURBO = "turbo";
|
||||
const char *const Constants::FREEZE_PROTECTION = "Freeze Protection";
|
||||
const char *const Constants::SILENT = "Silent";
|
||||
const char *const Constants::TURBO = "Turbo";
|
||||
|
||||
ClimateMode Converters::to_climate_mode(MideaMode mode) {
|
||||
switch (mode) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, uart
|
||||
from esphome.components.climate import validate_climate_swing_mode
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_SUPPORTED_SWING_MODES,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
@@ -43,6 +49,9 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(
|
||||
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s"
|
||||
): cv.update_interval,
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
): validate_climate_swing_mode,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -63,6 +72,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = await climate.new_climate(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES]))
|
||||
cg.add(
|
||||
var.set_current_temperature_min_interval(
|
||||
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/finite_set_mask.h"
|
||||
|
||||
@@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
traits.add_supported_fan_mode(p.second);
|
||||
}
|
||||
|
||||
traits.set_supported_swing_modes(this->supported_swing_modes_);
|
||||
|
||||
traits.set_visual_min_temperature(16.0f);
|
||||
traits.set_visual_max_temperature(31.0f);
|
||||
traits.set_visual_temperature_step(1.0f);
|
||||
@@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
this->hp_.set_fan_mode(*fan_mode);
|
||||
}
|
||||
|
||||
if (const auto swing_mode = call.get_swing_mode()) {
|
||||
auto vane = this->last_non_swing_vane_mode_;
|
||||
auto wide = this->last_non_swing_wide_vane_mode_;
|
||||
|
||||
switch (*swing_mode) {
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
this->hp_.set_vane_mode(vane);
|
||||
}
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
this->hp_.set_wide_vane_mode(wide);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->hp_.is_status_initialized()) {
|
||||
this->apply_values_();
|
||||
}
|
||||
@@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() {
|
||||
ESP_LOGD(TAG, "Unable to map fan mode");
|
||||
}
|
||||
|
||||
if (!this->supported_swing_modes_.empty()) {
|
||||
bool vertical_swinging = false;
|
||||
bool horizontal_swinging = false;
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
|
||||
vertical_swinging = true;
|
||||
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
|
||||
this->last_non_swing_vane_mode_ = status.vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
|
||||
horizontal_swinging = true;
|
||||
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
|
||||
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertical_swinging && horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_BOTH;
|
||||
} else if (vertical_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
} else if (horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
||||
} else {
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
|
||||
this->supported_swing_modes_.clear();
|
||||
switch (mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
|
||||
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
|
||||
|
||||
void set_supported_swing_mode(climate::ClimateSwingMode mode);
|
||||
|
||||
protected:
|
||||
void apply_values_();
|
||||
|
||||
MitsubishiCN105 hp_;
|
||||
climate::ClimateSwingModeMask supported_swing_modes_{};
|
||||
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
|
||||
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, speaker
|
||||
from esphome.components import audio, psram, speaker
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
@@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.one_of(CONF_NEVER, lower=True),
|
||||
),
|
||||
cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _set_stream_limits(config):
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_source_speaker(config):
|
||||
fconf = fv.full_config.get()
|
||||
|
||||
@@ -67,15 +57,25 @@ def _validate_source_speaker(config):
|
||||
output_speaker_id = fconf.get_config_for_path(path)
|
||||
config[CONF_OUTPUT_SPEAKER] = output_speaker_id
|
||||
|
||||
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config)
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config)
|
||||
inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config)
|
||||
|
||||
audio.final_validate_audio_schema(
|
||||
"mixer",
|
||||
audio_device=CONF_OUTPUT_SPEAKER,
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_output_speaker(config):
|
||||
audio.final_validate_audio_schema(
|
||||
"mixer",
|
||||
audio_device=CONF_OUTPUT_SPEAKER,
|
||||
bits_per_sample=config.get(CONF_BITS_PER_SAMPLE),
|
||||
channels=config.get(CONF_NUM_CHANNELS),
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
@@ -89,24 +89,26 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Required(CONF_SOURCE_SPEAKERS): cv.All(
|
||||
cv.ensure_list(SOURCE_SPEAKER_SCHEMA),
|
||||
cv.Length(min=2, max=8),
|
||||
[_set_stream_limits],
|
||||
),
|
||||
cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True),
|
||||
cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2),
|
||||
cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
),
|
||||
cv.only_on([PLATFORM_ESP32]),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER),
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker],
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
),
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
|
||||
_validate_output_speaker,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,16 +118,14 @@ async def to_code(config):
|
||||
|
||||
spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER])
|
||||
|
||||
cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
|
||||
cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS]))
|
||||
cg.add(var.set_output_speaker(spkr))
|
||||
cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE]))
|
||||
|
||||
if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(task_stack_in_psram))
|
||||
if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
|
||||
# Initialize FixedVector with exact count of source speakers
|
||||
cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS])))
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <mixer.h> // esp-audio-libs
|
||||
#include <pcm_convert.h> // esp-audio-libs
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::mixer_speaker {
|
||||
@@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200;
|
||||
|
||||
static const size_t TASK_STACK_SIZE = 4096;
|
||||
|
||||
static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX;
|
||||
static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN;
|
||||
|
||||
static const char *const TAG = "speaker_mixer";
|
||||
|
||||
// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB
|
||||
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
|
||||
// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15)
|
||||
static const std::array<int16_t, 51> DECIBEL_REDUCTION_TABLE = {
|
||||
32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183,
|
||||
4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731,
|
||||
651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103};
|
||||
|
||||
// Event bits for SourceSpeaker command processing
|
||||
enum SourceSpeakerEventBits : uint32_t {
|
||||
SOURCE_SPEAKER_COMMAND_START = (1 << 0),
|
||||
@@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBuffer
|
||||
|
||||
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
|
||||
if (samples_to_duck > 0) {
|
||||
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
|
||||
|
||||
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
|
||||
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
|
||||
this->db_change_per_ducking_step_);
|
||||
esp_audio_libs::ducking::apply(audio_source->mutable_data(),
|
||||
static_cast<uint8_t>(this->audio_stream_info_.get_bits_per_sample() / 8),
|
||||
samples_to_duck, this->ducking_state_);
|
||||
}
|
||||
|
||||
return bytes_read;
|
||||
}
|
||||
|
||||
void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) {
|
||||
if (this->target_ducking_db_reduction_ != decibel_reduction) {
|
||||
// Start transition from the previous target (which becomes the new current level)
|
||||
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
|
||||
|
||||
this->target_ducking_db_reduction_ = decibel_reduction;
|
||||
|
||||
// Calculate the number of intermediate dB steps for the transition timing.
|
||||
// Subtract 1 because the first step is taken immediately after this calculation.
|
||||
uint8_t total_ducking_steps = 0;
|
||||
if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) {
|
||||
// The dB reduction level is increasing (which results in quieter audio)
|
||||
total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1;
|
||||
this->db_change_per_ducking_step_ = 1;
|
||||
} else {
|
||||
// The dB reduction level is decreasing (which results in louder audio)
|
||||
total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1;
|
||||
this->db_change_per_ducking_step_ = -1;
|
||||
}
|
||||
if ((duration > 0) && (total_ducking_steps > 0)) {
|
||||
this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration);
|
||||
|
||||
this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps;
|
||||
this->ducking_transition_samples_remaining_ =
|
||||
this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding
|
||||
|
||||
this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_;
|
||||
} else {
|
||||
this->ducking_transition_samples_remaining_ = 0;
|
||||
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck,
|
||||
int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining,
|
||||
uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) {
|
||||
if (*ducking_transition_samples_remaining > 0) {
|
||||
// Ducking level is still transitioning
|
||||
|
||||
// Takes the ceiling of input_samples_to_duck/samples_per_ducking_step
|
||||
uint32_t ducking_steps_in_batch =
|
||||
input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0);
|
||||
|
||||
for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) {
|
||||
uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step;
|
||||
|
||||
if (samples_left_in_step == 0) {
|
||||
samples_left_in_step = samples_per_ducking_step;
|
||||
}
|
||||
|
||||
uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step);
|
||||
samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining);
|
||||
|
||||
// Ensure we only point to valid index in the Q15 scaling factor table
|
||||
uint8_t safe_db_reduction_index =
|
||||
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
|
||||
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
|
||||
|
||||
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck);
|
||||
|
||||
if (samples_left_in_step - samples_to_duck == 0) {
|
||||
// After scaling the current samples, we are ready to transition to the next step
|
||||
*current_ducking_db_reduction += db_change_per_ducking_step;
|
||||
}
|
||||
|
||||
input_buffer += samples_to_duck;
|
||||
*ducking_transition_samples_remaining -= samples_to_duck;
|
||||
input_samples_to_duck -= samples_to_duck;
|
||||
}
|
||||
}
|
||||
|
||||
if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) {
|
||||
// Audio is ducked, but its not in the middle of a transition step
|
||||
|
||||
uint8_t safe_db_reduction_index =
|
||||
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
|
||||
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
|
||||
|
||||
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck);
|
||||
}
|
||||
const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0;
|
||||
esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples);
|
||||
}
|
||||
|
||||
void SourceSpeaker::enter_stopping_state_() {
|
||||
@@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() {
|
||||
void MixerSpeaker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Speaker Mixer:\n"
|
||||
" Number of output channels: %u",
|
||||
this->output_channels_);
|
||||
" Number of output channels: %" PRIu8 "\n"
|
||||
" Output bits per sample: %" PRIu8,
|
||||
this->output_channels_, this->output_bits_per_sample_);
|
||||
}
|
||||
|
||||
void MixerSpeaker::setup() {
|
||||
@@ -512,13 +424,8 @@ void MixerSpeaker::loop() {
|
||||
|
||||
esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
if (stream_info.get_bits_per_sample() != 16) {
|
||||
// Audio streams that don't have 16 bits per sample are not supported
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_,
|
||||
stream_info.get_sample_rate());
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate());
|
||||
this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value());
|
||||
} else {
|
||||
if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) {
|
||||
@@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_transfer) {
|
||||
uint8_t input_channels = input_stream_info.get_channels();
|
||||
uint8_t output_channels = output_stream_info.get_channels();
|
||||
const uint8_t max_input_channel_index = input_channels - 1;
|
||||
|
||||
if (input_channels == output_channels) {
|
||||
size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer);
|
||||
memcpy(output_buffer, input_buffer, bytes_to_copy);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) {
|
||||
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
|
||||
uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index);
|
||||
output_buffer[output_channels * frame_index + output_channel_index] =
|
||||
input_buffer[input_channels * frame_index + input_channel_index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
|
||||
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_mix) {
|
||||
const uint8_t primary_channels = primary_stream_info.get_channels();
|
||||
const uint8_t secondary_channels = secondary_stream_info.get_channels();
|
||||
const uint8_t output_channels = output_stream_info.get_channels();
|
||||
|
||||
const uint8_t max_primary_channel_index = primary_channels - 1;
|
||||
const uint8_t max_secondary_channel_index = secondary_channels - 1;
|
||||
|
||||
for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) {
|
||||
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
|
||||
const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index);
|
||||
const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index];
|
||||
|
||||
const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index);
|
||||
const int32_t primary_sample =
|
||||
static_cast<int32_t>(primary_buffer[frames_index * primary_channels + primary_channel_index]);
|
||||
|
||||
const int32_t added_sample = secondary_sample + primary_sample;
|
||||
|
||||
output_buffer[frames_index * output_channels + output_channel_index] =
|
||||
static_cast<int16_t>(clamp<int32_t>(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created
|
||||
void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
MixerSpeaker *this_mixer = static_cast<MixerSpeaker *>(params);
|
||||
@@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
uint32_t frames_to_mix = output_frames_free;
|
||||
|
||||
const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value();
|
||||
const uint8_t output_bps = output_info.get_bits_per_sample() / 8;
|
||||
const uint8_t output_channels = output_info.get_channels();
|
||||
|
||||
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
|
||||
// Only one speaker has audio data, just copy samples over
|
||||
|
||||
@@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
if (active_stream_info.get_sample_rate() ==
|
||||
this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) {
|
||||
// Speaker's sample rate matches the output speaker's, copy directly
|
||||
// Speaker's sample rate matches the output speaker's, convert directly into the output buffer
|
||||
|
||||
const uint32_t frames_available_in_buffer =
|
||||
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
|
||||
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
|
||||
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
|
||||
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
|
||||
this_mixer->audio_stream_info_.value(), frames_to_mix);
|
||||
esp_audio_libs::pcm_convert::copy_frames(
|
||||
audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(),
|
||||
static_cast<uint8_t>(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(),
|
||||
output_bps, output_channels, frames_to_mix);
|
||||
|
||||
// Set playback delay for newly contributing source
|
||||
if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) {
|
||||
@@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
|
||||
|
||||
// Update output transfer buffer length and pipeline frame count
|
||||
output_transfer_buffer->increase_buffer_length(
|
||||
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
|
||||
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
|
||||
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
} else {
|
||||
// Speaker's stream info doesn't match the output speaker's, so it's a new source speaker
|
||||
@@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
} else {
|
||||
// Speaker has finished writing the current audio, update the stream information and restart the speaker
|
||||
this_mixer->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_,
|
||||
audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_,
|
||||
active_stream_info.get_sample_rate());
|
||||
this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value());
|
||||
this_mixer->output_speaker_->start();
|
||||
@@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
|
||||
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
|
||||
}
|
||||
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
|
||||
const uint8_t *primary_buffer = audio_sources_with_data[0]->data();
|
||||
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
|
||||
|
||||
// Mix two streams together
|
||||
// Mix two streams together at a time, accumulating into the output buffer.
|
||||
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
|
||||
mix_audio_samples(primary_buffer, primary_stream_info,
|
||||
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
|
||||
speakers_with_data[i]->get_audio_stream_info(),
|
||||
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
|
||||
this_mixer->audio_stream_info_.value(), frames_to_mix);
|
||||
esp_audio_libs::mixer::mix_frames(
|
||||
primary_buffer, static_cast<uint8_t>(primary_stream_info.get_bits_per_sample() / 8),
|
||||
primary_stream_info.get_channels(), audio_sources_with_data[i]->data(),
|
||||
static_cast<uint8_t>(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8),
|
||||
speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(),
|
||||
output_bps, output_channels, frames_to_mix);
|
||||
|
||||
if (i != audio_sources_with_data.size() - 1) {
|
||||
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
|
||||
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
|
||||
primary_stream_info = this_mixer->audio_stream_info_.value();
|
||||
primary_buffer = output_transfer_buffer->get_buffer_end();
|
||||
primary_stream_info = output_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
}
|
||||
|
||||
// Update output transfer buffer length and pipeline frame count (once, not per source)
|
||||
output_transfer_buffer->increase_buffer_length(
|
||||
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
|
||||
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
|
||||
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#include <ducking.h> // esp-audio-libs
|
||||
|
||||
#include <freertos/event_groups.h>
|
||||
|
||||
#include <atomic>
|
||||
@@ -22,7 +24,8 @@ namespace esphome::mixer_speaker {
|
||||
* - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread
|
||||
* safety
|
||||
* - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker.
|
||||
* - Audio sent to the SourceSpeaker must have 16 bits per sample.
|
||||
* - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output
|
||||
* speaker's bit depth as it is mixed (or copied) into the output buffer.
|
||||
* - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match
|
||||
* the number of channels required for the output speaker.
|
||||
* - In queue mode, the audio sent to the SourceSpeakers can have different sample rates.
|
||||
@@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
void enter_stopping_state_();
|
||||
void send_command_(uint32_t command_bit, bool wake_loop = false);
|
||||
|
||||
/// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually
|
||||
/// over a specified amount of samples.
|
||||
/// @param input_buffer buffer with audio samples to be ducked in place
|
||||
/// @param input_samples_to_duck number of samples to process in ``input_buffer``
|
||||
/// @param current_ducking_db_reduction pointer to the current dB reduction
|
||||
/// @param ducking_transition_samples_remaining pointer to the total number of samples left before the
|
||||
/// transition is finished
|
||||
/// @param samples_per_ducking_step total number of samples per ducking step for the transition
|
||||
/// @param db_change_per_ducking_step the change in dB reduction per step
|
||||
static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction,
|
||||
uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step,
|
||||
int8_t db_change_per_ducking_step);
|
||||
|
||||
MixerSpeaker *parent_;
|
||||
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
|
||||
@@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
|
||||
bool pause_state_{false};
|
||||
|
||||
int8_t target_ducking_db_reduction_{0};
|
||||
int8_t current_ducking_db_reduction_{0};
|
||||
int8_t db_change_per_ducking_step_{1};
|
||||
uint32_t ducking_transition_samples_remaining_{0};
|
||||
uint32_t samples_per_ducking_step_{0};
|
||||
esp_audio_libs::ducking::DuckingState ducking_state_{};
|
||||
|
||||
std::atomic<uint32_t> pending_playback_frames_{0};
|
||||
std::atomic<uint32_t> playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing
|
||||
@@ -143,12 +129,14 @@ class MixerSpeaker : public Component {
|
||||
|
||||
/// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information
|
||||
/// @param stream_info The calling source speaker's audio stream information
|
||||
/// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample
|
||||
/// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
|
||||
/// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
|
||||
/// ESP_OK if the incoming stream is compatible and the mixer task starts
|
||||
esp_err_t start(audio::AudioStreamInfo &stream_info);
|
||||
|
||||
void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; }
|
||||
void set_output_bits_per_sample(uint8_t output_bits_per_sample) {
|
||||
this->output_bits_per_sample_ = output_bits_per_sample;
|
||||
}
|
||||
void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; }
|
||||
void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
@@ -159,33 +147,6 @@ class MixerSpeaker : public Component {
|
||||
uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); }
|
||||
|
||||
protected:
|
||||
/// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels
|
||||
/// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has
|
||||
/// less channels, the extra channel input samples are dropped.
|
||||
/// @param input_buffer
|
||||
/// @param input_stream_info
|
||||
/// @param output_buffer
|
||||
/// @param output_stream_info
|
||||
/// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer
|
||||
static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer,
|
||||
audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer);
|
||||
|
||||
/// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary
|
||||
/// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number
|
||||
/// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample
|
||||
/// overflows.
|
||||
/// @param primary_buffer samples buffer for the primary stream
|
||||
/// @param primary_stream_info stream info for the primary stream
|
||||
/// @param secondary_buffer samples buffer for secondary stream
|
||||
/// @param secondary_stream_info stream info for the secondary stream
|
||||
/// @param output_buffer buffer for the mixed samples
|
||||
/// @param output_stream_info stream info for the output buffer
|
||||
/// @param frames_to_mix number of frames in the primary and secondary buffers to mix together
|
||||
static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
|
||||
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_mix);
|
||||
|
||||
static void audio_mixer_task(void *params);
|
||||
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
@@ -193,6 +154,7 @@ class MixerSpeaker : public Component {
|
||||
FixedVector<SourceSpeaker *> source_speakers_;
|
||||
speaker::Speaker *output_speaker_{nullptr};
|
||||
|
||||
uint8_t output_bits_per_sample_;
|
||||
uint8_t output_channels_;
|
||||
bool queue_mode_;
|
||||
bool task_stack_in_psram_{false};
|
||||
|
||||
@@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_PORT, default=1883): cv.port,
|
||||
cv.Optional(CONF_USERNAME, default=""): cv.string,
|
||||
cv.Optional(CONF_PASSWORD, default=""): cv.string,
|
||||
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
|
||||
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
|
||||
cv.Optional(CONF_CLIENT_ID): cv.string,
|
||||
cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All(
|
||||
|
||||
@@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend(
|
||||
),
|
||||
key=CONF_NAME,
|
||||
)
|
||||
for event, icon in zip(EVENT_SENSORS, ICONS)
|
||||
for event, icon in zip(EVENT_SENSORS, ICONS, strict=True)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
@@ -22,6 +24,7 @@ from esphome.const import (
|
||||
Framework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ._methods import (
|
||||
METHOD_BIT_BANG,
|
||||
@@ -34,6 +37,8 @@ from ._methods import (
|
||||
)
|
||||
from .const import CHIP_TYPES, CONF_ASYNC, CONF_BUS, ONE_WIRE_CHIPS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
neopixelbus_ns = cg.esphome_ns.namespace("neopixelbus")
|
||||
NeoPixelBusLightOutputBase = neopixelbus_ns.class_(
|
||||
"NeoPixelBusLightOutputBase", light.AddressableLight
|
||||
@@ -134,6 +139,17 @@ def _validate(config):
|
||||
return config
|
||||
|
||||
|
||||
def _warn_esp32_deprecated(config: ConfigType) -> ConfigType:
|
||||
if CORE.is_esp32:
|
||||
_LOGGER.warning(
|
||||
"'neopixelbus' on ESP32 is deprecated. The upstream library "
|
||||
"(makuna/NeoPixelBus) is no longer actively maintained. Migrate "
|
||||
"to 'esp32_rmt_led_strip'. Removal is targeted for 2027.1 but "
|
||||
"may happen sooner once ESPHome moves to ESP-IDF 6."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_method(value):
|
||||
if value is None:
|
||||
# default method is determined afterwards because it depends on the chip type chosen
|
||||
@@ -195,6 +211,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_choose_default_method,
|
||||
_validate,
|
||||
_warn_esp32_deprecated,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,16 +4,11 @@ import logging
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
|
||||
from esphome.components.zephyr import zephyr_add_prj_conf
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ID,
|
||||
CONF_MIN_IPV6_ADDR_COUNT,
|
||||
CONF_PRIORITY,
|
||||
CONF_TIMEOUT,
|
||||
)
|
||||
from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
AUTO_LOAD = ["mdns"]
|
||||
@@ -25,28 +20,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
|
||||
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
|
||||
|
||||
# Network priority tracking infrastructure
|
||||
# Components can query this to determine their relative setup priority and fallback timeout.
|
||||
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts:
|
||||
# [{"interface": "ethernet", "timeout": 30000}, {"interface": "wifi", "timeout": None}, ...]
|
||||
# where timeout is in milliseconds, or None meaning "start the next interface immediately".
|
||||
KEY_NETWORK_PRIORITY = "network_priority"
|
||||
|
||||
VALID_NETWORK_TYPES = ["ethernet", "openthread", "wifi", "modem"]
|
||||
|
||||
# Setup priority base values — first in list gets the highest priority.
|
||||
#
|
||||
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
|
||||
# (250.0), so a single-entry priority list yields exactly the same setup order
|
||||
# as a config with no priority block. Subsequent entries step down by a small
|
||||
# amount to break ties without crossing other priority bands.
|
||||
#
|
||||
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
|
||||
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
|
||||
# interface could tie with NetworkComponent and run before esp_netif_init().
|
||||
NETWORK_PRIORITY_BASE = 250.0
|
||||
NETWORK_PRIORITY_STEP = 5.0
|
||||
|
||||
network_ns = cg.esphome_ns.namespace("network")
|
||||
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
|
||||
IPAddress = network_ns.class_("IPAddress")
|
||||
@@ -135,155 +108,11 @@ def has_high_performance_networking() -> bool:
|
||||
return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False)
|
||||
|
||||
|
||||
def _get_priority_entry(iface: str) -> dict | None:
|
||||
"""Return the priority entry dict for the given interface, or None if not configured."""
|
||||
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
|
||||
if priority_list is None:
|
||||
return None
|
||||
iface_lower = iface.lower()
|
||||
for entry in priority_list:
|
||||
if entry["interface"] == iface_lower:
|
||||
return entry
|
||||
return None
|
||||
def validate_ipv6(value: bool) -> bool:
|
||||
if CORE.is_nrf52 and not value:
|
||||
raise cv.Invalid("On nRF52, enable_ipv6 must be true")
|
||||
|
||||
|
||||
def get_network_priority(iface: str) -> float | None:
|
||||
"""Get the setup priority for the given network interface type.
|
||||
|
||||
Returns the float setup priority for ``iface`` based on the order declared
|
||||
under ``network: priority:``. Interfaces listed first receive a higher
|
||||
setup priority so they are initialised before lower-priority ones.
|
||||
|
||||
If no ``network: priority:`` has been configured this returns ``None`` and
|
||||
the calling component should fall back to its own default setup priority.
|
||||
|
||||
Args:
|
||||
iface: Interface type string — one of ``"ethernet"``, ``"wifi"``,
|
||||
``"openthread"`` or ``"modem"`` (case-insensitive).
|
||||
|
||||
Returns:
|
||||
float setup priority, or None if no priority list was configured.
|
||||
|
||||
Example usage inside a component's ``to_code``::
|
||||
|
||||
from esphome.components import network
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
prio = network.get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.add(var.set_setup_priority(prio))
|
||||
...
|
||||
"""
|
||||
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
|
||||
if priority_list is None:
|
||||
return None
|
||||
iface_lower = iface.lower()
|
||||
for idx, entry in enumerate(priority_list):
|
||||
if entry["interface"] == iface_lower:
|
||||
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
|
||||
return None
|
||||
|
||||
|
||||
def get_network_timeout(iface: str) -> int | None:
|
||||
"""Get the fallback timeout in milliseconds for the given network interface.
|
||||
|
||||
Returns the timeout (in ms) that the runtime should wait for ``iface`` to
|
||||
connect before attempting to bring up the next interface in the priority
|
||||
list. Returns ``None`` if no timeout was configured for this interface,
|
||||
meaning the next interface should start immediately.
|
||||
|
||||
Args:
|
||||
iface: Interface type string — one of ``"ethernet"``, ``"wifi"``,
|
||||
``"openthread"`` or ``"modem"`` (case-insensitive).
|
||||
|
||||
Returns:
|
||||
int timeout in milliseconds, or None if no timeout is configured.
|
||||
|
||||
Example usage inside a component's ``to_code``::
|
||||
|
||||
from esphome.components import network
|
||||
|
||||
async def to_code(config):
|
||||
...
|
||||
timeout_ms = network.get_network_timeout("ethernet")
|
||||
if timeout_ms is not None:
|
||||
cg.add(var.set_fallback_timeout(timeout_ms))
|
||||
...
|
||||
"""
|
||||
entry = _get_priority_entry(iface)
|
||||
if entry is None:
|
||||
return None
|
||||
return entry.get("timeout")
|
||||
|
||||
|
||||
def _validate_timeout(value):
|
||||
"""Accept any common ESPHome/HA time period format, or a plain integer as seconds.
|
||||
|
||||
Accepted formats: 30s, 10sec, 1min, 500ms, 1h, 1.5h, 30 (plain int → 30s).
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
# Plain integer — treat as seconds, e.g. timeout: 30 means 30s
|
||||
return cv.positive_time_period_milliseconds(f"{value}s")
|
||||
return cv.positive_time_period_milliseconds(value)
|
||||
|
||||
|
||||
def _priority_entry_schema(value):
|
||||
"""Validate a single priority list entry in either plain string or mapping form.
|
||||
|
||||
Plain string form (no timeout):
|
||||
- ethernet
|
||||
|
||||
Mapping form with optional timeout:
|
||||
- ethernet:
|
||||
timeout: 30s
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return cv.one_of(*VALID_NETWORK_TYPES, lower=True)(value)
|
||||
if isinstance(value, dict):
|
||||
if len(value) != 1:
|
||||
raise cv.Invalid(
|
||||
"Each priority entry must have exactly one interface name as its key"
|
||||
)
|
||||
iface = next(iter(value))
|
||||
cv.one_of(*VALID_NETWORK_TYPES, lower=True)(iface)
|
||||
opts = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_TIMEOUT): _validate_timeout,
|
||||
}
|
||||
)(value[iface] or {})
|
||||
return {iface: opts}
|
||||
raise cv.Invalid(
|
||||
f"Expected an interface name string or a mapping, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_priority_entry(value) -> dict:
|
||||
"""Normalize a validated priority entry to a canonical dict.
|
||||
|
||||
Returns a dict with keys:
|
||||
- ``interface``: str, lowercase interface name
|
||||
- ``timeout``: int milliseconds, or None
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return {"interface": value, "timeout": None}
|
||||
# Mapping form — exactly one key (the interface name)
|
||||
iface, opts = next(iter(value.items()))
|
||||
timeout = opts.get(CONF_TIMEOUT)
|
||||
timeout_ms = int(timeout.total_milliseconds) if timeout is not None else None
|
||||
return {"interface": iface, "timeout": timeout_ms}
|
||||
|
||||
|
||||
def _validate_priority_list(value):
|
||||
"""Validate and normalize the full priority list, rejecting duplicates."""
|
||||
raw = cv.ensure_list(_priority_entry_schema)(value)
|
||||
entries = [_normalize_priority_entry(e) for e in raw]
|
||||
interfaces = [e["interface"] for e in entries]
|
||||
if len(interfaces) != len(set(interfaces)):
|
||||
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
|
||||
return entries
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
@@ -296,6 +125,7 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
esp8266=False,
|
||||
host=False,
|
||||
rp2040=False,
|
||||
nrf52=True,
|
||||
): cv.All(
|
||||
cv.boolean,
|
||||
cv.Any(
|
||||
@@ -306,59 +136,23 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
esp8266_arduino=cv.Version(0, 0, 0),
|
||||
host=cv.Version(0, 0, 0),
|
||||
rp2040_arduino=cv.Version(0, 0, 0),
|
||||
nrf52_zephyr=cv.Version(0, 0, 0),
|
||||
),
|
||||
cv.boolean_false,
|
||||
),
|
||||
validate_ipv6,
|
||||
),
|
||||
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
|
||||
cv.Optional(CONF_PRIORITY): _validate_priority_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config):
|
||||
"""Check that every interface named in 'priority' has a corresponding component block."""
|
||||
full = fv.full_config.get()
|
||||
for entry in config.get(CONF_PRIORITY, []):
|
||||
iface = entry["interface"]
|
||||
if iface not in full:
|
||||
raise cv.Invalid(
|
||||
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
|
||||
f"component is configured",
|
||||
[CONF_PRIORITY],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.NETWORK)
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_NETWORK")
|
||||
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
|
||||
|
||||
# Store the user-declared network priority list in CORE.data so that ethernet,
|
||||
# wifi and other network components can query it via get_network_priority() and
|
||||
# get_network_timeout() during their own to_code phase.
|
||||
if CONF_PRIORITY in config:
|
||||
priority_list = config[CONF_PRIORITY]
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
|
||||
# Enable Component::set_setup_priority() so the per-interface to_code
|
||||
# calls below have a defined symbol to link against. Without this define
|
||||
# the implementation in core/component.cpp is compiled out.
|
||||
cg.add_define("USE_SETUP_PRIORITY_OVERRIDE")
|
||||
|
||||
def _fmt(entry):
|
||||
if entry["timeout"] is not None:
|
||||
return f"{entry['interface']} (timeout: {entry['timeout']}ms)"
|
||||
return entry["interface"]
|
||||
|
||||
_LOGGER.info(
|
||||
"Network interface priority: %s",
|
||||
" > ".join(_fmt(e) for e in priority_list),
|
||||
)
|
||||
|
||||
# Apply high performance networking settings
|
||||
# Config can explicitly enable/disable, or default to component-driven behavior
|
||||
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
|
||||
@@ -422,6 +216,12 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64)
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64)
|
||||
|
||||
if CORE.is_nrf52:
|
||||
zephyr_add_prj_conf("NETWORKING", True)
|
||||
zephyr_add_prj_conf("NET_IPV6", True)
|
||||
zephyr_add_prj_conf("NET_TCP", True)
|
||||
zephyr_add_prj_conf("NET_UDP", True)
|
||||
|
||||
if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None:
|
||||
cg.add_define("USE_NETWORK_IPV6", enable_ipv6)
|
||||
if enable_ipv6:
|
||||
@@ -456,10 +256,3 @@ async def to_code(config):
|
||||
async def network_component_to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
# Pass the priority list to the C++ component. NetworkComponent::add_priority_entry
|
||||
# captures the interface-name string literal pointer; CORE.data[KEY_NETWORK_PRIORITY]
|
||||
# holds the normalized list of dicts (`{"interface": str, "timeout": int|None}`).
|
||||
for entry in CORE.data.get(KEY_NETWORK_PRIORITY, []):
|
||||
timeout_ms = entry["timeout"] if entry["timeout"] is not None else 0
|
||||
cg.add(var.add_priority_entry(entry["interface"], timeout_ms))
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/macros.h"
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || USE_ARDUINO_VERSION_CODE > VERSION_CODE(3, 0, 0)
|
||||
#include <lwip/ip_addr.h>
|
||||
#endif
|
||||
|
||||
#if USE_ARDUINO
|
||||
#include <Arduino.h>
|
||||
#include <IPAddress.h>
|
||||
@@ -24,6 +24,14 @@ using ip4_addr_t = in_addr;
|
||||
#define ipaddr_aton(x, y) inet_aton((x), (y))
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
#include <zephyr/net/net_ip.h>
|
||||
#include <zephyr/net/socket.h>
|
||||
#include <zephyr/posix/arpa/inet.h>
|
||||
using ip_addr_t = struct in6_addr;
|
||||
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; }
|
||||
#endif
|
||||
|
||||
#if USE_ESP32_FRAMEWORK_ARDUINO
|
||||
#define arduino_ns Arduino_h
|
||||
#elif USE_LIBRETINY
|
||||
@@ -33,7 +41,6 @@ using ip4_addr_t = in_addr;
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include <cstring>
|
||||
#include <esp_netif.h>
|
||||
#endif
|
||||
|
||||
@@ -52,7 +59,36 @@ inline void lowercase_ip_str(char *buf) {
|
||||
|
||||
struct IPAddress {
|
||||
public:
|
||||
#ifdef USE_HOST
|
||||
#ifdef USE_ZEPHYR
|
||||
IPAddress() { memset(&ip_addr_, 0, sizeof(ip_addr_)); }
|
||||
IPAddress(const std::string &in_address) : ip_addr_{} { ipaddr_aton(in_address.c_str(), &ip_addr_); }
|
||||
IPAddress(const struct in6_addr *other_ip) { ip_addr_ = *other_ip; }
|
||||
IPAddress(const struct sockaddr_in6 *addr) { ip_addr_ = addr->sin6_addr; }
|
||||
|
||||
operator struct in6_addr() const { return ip_addr_; }
|
||||
|
||||
bool is_set() const { return !net_ipv6_is_addr_unspecified(&ip_addr_); }
|
||||
bool is_ip4() const { return false; }
|
||||
bool is_ip6() const { return this->is_set(); }
|
||||
bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); }
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED(
|
||||
"str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0",
|
||||
"2026.2.0")
|
||||
std::string str() const {
|
||||
char buf[IP_ADDRESS_BUFFER_SIZE];
|
||||
this->str_to(buf);
|
||||
return buf;
|
||||
}
|
||||
char *str_to(char *buf) const {
|
||||
if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr)
|
||||
buf[0] = '\0';
|
||||
return buf;
|
||||
}
|
||||
bool operator==(const IPAddress &other) const { return net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); }
|
||||
bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); }
|
||||
|
||||
#elif defined(USE_HOST)
|
||||
IPAddress() { ip_addr_.s_addr = 0; }
|
||||
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
|
||||
this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth);
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NETWORK) && defined(USE_ESP32)
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_netif.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
namespace esphome::network {
|
||||
|
||||
static const char *const TAG = "network";
|
||||
@@ -31,88 +27,6 @@ void NetworkComponent::setup() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Register an IP_EVENT handler so we can re-arbitrate the default netif on every
|
||||
// interface up/down. ESP-IDF's built-in auto-selection picks by route_prio (WiFi STA = 100
|
||||
// > Ethernet = 50), which inverts the user's stated priority for same-subnet configurations.
|
||||
// We register AFTER esp-idf's internal handler, so our esp_netif_set_default_netif() call
|
||||
// wins and stays sticky thanks to esp-idf's "manual override" flag.
|
||||
err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &NetworkComponent::event_handler_, this);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "IP_EVENT handler register failed: %s — default route arbitration disabled",
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
|
||||
// Defensive: arbitrate now in case an interface came up before our handler registered
|
||||
// (unlikely given our AFTER_BLUETOOTH priority but cheap).
|
||||
this->update_default_netif_();
|
||||
}
|
||||
|
||||
void NetworkComponent::add_priority_entry(const char *interface, uint32_t timeout_ms) {
|
||||
if (this->priority_list_.size() >= MAX_NETWORK_PRIORITY_ENTRIES) {
|
||||
ESP_LOGW(TAG, "Priority list full; ignoring '%s'", interface);
|
||||
return;
|
||||
}
|
||||
this->priority_list_.push_back({interface, timeout_ms});
|
||||
}
|
||||
|
||||
const char *NetworkComponent::interface_to_ifkey_(const char *interface) {
|
||||
// Standard ESP-IDF netif keys. esphome's wifi/ethernet/openthread components create
|
||||
// netifs using these defaults.
|
||||
if (std::strcmp(interface, "ethernet") == 0)
|
||||
return "ETH_DEF";
|
||||
if (std::strcmp(interface, "wifi") == 0)
|
||||
return "WIFI_STA_DEF"; // STA carries uplink; AP never wins default route
|
||||
if (std::strcmp(interface, "openthread") == 0)
|
||||
return "OT_DEF";
|
||||
if (std::strcmp(interface, "modem") == 0)
|
||||
return "PPP_DEF";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void NetworkComponent::event_handler_(void *arg, esp_event_base_t /*base*/, int32_t /*id*/, void * /*data*/) {
|
||||
auto *self = static_cast<NetworkComponent *>(arg);
|
||||
self->update_default_netif_();
|
||||
}
|
||||
|
||||
void NetworkComponent::update_default_netif_() {
|
||||
// No priority list configured → leave ESP-IDF's route_prio-based auto-selection alone.
|
||||
// Single-interface configs behave exactly as before.
|
||||
if (this->priority_list_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto &entry : this->priority_list_) {
|
||||
const char *ifkey = interface_to_ifkey_(entry.interface);
|
||||
if (ifkey == nullptr)
|
||||
continue;
|
||||
|
||||
esp_netif_t *netif = esp_netif_get_handle_from_ifkey(ifkey);
|
||||
if (netif == nullptr)
|
||||
continue; // component for this interface hasn't run setup() yet
|
||||
|
||||
// is_netif_up returns true only when the netif has link + IP, which is what
|
||||
// we want for "this interface can carry traffic right now."
|
||||
if (!esp_netif_is_netif_up(netif))
|
||||
continue;
|
||||
|
||||
if (netif != this->active_netif_) {
|
||||
ESP_LOGI(TAG, "Default interface: %s", entry.interface);
|
||||
esp_netif_set_default_netif(netif);
|
||||
this->active_interface_ = entry.interface;
|
||||
this->active_netif_ = netif;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No priority-listed interface is currently up.
|
||||
if (this->active_netif_ != nullptr) {
|
||||
ESP_LOGD(TAG, "No active interface in priority list");
|
||||
this->active_interface_ = nullptr;
|
||||
this->active_netif_ = nullptr;
|
||||
// We intentionally don't clear esp-idf's default — the next interface that comes
|
||||
// up will trigger our handler again and we'll re-pick.
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::network
|
||||
|
||||
@@ -2,53 +2,13 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NETWORK) && defined(USE_ESP32)
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_netif.h"
|
||||
|
||||
namespace esphome::network {
|
||||
|
||||
// Cap matches the number of interface types the priority list accepts in YAML
|
||||
// (ethernet, wifi, openthread, modem). StaticVector keeps zero heap allocation.
|
||||
inline constexpr size_t MAX_NETWORK_PRIORITY_ENTRIES = 4;
|
||||
|
||||
struct NetworkPriorityEntry {
|
||||
const char *interface; // YAML name: "ethernet", "wifi", "openthread", "modem"
|
||||
uint32_t timeout_ms; // 0 = no timeout; consumed by Unit D (runtime fallback)
|
||||
};
|
||||
|
||||
class NetworkComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
// Codegen-time priority list construction. Called once per `network: priority:` entry
|
||||
// in YAML order. The interface name pointer must have static storage duration.
|
||||
void add_priority_entry(const char *interface, uint32_t timeout_ms);
|
||||
|
||||
// Currently-active interface in priority order (the one set as default netif).
|
||||
// Returns nullptr if no priority list is configured or no interface is up.
|
||||
const char *get_active_interface() const { return this->active_interface_; }
|
||||
esp_netif_t *get_active_netif() const { return this->active_netif_; }
|
||||
|
||||
protected:
|
||||
// Maps a YAML interface name to its ESP-IDF netif if-key.
|
||||
// Returns nullptr if the interface name is not recognized.
|
||||
static const char *interface_to_ifkey_(const char *interface);
|
||||
|
||||
// ESP-IDF event handler trampoline. Fires on IP_EVENT_* and re-arbitrates the default netif.
|
||||
static void event_handler_(void *arg, esp_event_base_t base, int32_t id, void *data);
|
||||
|
||||
// Walk priority_list_ in order. Set the highest-priority netif that is up as the
|
||||
// ESP-IDF default. No-op if priority_list_ is empty (single-interface configs).
|
||||
void update_default_netif_();
|
||||
|
||||
StaticVector<NetworkPriorityEntry, MAX_NETWORK_PRIORITY_ENTRIES> priority_list_;
|
||||
const char *active_interface_{nullptr};
|
||||
esp_netif_t *active_netif_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::network
|
||||
#endif
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user