diff --git a/.clang-tidy b/.clang-tidy
index ea7370a3b2..6dab84fbd9 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -116,7 +116,6 @@ Checks: >-
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
- -readability-container-contains,
-readability-container-data-pointer,
-readability-convert-member-functions-to-static,
-readability-else-after-return,
diff --git a/.clang-tidy.hash b/.clang-tidy.hash
index 77b4f5323f..84daffc69f 100644
--- a/.clang-tidy.hash
+++ b/.clang-tidy.hash
@@ -1 +1 @@
-593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
+72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6
diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md
index 4ec2551804..2c529dcd0f 100644
--- a/.claude/skills/pr-workflow/SKILL.md
+++ b/.claude/skills/pr-workflow/SKILL.md
@@ -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 ` 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
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 19f52349a6..3b39d519c4 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 72013e411e..08def88577 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -16,9 +16,9 @@
- fixes
-**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/esphome.io#
## 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).
diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml
index 52d72544d3..494c0cebe8 100644
--- a/.github/actions/build-image/action.yaml
+++ b/.github/actions/build-image/action.yaml
@@ -15,11 +15,6 @@ inputs:
description: "Version to build"
required: true
example: "2023.12.0"
- base_os:
- description: "Base OS to use"
- required: false
- default: "debian"
- example: "debian"
runs:
using: "composite"
steps:
@@ -47,7 +42,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -60,7 +55,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
@@ -73,7 +67,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -86,7 +80,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml
new file mode 100644
index 0000000000..7a17c222a3
--- /dev/null
+++ b/.github/actions/cache-esp-idf/action.yml
@@ -0,0 +1,46 @@
+name: Cache ESP-IDF
+description: >
+ Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
+ (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
+ natively (clang-tidy for IDF/Arduino and the native-IDF component build)
+ shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
+ defaults to "all", so all toolchains are present regardless of the chip).
+ Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
+ Python venv already restored.
+inputs:
+ framework:
+ description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".'
+ default: espidf
+runs:
+ using: composite
+ steps:
+ - name: Resolve ESP-IDF version for cache key
+ # The native-IDF version is pinned in code, not in any file that feeds the
+ # other cache keys, so resolve it explicitly. Keying on it means the cache
+ # invalidates on a version bump (actions/cache never overwrites a key).
+ id: version
+ shell: bash
+ run: |
+ . venv/bin/activate
+ if [ "${{ inputs.framework }}" = "arduino" ]; then
+ version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])')
+ else
+ version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ # Mirror the adjacent PlatformIO cache: only dev-branch runs write the
+ # shared cache (so it lives in the default-branch scope readable by all
+ # PRs), and PRs are restore-only -- they never push multi-GB artifacts into
+ # their own scope / the repo quota (e.g. on a version-bump PR).
+ - name: Cache ESP-IDF install (write on dev)
+ if: github.ref == 'refs/heads/dev'
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
+ - name: Cache ESP-IDF install (restore-only off dev)
+ if: github.ref != 'refs/heads/dev'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml
index 21393f2aba..66d016b42d 100644
--- a/.github/actions/restore-python/action.yml
+++ b/.github/actions/restore-python/action.yml
@@ -27,6 +27,18 @@ runs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
+ # downstream jobs rely on is preserved.
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
@@ -34,8 +46,8 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
shell: bash
@@ -43,5 +55,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 528e69c478..e87939f824 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -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
diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js
index e02b450bf0..2938fd923c 100644
--- a/.github/scripts/auto-label-pr/constants.js
+++ b/.github/scripts/auto-label-pr/constants.js
@@ -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+/
]
diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js
index 410c1a53c0..81bb77843d 100644
--- a/.github/scripts/auto-label-pr/detectors.js
+++ b/.github/scripts/auto-label-pr/detectors.js
@@ -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):
+ // /.py <-> //__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) {
diff --git a/.github/scripts/auto-label-pr/package.json b/.github/scripts/auto-label-pr/package.json
new file mode 100644
index 0000000000..401b376db6
--- /dev/null
+++ b/.github/scripts/auto-label-pr/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "auto-label-pr",
+ "private": true,
+ "scripts": {
+ "test": "node --test tests/*.test.js"
+ }
+}
diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js
new file mode 100644
index 0000000000..02d69ca95e
--- /dev/null
+++ b/.github/scripts/auto-label-pr/tests/detectors.test.js
@@ -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);
+ });
+});
diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml
index 6c80d36d20..e48d6f69bd 100644
--- a/.github/workflows/auto-label-pr.yml
+++ b/.github/workflows/auto-label-pr.yml
@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Generate a token
id: generate-token
diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml
index 2f7fd271ba..c6e9a358ab 100644
--- a/.github/workflows/ci-api-proto.yml
+++ b/.github/workflows/ci-api-proto.yml
@@ -21,11 +21,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
+ - name: Set up uv
+ # ``--system`` (below) installs into the setup-python interpreter;
+ # no venv is created or restored by this workflow.
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Install apt dependencies
run: |
@@ -34,7 +44,7 @@ jobs:
sudo apt install -y protobuf-compiler
protoc --version
- name: Install python dependencies
- run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
+ run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
- name: Generate files
run: script/api_protobuf/api_protobuf.py
- name: Check for changes
diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml
index d9148fb06d..73c437467b 100644
--- a/.github/workflows/ci-clang-tidy-hash.yml
+++ b/.github/workflows/ci-clang-tidy-hash.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml
index 3fd17888c7..7d4b850356 100644
--- a/.github/workflows/ci-docker.yml
+++ b/.github/workflows/ci-docker.yml
@@ -22,7 +22,7 @@ on:
- "script/platformio_install_deps.py"
permissions:
- contents: read # actions/checkout only; the build does not push images
+ contents: read # actions/checkout only
concurrency:
# yamllint disable-line rule:line-length
@@ -33,6 +33,9 @@ jobs:
check-docker:
name: Build docker containers
runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read # actions/checkout to load Dockerfile and build context
+ packages: write # push branch-tagged images to ghcr.io for local testing
strategy:
fail-fast: false
matrix:
@@ -41,23 +44,94 @@ jobs:
- "ha-addon"
- "docker"
# - "lint"
+ outputs:
+ tag: ${{ steps.tag.outputs.tag }}
+ push: ${{ steps.tag.outputs.push }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- - name: Set TAG
+ - name: Determine tag and whether to push
+ id: tag
run: |
- echo "TAG=check" >> $GITHUB_ENV
+ # Sanitize the branch name into a valid docker tag: replace invalid
+ # characters, ensure the first character is valid (tags must start
+ # with [A-Za-z0-9_]), and cap the length at 128 characters.
+ branch="${{ github.head_ref || github.ref_name }}"
+ tag="${branch//[^a-zA-Z0-9_.-]/-}"
+ case "$tag" in
+ [a-zA-Z0-9_]*) ;;
+ *) tag="pr-${tag}" ;;
+ esac
+ tag="${tag:0:128}"
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ # Only push branch images for same-repo pull requests. Push events
+ # only fire for dev/beta/release, whose images are owned by the
+ # release pipeline -- never overwrite those from here.
+ if [ "${{ github.event_name }}" = "pull_request" ] \
+ && [ "${{ github.repository }}" = "esphome/esphome" ] \
+ && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
+ echo "push=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "push=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Log in to the GitHub container registry
+ if: steps.tag.outputs.push == 'true'
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Run build
run: |
docker/build.py \
- --tag "${TAG}" \
+ --tag "${{ steps.tag.outputs.tag }}" \
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
--build-type "${{ matrix.build_type }}" \
- build
+ --registry ghcr \
+ build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }}
+
+ manifest:
+ name: Push ${{ matrix.build_type }} manifest to ghcr.io
+ needs: [check-docker]
+ if: needs.check-docker.outputs.push == 'true'
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read # actions/checkout to run docker/build.py
+ packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
+ strategy:
+ fail-fast: false
+ matrix:
+ build_type:
+ - "ha-addon"
+ - "docker"
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.11"
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+ - name: Log in to the GitHub container registry
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create and push manifest
+ run: |
+ docker/build.py \
+ --tag "${{ needs.check-docker.outputs.tag }}" \
+ --build-type "${{ matrix.build_type }}" \
+ --registry ghcr \
+ manifest
diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml
new file mode 100644
index 0000000000..43d530128c
--- /dev/null
+++ b/.github/workflows/ci-github-scripts.yml
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - name: Run tests
+ working-directory: .github/scripts/auto-label-pr
+ run: npm test
diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml
index 025b960985..35cfce65f8 100644
--- a/.github/workflows/ci-memory-impact-comment.yml
+++ b/.github/workflows/ci-memory-impact-comment.yml
@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 819dac926e..a57be34e9b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,14 +6,6 @@ on:
branches: [dev, beta, release]
pull_request:
- paths:
- - "**"
- - "!.github/workflows/*.yml"
- - "!.github/actions/build-image/*"
- - ".github/workflows/ci.yml"
- - "!.yamllint"
- - "!.github/dependabot.yml"
- - "!docker/**"
merge_group:
permissions:
@@ -36,7 +28,7 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
@@ -52,14 +44,26 @@ jobs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
+ # that ``. venv/bin/activate`` see an identical layout.
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
+ uv pip install -e .
pylint:
name: Check pylint
@@ -70,7 +74,7 @@ jobs:
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -89,9 +93,11 @@ jobs:
runs-on: ubuntu-24.04
needs:
- common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -107,6 +113,7 @@ jobs:
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2040-boards.py --check
+ script/ci_check_duplicate_test_ids.py
import-time:
name: Check import esphome.__main__ time
@@ -117,7 +124,7 @@ jobs:
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -145,11 +152,11 @@ jobs:
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
path: esphome
- name: Check out esphome/device-builder
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: esphome/device-builder
ref: main
@@ -164,9 +171,13 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
@@ -179,9 +190,12 @@ jobs:
- name: Run device-builder pytest
# ``-n auto`` runs under pytest-xdist (matches device-builder's
# own CI). No ``--cov`` here -- this is purely a downstream
- # smoke check against this PR's esphome code.
+ # smoke check against this PR's esphome code. ``tests/e2e/slow``
+ # is excluded: those are real multi-minute toolchain compiles
+ # (LibreTiny SDK clone, native ESP-IDF install) that device-builder
+ # runs in its own dedicated jobs, not this smoke check.
working-directory: device-builder
- run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
+ run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
pytest:
name: Run pytest
@@ -207,9 +221,11 @@ jobs:
runs-on: ${{ matrix.os }}
needs:
- common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
@@ -222,14 +238,14 @@ jobs:
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
- pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
- pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
@@ -245,10 +261,12 @@ jobs:
needs:
- common
outputs:
+ core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
+ clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
@@ -267,7 +285,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -287,15 +305,22 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
. venv/bin/activate
- output=$(python script/determine-jobs.py)
+ EXTRA_ARGS=""
+ if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
+ EXTRA_ARGS="--force-all"
+ echo "::notice::ci-run-all label detected -- forcing every CI job to run"
+ fi
+ output=$(python script/determine-jobs.py $EXTRA_ARGS)
echo "Test determination output:"
echo "$output" | jq
# Extract individual fields
+ echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
+ echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
@@ -332,7 +357,7 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python 3.13
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -344,14 +369,24 @@ jobs:
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv.
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
@@ -363,7 +398,7 @@ jobs:
. venv/bin/activate
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
- pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
+ pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -374,7 +409,7 @@ jobs:
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -403,7 +438,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -421,7 +456,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
@@ -438,6 +473,8 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy == 'true'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 2
@@ -448,9 +485,9 @@ jobs:
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 IDF
- options: --environment esp32-idf-tidy --grep USE_ESP_IDF
- pio_cache_key: tidyesp32-idf
+ name: Run script/clang-tidy for ESP32 Arduino
+ options: --environment esp32-arduino-tidy --grep USE_ARDUINO
+ cache_idf: true
- id: clang-tidy
name: Run script/clang-tidy for ZEPHYR
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
@@ -459,7 +496,7 @@ jobs:
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -471,36 +508,42 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- if: github.ref == 'refs/heads/dev'
+ if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
- if: github.ref != 'refs/heads/dev'
+ if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ # Shared with the IDF tidy + native-IDF build jobs (same install).
+ if: matrix.cache_idf
+ uses: ./.github/actions/cache-esp-idf
+ with:
+ framework: arduino
+
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- - name: Run 'pio run --list-targets -e esp32-idf-tidy'
- if: matrix.name == 'Run script/clang-tidy for ESP32 IDF'
- run: |
- . venv/bin/activate
- mkdir -p .temp
- pio run --list-targets -e esp32-idf-tidy
-
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -512,7 +555,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
else
echo "Running clang-tidy on changed files only"
@@ -528,7 +571,7 @@ jobs:
if: always()
clang-tidy-nosplit:
- name: Run script/clang-tidy for ESP32 Arduino
+ name: Run script/clang-tidy for ESP32 IDF
runs-on: ubuntu-24.04
needs:
- common
@@ -536,9 +579,11 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -549,19 +594,9 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ # Shared with the Arduino tidy + native-IDF build jobs (same install).
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -572,7 +607,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -584,11 +625,11 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
- script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
+ script/clang-tidy --all-headers --fix --environment esp32-idf-tidy
else
echo "Running clang-tidy on changed files only"
- script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy
+ script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -607,27 +648,26 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'split'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
- max-parallel: 2
+ max-parallel: 3
matrix:
include:
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 1/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
+ name: Run script/clang-tidy for ESP32 IDF 1/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 1
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 2/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
+ name: Run script/clang-tidy for ESP32 IDF 2/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 2
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 3/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
- - id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 4/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
+ name: Run script/clang-tidy for ESP32 IDF 3/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 3
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -638,19 +678,9 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ # Shared with the Arduino tidy + native-IDF build jobs (same install).
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -661,7 +691,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -673,7 +709,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
@@ -687,6 +723,93 @@ jobs:
run: script/ci-suggest-changes
if: always()
+ clang-tidy-esp32-variants:
+ name: ${{ matrix.name }}
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.clang-tidy == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # The variant tidy envs install ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
+ strategy:
+ fail-fast: false
+ max-parallel: 3
+ matrix:
+ include:
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 S3
+ options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 P4
+ # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
+ # so their code paths differ -- lint them under the P4 build too.
+ # yamllint disable-line rule:line-length
+ options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 C6
+ # yamllint disable-line rule:line-length
+ options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
+
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ # Need history for HEAD~1 to work for checking changed files
+ fetch-depth: 2
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Cache ESP-IDF install
+ # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install).
+ uses: ./.github/actions/cache-esp-idf
+
+ - name: Register problem matchers
+ run: |
+ echo "::add-matcher::.github/workflows/matchers/gcc.json"
+ echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
+
+ - name: Check if full clang-tidy scan needed
+ id: check_full_scan
+ run: |
+ . venv/bin/activate
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=hash_changed" >> $GITHUB_OUTPUT
+ else
+ echo "full_scan=false" >> $GITHUB_OUTPUT
+ echo "reason=normal" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Run clang-tidy
+ # Limited variant scan: only the files carrying that variant's code paths
+ # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree).
+ run: |
+ . venv/bin/activate
+ if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
+ script/clang-tidy --fix ${{ matrix.options }}
+ else
+ echo "Running clang-tidy on changed files only"
+ script/clang-tidy --fix --changed ${{ matrix.options }}
+ fi
+
+ - name: Suggested changes
+ run: script/ci-suggest-changes
+ if: always()
+
test-build-components-split:
name: Test components batch (${{ matrix.components }})
runs-on: ubuntu-24.04
@@ -715,7 +838,7 @@ jobs:
version: 1.0
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -772,7 +895,7 @@ jobs:
fi
echo ""
- # Show disk space before validation (after bind mounts setup)
+ # Show disk space before validation
echo "Disk space before config validation:"
df -h
echo ""
@@ -840,7 +963,7 @@ jobs:
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -848,33 +971,20 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache ESPHome
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.esphome-idf
- key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
-
- - name: Run native ESP-IDF compile test
+ - name: Prepare build storage on /mnt
+ # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE
+ # restoring the cache, so the ~4.5GB restore lands on the roomier volume
+ # instead of being shadowed by a mount set up later in the run step.
run: |
- . venv/bin/activate
-
- # Check if /mnt has more free space than / before bind mounting
- # Extract available space in KB for comparison
root_avail=$(df -k / | awk 'NR==2 {print $4}')
mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
-
echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
-
- # Only use /mnt if it has more space than /
if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
echo "Using /mnt for build files (more space available)"
- # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/esphome-idf
sudo chown $USER:$USER /mnt/esphome-idf
mkdir -p ~/.esphome-idf
sudo mount --bind /mnt/esphome-idf ~/.esphome-idf
-
- # Bind mount test build directory to /mnt
sudo mkdir -p /mnt/test_build_components_build
sudo chown $USER:$USER /mnt/test_build_components_build
mkdir -p tests/test_build_components/build
@@ -883,10 +993,19 @@ jobs:
echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
fi
+ - name: Cache ESP-IDF install
+ # Shared with the IDF/Arduino clang-tidy jobs (same install); restores
+ # into the /mnt bind-mount prepared above when present.
+ uses: ./.github/actions/cache-esp-idf
+
+ - name: Run native ESP-IDF compile test
+ run: |
+ . venv/bin/activate
+
echo "Testing components: $TEST_COMPONENTS"
echo ""
- # Show disk space before validation (after bind mounts setup)
+ # Show disk space before validation
echo "Disk space before config validation:"
df -h
echo ""
@@ -918,10 +1037,11 @@ jobs:
runs-on: ubuntu-latest
needs:
- common
- if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
+ - determine-jobs
+ if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -947,7 +1067,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.base_ref }}
@@ -1129,7 +1249,7 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1198,7 +1318,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1241,6 +1361,7 @@ jobs:
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
+ - clang-tidy-esp32-variants
- determine-jobs
- device-builder
- test-build-components-split
diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml
index 013517bde6..1bd60fd11d 100644
--- a/.github/workflows/codeowner-approved-label-update.yml
+++ b/.github/workflows/codeowner-approved-label-update.yml
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml
index 7cdbfcf328..5ad0b02de1 100644
--- a/.github/workflows/codeowner-review-request.yml
+++ b/.github/workflows/codeowner-review-request.yml
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 0a4dd9a92d..e559472b60 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml
index 04a2a2151b..ffd5ec7bd9 100644
--- a/.github/workflows/dashboard-deprecation-comment.yml
+++ b/.github/workflows/dashboard-deprecation-comment.yml
@@ -12,6 +12,12 @@ jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
+ # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
+ # roll up everything merged into dev since the last cut, which can
+ # include dashboard changes that have already been reviewed once.
+ # The bot's purpose is to warn new contributors before they invest
+ # time -- that only applies to PRs entering dev.
+ if: github.event.pull_request.base.ref == 'dev'
steps:
- name: Generate a token
id: generate-token
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index ed0bff9664..0e2efb1bcf 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -29,10 +29,11 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
- const author = context.payload.pull_request.user.login;
+ const user = context.payload.pull_request.user;
- // Skip bot PRs (e.g. dependabot) - they have their own title format
- if (author === 'dependabot[bot]') {
+ // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
+ // they have their own title formats.
+ if (user.type === 'Bot') {
return;
}
@@ -68,14 +69,15 @@ jobs:
return;
}
- // Check for angle brackets not wrapped in backticks.
- // Astro docs MDX treats bare < as JSX component opening tags.
+ // Check for MDX syntax characters not wrapped in backticks.
+ // Astro docs MDX treats bare `<` as JSX component opening tags and
+ // bare `{` as JS expressions, so both must be escaped in changelog entries.
const stripped = title.replace(/`[^`]*`/g, '');
- if (/[<>]/.test(stripped)) {
+ if (/[<>{}]/.test(stripped)) {
core.setFailed(
- 'PR title contains `<` or `>` not wrapped in backticks.\n' +
- 'Astro docs MDX interprets bare `<` as JSX components.\n' +
- 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support'
+ 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
+ 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
+ 'Please wrap these characters with backticks, e.g.: [component] Add `` support'
);
return;
}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9799f882db..8efc395951 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -60,7 +60,7 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -92,22 +92,22 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 2e57093bbb..7003f6c482 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
+ uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml
index f69c7530f7..ab1ce2b587 100644
--- a/.github/workflows/sync-device-classes.yml
+++ b/.github/workflows/sync-device-classes.yml
@@ -28,10 +28,10 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Checkout Home Assistant
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: home-assistant/core
path: lib/home-assistant
@@ -41,19 +41,56 @@ jobs:
with:
python-version: "3.14"
+ - name: Set up uv
+ # An order of magnitude faster than pip on cold boots, with its
+ # own wheel cache. ``--system`` (below) installs into the
+ # setup-python interpreter so subsequent ``pre-commit`` /
+ # ``script/run-in-env.py`` steps find the deps without a
+ # ``uv run`` prefix.
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
+
- name: Install Home Assistant
run: |
- python -m pip install --upgrade pip
- pip install -e lib/home-assistant
- pip install -r requirements_test.txt pre-commit
+ uv pip install --system -e lib/home-assistant
+ uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
- name: Sync
run: |
python ./script/sync-device_class.py
- - name: Run pre-commit hooks
- run: |
- python script/run-in-env.py pre-commit run --all-files
+ - name: Apply pre-commit auto-fixes
+ # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
+ # files. pre-commit exits non-zero whenever a hook touches anything,
+ # which would otherwise abort the workflow before the auto-fixes
+ # can flow into the sync PR.
+ #
+ # SKIP:
+ # - no-commit-to-branch is a local guard against committing on
+ # dev/release/beta; CI runs on dev by definition, and
+ # peter-evans/create-pull-request creates the branch itself.
+ # - pylint surfaces import-error / relative-beyond-top-level
+ # noise here because this workflow installs only a subset of
+ # the runtime deps (HA + requirements*.txt); main CI already
+ # gates pylint on real PRs.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files || true
+
+ - name: Verify pre-commit clean
+ # Second pass: re-run all hooks against the now-fixed tree.
+ # Auto-fixers exit 0 (nothing to change); any remaining failure
+ # from a check-only hook (flake8 / yamllint / ci-custom) is a
+ # real issue and fails the workflow loudly. Same SKIP list as
+ # above for the same reasons.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
diff --git a/.gitignore b/.gitignore
index 4a4a88fd48..de3e4fa68e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,6 +141,7 @@ tests/.esphome/
sdkconfig.*
!sdkconfig.defaults
+!sdkconfig.defaults.*
.tests/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index da5fb94d5e..3b6278e6b5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: v0.15.12
+ rev: v0.15.15
hooks:
# Run the linter.
- id: ruff
@@ -63,7 +63,7 @@ repos:
name: Update clang-tidy hash
entry: python script/clang_tidy_hash.py --update-if-changed
language: python
- files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$
+ files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$
pass_filenames: false
additional_dependencies: []
- id: ci-custom
diff --git a/AGENTS.md b/AGENTS.md
index 2139a2b796..4adc53cae9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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++):**
diff --git a/CODEOWNERS b/CODEOWNERS
index f8cdfdc6c6..10128c64e5 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet
esphome/components/adc/* @esphome/core
esphome/components/adc128s102/* @DeerMaximum
esphome/components/addressable_light/* @justfalter
-esphome/components/ade7880/* @kpfleming
esphome/components/ade7953/* @angelnu
esphome/components/ade7953_base/* @angelnu
esphome/components/ade7953_i2c/* @angelnu
@@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1
esphome/components/ags10/* @mak-42
esphome/components/aic3204/* @kbx81
esphome/components/airthings_ble/* @jeromelaban
-esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau
+esphome/components/airthings_wave_base/* @jeromelaban @ncareau
esphome/components/airthings_wave_mini/* @ncareau
esphome/components/airthings_wave_plus/* @jeromelaban @precurse
esphome/components/alarm_control_panel/* @grahambrown11 @hwstar
@@ -84,6 +83,7 @@ esphome/components/bme680_bsec/* @trvrnrth
esphome/components/bme68x_bsec2/* @kbx81 @neffs
esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs
esphome/components/bmi160/* @flaviut
+esphome/components/bmi270/* @clydebarrow
esphome/components/bmp280_base/* @ademuri
esphome/components/bmp280_i2c/* @ademuri
esphome/components/bmp280_spi/* @ademuri
@@ -139,7 +139,7 @@ esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter
esphome/components/display_menu_base/* @numo68
-esphome/components/dlms_meter/* @SimonFischer04
+esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
@@ -291,6 +291,7 @@ esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lps22/* @nagisa
+esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita
@@ -351,6 +352,7 @@ esphome/components/modbus_server/* @exciton
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt
+esphome/components/motion/* @esphome/core
esphome/components/mpl3115a2/* @kbickar
esphome/components/mpu6886/* @fabaff
esphome/components/ms8607/* @e28eta
@@ -379,6 +381,7 @@ esphome/components/pca6416a/* @Mat931
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
+esphome/components/pcm5122/* @remcom
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
@@ -417,6 +420,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
@@ -594,6 +598,7 @@ esphome/components/wk2212_spi/* @DrCoolZic
esphome/components/wl_134/* @hobbypunk90
esphome/components/wts01/* @alepee
esphome/components/x9c/* @EtienneMD
+esphome/components/xdb401/* @RT530
esphome/components/xgzp68xx/* @gcormier
esphome/components/xiaomi_hhccjcy10/* @fariouche
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
diff --git a/Doxyfile b/Doxyfile
index 3d74858d3d..56879237d4 100644
--- a/Doxyfile
+++ b/Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
-PROJECT_NUMBER = 2026.5.3
+PROJECT_NUMBER = 2026.6.0
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..f8afbbde04
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,18 @@
+coverage:
+ status:
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
+ project:
+ default:
+ informational: true
+
+ignore:
+ - "esphome/components/**/*"
+ - "esphome/analyze_memory/**/*"
+ - "tests/integration/**/*"
+
+comment:
+ layout: "reach, diff, flags, files"
+ require_changes: true
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 25de9472b6..18a9903735 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,10 +1,9 @@
ARG BUILD_VERSION=dev
-ARG BUILD_OS=alpine
-ARG BUILD_BASE_VERSION=2025.04.0
+ARG BUILD_BASE_VERSION=2026.06.0
ARG BUILD_TYPE=docker
-FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker
-FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
+FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker
+FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
@@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
# it idf_tools.py rejects the openocd install with exit 127 and aborts
# the whole framework setup.
-RUN if command -v apk > /dev/null; then \
- apk add --no-cache build-base libusb; \
- else \
- apt-get update \
- && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
- && rm -rf /var/lib/apt/lists/*; \
- fi
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
+ && rm -rf /var/lib/apt/lists/*
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
@@ -36,6 +31,9 @@ RUN \
uv pip install --no-cache-dir \
-r /requirements.txt
+# Install the ESPHome Device Builder dashboard.
+RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9
+
RUN \
platformio settings set enable_telemetry No \
&& platformio settings set check_platformio_interval 1000000 \
diff --git a/docker/build.py b/docker/build.py
index 4d093cf88d..475986e905 100755
--- a/docker/build.py
+++ b/docker/build.py
@@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon"
TYPE_LINT = "lint"
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
+REGISTRY_GHCR = "ghcr"
+REGISTRY_DOCKERHUB = "dockerhub"
+REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
+
parser = argparse.ArgumentParser()
parser.add_argument(
@@ -34,6 +38,12 @@ parser.add_argument(
parser.add_argument(
"--build-type", choices=TYPES, required=True, help="The type of build to run"
)
+parser.add_argument(
+ "--registry",
+ choices=REGISTRIES,
+ action="append",
+ help="Restrict to specific registries (default: all). May be passed multiple times.",
+)
parser.add_argument(
"--dry-run", action="store_true", help="Don't run any commands, just print them"
)
@@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
build_parser.add_argument(
"--load", help="Load the docker image locally", action="store_true"
)
+build_parser.add_argument(
+ "--no-cache-to",
+ help="Don't write the build cache (avoids polluting the shared cache)",
+ action="store_true",
+)
manifest_parser = subparsers.add_parser(
"manifest", help="Create a manifest from already pushed images"
)
@@ -95,11 +110,14 @@ def main():
print("Command failed")
sys.exit(1)
+ registries = args.registry or REGISTRIES
+
# detect channel from tag
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
major_minor_version = None
if match is None:
- channel = CHANNEL_DEV
+ # Custom tag (e.g. a branch name) -- push only the tag itself
+ channel = None
elif match.group(2) is None:
major_minor_version = match.group(1)
channel = CHANNEL_RELEASE
@@ -128,11 +146,18 @@ def main():
CHANNEL_DEV: "cache-dev",
CHANNEL_BETA: "cache-beta",
CHANNEL_RELEASE: "cache-latest",
- }[channel]
- cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
+ }.get(channel, "cache-dev")
+ # Cache images live alongside the pushed images; prefer GHCR when it is
+ # one of the selected registries, otherwise fall back to Docker Hub so a
+ # registry-restricted build doesn't need GHCR auth.
+ cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else ""
+ cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}"
- imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push]
- imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
+ imgs = []
+ if REGISTRY_DOCKERHUB in registries:
+ imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push]
+ if REGISTRY_GHCR in registries:
+ imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
# 3. build
cmd = [
@@ -155,7 +180,9 @@ def main():
for img in imgs:
cmd += ["--tag", img]
if args.push:
- cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
+ cmd += ["--push"]
+ if not args.no_cache_to:
+ cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
if args.load:
cmd += ["--load"]
@@ -163,20 +190,22 @@ def main():
elif args.command == "manifest":
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
- targets = [f"{manifest}:{tag}" for tag in tags_to_push]
- targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
- # 1. Create manifests
+ targets = []
+ if REGISTRY_DOCKERHUB in registries:
+ targets += [f"{manifest}:{tag}" for tag in tags_to_push]
+ if REGISTRY_GHCR in registries:
+ targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
+ # Use buildx imagetools (not `docker manifest`) so the per-arch sources,
+ # which buildx pushes as single-platform manifest lists, are combined
+ # and pushed correctly in one step.
for target in targets:
- cmd = ["docker", "manifest", "create", target]
+ cmd = ["docker", "buildx", "imagetools", "create", "--tag", target]
for arch in ARCHS:
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
if target.startswith("ghcr.io"):
src = f"ghcr.io/{src}"
cmd.append(src)
run_command(*cmd)
- # 2. Push manifests
- for target in targets:
- run_command("docker", "manifest", "push", target)
if __name__ == "__main__":
diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh
index 1b9224244c..18baf40c29 100755
--- a/docker/docker_entrypoint.sh
+++ b/docker/docker_entrypoint.sh
@@ -27,4 +27,12 @@ if [[ -d /build ]]; then
export ESPHOME_BUILD_PATH=/build
fi
+# The default CMD is "dashboard /config". Route the dashboard to the new
+# Device Builder, but pass every other subcommand (compile, run, config,
+# logs, ...) straight through to the esphome CLI so direct CLI use keeps working.
+if [[ "$1" == "dashboard" ]]; then
+ shift
+ exec esphome-device-builder "$@"
+fi
+
exec esphome "$@"
diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
deleted file mode 100755
index b990469762..0000000000
--- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/usr/bin/with-contenv bashio
-# ==============================================================================
-# Installs the latest prerelease of esphome-device-builder when the
-# `use_new_device_builder` config option is enabled.
-# This is a temporary install-on-boot step until esphome-device-builder
-# becomes a direct dependency of esphome.
-# ==============================================================================
-
-if ! bashio::config.true 'use_new_device_builder'; then
- exit 0
-fi
-
-bashio::log.info "Installing latest prerelease of esphome-device-builder..."
-if command -v uv > /dev/null; then
- uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
- esphome-device-builder ||
- bashio::exit.nok "Failed installing esphome-device-builder."
-else
- pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
- bashio::exit.nok "Failed installing esphome-device-builder."
-fi
-bashio::log.info "Installed esphome-device-builder."
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types
deleted file mode 100644
index 7c7cdef2d1..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types
+++ /dev/null
@@ -1,96 +0,0 @@
-types {
- text/html html htm shtml;
- text/css css;
- text/xml xml;
- image/gif gif;
- image/jpeg jpeg jpg;
- application/javascript js;
- application/atom+xml atom;
- application/rss+xml rss;
-
- text/mathml mml;
- text/plain txt;
- text/vnd.sun.j2me.app-descriptor jad;
- text/vnd.wap.wml wml;
- text/x-component htc;
-
- image/png png;
- image/svg+xml svg svgz;
- image/tiff tif tiff;
- image/vnd.wap.wbmp wbmp;
- image/webp webp;
- image/x-icon ico;
- image/x-jng jng;
- image/x-ms-bmp bmp;
-
- font/woff woff;
- font/woff2 woff2;
-
- application/java-archive jar war ear;
- application/json json;
- application/mac-binhex40 hqx;
- application/msword doc;
- application/pdf pdf;
- application/postscript ps eps ai;
- application/rtf rtf;
- application/vnd.apple.mpegurl m3u8;
- application/vnd.google-earth.kml+xml kml;
- application/vnd.google-earth.kmz kmz;
- application/vnd.ms-excel xls;
- application/vnd.ms-fontobject eot;
- application/vnd.ms-powerpoint ppt;
- application/vnd.oasis.opendocument.graphics odg;
- application/vnd.oasis.opendocument.presentation odp;
- application/vnd.oasis.opendocument.spreadsheet ods;
- application/vnd.oasis.opendocument.text odt;
- application/vnd.openxmlformats-officedocument.presentationml.presentation
- pptx;
- application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
- xlsx;
- application/vnd.openxmlformats-officedocument.wordprocessingml.document
- docx;
- application/vnd.wap.wmlc wmlc;
- application/x-7z-compressed 7z;
- application/x-cocoa cco;
- application/x-java-archive-diff jardiff;
- application/x-java-jnlp-file jnlp;
- application/x-makeself run;
- application/x-perl pl pm;
- application/x-pilot prc pdb;
- application/x-rar-compressed rar;
- application/x-redhat-package-manager rpm;
- application/x-sea sea;
- application/x-shockwave-flash swf;
- application/x-stuffit sit;
- application/x-tcl tcl tk;
- application/x-x509-ca-cert der pem crt;
- application/x-xpinstall xpi;
- application/xhtml+xml xhtml;
- application/xspf+xml xspf;
- application/zip zip;
-
- application/octet-stream bin exe dll;
- application/octet-stream deb;
- application/octet-stream dmg;
- application/octet-stream iso img;
- application/octet-stream msi msp msm;
-
- audio/midi mid midi kar;
- audio/mpeg mp3;
- audio/ogg ogg;
- audio/x-m4a m4a;
- audio/x-realaudio ra;
-
- video/3gpp 3gpp 3gp;
- video/mp2t ts;
- video/mp4 mp4;
- video/mpeg mpeg mpg;
- video/quicktime mov;
- video/webm webm;
- video/x-flv flv;
- video/x-m4v m4v;
- video/x-mng mng;
- video/x-ms-asf asx asf;
- video/x-ms-wmv wmv;
- video/x-msvideo avi;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf
deleted file mode 100644
index a1ebb5079a..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf
+++ /dev/null
@@ -1,16 +0,0 @@
-proxy_http_version 1.1;
-proxy_ignore_client_abort off;
-proxy_read_timeout 86400s;
-proxy_redirect off;
-proxy_send_timeout 86400s;
-proxy_max_temp_file_size 0;
-
-proxy_set_header Accept-Encoding "";
-proxy_set_header Connection $connection_upgrade;
-proxy_set_header Host $http_host;
-proxy_set_header Upgrade $http_upgrade;
-proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-proxy_set_header X-Forwarded-Proto $scheme;
-proxy_set_header X-NginX-Proxy true;
-proxy_set_header X-Real-IP $remote_addr;
-proxy_set_header Authorization "";
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf
deleted file mode 100644
index debdf83a8c..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-root /dev/null;
-server_name $hostname;
-
-client_max_body_size 512m;
-
-add_header X-Content-Type-Options nosniff;
-add_header X-XSS-Protection "1; mode=block";
-add_header X-Robots-Tag none;
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf
deleted file mode 100644
index e6789cbb9b..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-ssl_protocols TLSv1.2 TLSv1.3;
-ssl_prefer_server_ciphers off;
-ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
-ssl_session_timeout 10m;
-ssl_session_cache shared:SSL:10m;
-ssl_session_tickets off;
-ssl_stapling on;
-ssl_stapling_verify on;
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf
deleted file mode 100644
index 8e782bdc88..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf
+++ /dev/null
@@ -1,3 +0,0 @@
-upstream esphome {
- server unix:/var/run/esphome.sock;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf
deleted file mode 100644
index 497427596d..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf
+++ /dev/null
@@ -1,30 +0,0 @@
-daemon off;
-user root;
-pid /var/run/nginx.pid;
-worker_processes 1;
-error_log /proc/1/fd/1 error;
-events {
- worker_connections 1024;
-}
-
-http {
- include /etc/nginx/includes/mime.types;
-
- access_log off;
- default_type application/octet-stream;
- gzip on;
- keepalive_timeout 65;
- sendfile on;
- server_tokens off;
-
- tcp_nodelay on;
- tcp_nopush on;
-
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
-
- include /etc/nginx/includes/upstream.conf;
- include /etc/nginx/servers/*.conf;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep
deleted file mode 100644
index 85ad51be5f..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)
diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl
deleted file mode 100644
index 4fb0ca3f90..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl
+++ /dev/null
@@ -1,28 +0,0 @@
-server {
- {{ if not .ssl }}
- listen 6052 default_server;
- {{ else }}
- listen 6052 default_server ssl http2;
- {{ end }}
-
- include /etc/nginx/includes/server_params.conf;
- include /etc/nginx/includes/proxy_params.conf;
-
- {{ if .ssl }}
- include /etc/nginx/includes/ssl_params.conf;
-
- ssl_certificate /ssl/{{ .certfile }};
- ssl_certificate_key /ssl/{{ .keyfile }};
-
- # Redirect http requests to https on the same port.
- # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/
- error_page 497 https://$http_host$request_uri;
- {{ end }}
-
- # Clear Home Assistant Ingress header
- proxy_set_header X-HA-Ingress "";
-
- location / {
- proxy_pass http://esphome;
- }
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl
deleted file mode 100644
index 105ddde710..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl
+++ /dev/null
@@ -1,18 +0,0 @@
-server {
- listen 127.0.0.1:{{ .port }} default_server;
- listen {{ .interface }}:{{ .port }} default_server;
-
- include /etc/nginx/includes/server_params.conf;
- include /etc/nginx/includes/proxy_params.conf;
-
- # Set Home Assistant Ingress header
- proxy_set_header X-HA-Ingress "YES";
-
- location / {
- allow 172.30.32.2;
- allow 127.0.0.1;
- deny all;
-
- proxy_pass http://esphome;
- }
-}
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
index 111157d301..bb36cfcdb4 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
@@ -16,7 +16,7 @@ fi
port=$(bashio::addon.ingress_port)
-# Wait for NGINX to become available
+# Wait for the ESPHome Device Builder to become available
bashio::net.wait_for "${port}" "127.0.0.1" 300
config=$(\
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
index 6e0f8fe23a..da450c25f9 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
@@ -2,7 +2,7 @@
# shellcheck shell=bash
# ==============================================================================
# Home Assistant Community Add-on: ESPHome
-# Take down the S6 supervision tree when ESPHome dashboard fails
+# Take down the S6 supervision tree when ESPHome Device Builder fails
# ==============================================================================
declare exit_code
readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode
- fi
- [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
-elif [[ "${exit_code_service}" -ne 0 ]]; then
- if [[ "${exit_code_container}" -eq 0 ]]; then
- echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
- fi
- exec /run/s6/basedir/bin/halt
-fi
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
deleted file mode 100755
index bb5f52e10c..0000000000
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/command/with-contenv bashio
-# shellcheck shell=bash
-# ==============================================================================
-# Community Hass.io Add-ons: ESPHome
-# Runs the NGINX proxy
-# ==============================================================================
-
-# The new device builder handles HA ingress itself, so nginx is bypassed.
-# Block the longrun forever so s6 keeps the dependency satisfied and does
-# not respawn it.
-if bashio::config.true 'use_new_device_builder'; then
- bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
- exec sleep infinity
-fi
-
-bashio::log.info "Waiting for ESPHome dashboard to come up..."
-
-while [[ ! -S /var/run/esphome.sock ]]; do
- sleep 0.5
-done
-
-bashio::log.info "Starting NGINX..."
-exec nginx
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type
deleted file mode 100644
index 5883cff0cd..0000000000
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/esphome/__main__.py b/esphome/__main__.py
index 07bbd89358..f7d3f8e834 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -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}]"
@@ -695,6 +695,11 @@ def _wrap_to_code(name, comp, yaml_util):
def write_cpp(config: ConfigType) -> int:
from esphome import writer
+ # Refresh the storage sidecar and clean an incompatible previous build
+ # before regenerating any sources. This may full-wipe the build dir, so it
+ # has to run before write_cpp_file writes src/.
+ writer.update_storage_json()
+
if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore()
@@ -760,6 +765,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 +800,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 +1062,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 +1107,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 +1356,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 +1398,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():
@@ -1409,20 +1428,59 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
- if not CORE.verbose:
+ if getattr(args, "no_defaults", False):
+ user_config = getattr(config, "user_config", None)
+ if user_config is None:
+ _LOGGER.warning(
+ "--no-defaults requested but the user-only config snapshot is "
+ "unavailable; falling back to the validated configuration."
+ )
+ else:
+ config = user_config
+ elif 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\b(?:\w+_)?(?:password|key|psk|ssid))\: "
+ r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)"
+)
+_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
@@ -1587,7 +1645,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import writer
try:
- writer.clean_build()
+ writer.clean_build(full=True)
except OSError as err:
_LOGGER.error("Error deleting build files: %s", err)
return 1
@@ -1800,7 +1858,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 +2046,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(
@@ -2080,6 +2161,12 @@ def parse_args(argv):
parser_config.add_argument(
"--show-secrets", help="Show secrets in output.", action="store_true"
)
+ parser_config.add_argument(
+ "--no-defaults",
+ help="Only output the user-supplied configuration without "
+ "schema defaults applied.",
+ action="store_true",
+ )
parser_config_hash = subparsers.add_parser(
"config-hash", help="Calculate the hash of the configuration."
@@ -2164,11 +2251,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 +2283,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",
diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py
index 8f1f39e1d6..4fbceb7e5e 100644
--- a/esphome/analyze_memory/cli.py
+++ b/esphome/analyze_memory/cli.py
@@ -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)
diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py
index 8999108b51..7dbd6d4f63 100644
--- a/esphome/analyze_memory/demangle.py
+++ b/esphome/analyze_memory/demangle.py
@@ -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)
diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py
index 3a8a5f7be4..a724d52f25 100644
--- a/esphome/analyze_memory/toolchain.py
+++ b/esphome/analyze_memory/toolchain.py
@@ -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
diff --git a/esphome/async_thread.py b/esphome/async_thread.py
index 7be3c83a9a..c5225a7a14 100644
--- a/esphome/async_thread.py
+++ b/esphome/async_thread.py
@@ -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
diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py
index 96f84ebbd1..9e11d785c0 100644
--- a/esphome/build_gen/espidf.py
+++ b/esphome/build_gen/espidf.py
@@ -7,7 +7,17 @@ from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
-from esphome.writer import update_storage_json
+
+# Replaces the IDF default C++ standard (-std=gnu++2b appended to
+# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
+# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
+# i.e. after IDF appends its default and before the options are consumed, and
+# applies project-wide like PlatformIO build_unflags.
+CPP_STANDARD_TEMPLATE = """\
+idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)
+list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
+list(APPEND esphome_cxx_compile_options "-std={standard}")
+idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
def get_available_components() -> list[str] | None:
@@ -24,7 +34,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", {})
@@ -85,6 +95,12 @@ def get_project_cmakelists(minimal: bool = False) -> str:
for flag in project_compile_opts
)
+ cpp_standard_options = (
+ CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
+ if CORE.cpp_standard
+ else ""
+ )
+
# Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists.
@@ -141,6 +157,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
+{cpp_standard_options}
+
{extra_compile_options}
{managed_components_property}
@@ -201,9 +219,6 @@ idf_component_register(
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
-# Apply C++ standard
-target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
-
# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
@@ -213,11 +228,6 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def write_project(minimal: bool = False) -> None:
"""Write ESP-IDF project files."""
- # Refresh /storage/.yaml.json so the dashboard's
- # /info and /downloads endpoints can locate the build (they 404
- # otherwise). This mirrors the PlatformIO build-gen path's call
- # in build_gen/platformio.py:write_ini().
- update_storage_json()
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())
diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py
index 30dbb69d86..a583279ea7 100644
--- a/esphome/build_gen/platformio.py
+++ b/esphome/build_gen/platformio.py
@@ -1,7 +1,7 @@
from esphome.const import __version__
from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
-from esphome.writer import find_begin_end, update_storage_json
+from esphome.writer import find_begin_end
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============"
@@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str:
return content
+# All -std= variants a platform/framework may set by default, in both the GNU
+# and strict dialects; unflagged so the cg.set_cpp_standard() value is the
+# only standard left in the build.
+CPP_STD_VARIANTS = [
+ f"{prefix}{year}"
+ for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c")
+ for prefix in ("gnu++", "c++")
+]
+
+
def get_ini_content():
CORE.add_platformio_option(
"lib_deps",
[x.as_lib_dep for x in CORE.platformio_libraries.values()]
+ ["${common.lib_deps}"],
)
+ if CORE.cpp_standard:
+ for variant in CPP_STD_VARIANTS:
+ if variant != CORE.cpp_standard:
+ CORE.add_build_unflag(f"-std={variant}")
+ CORE.add_build_flag(f"-std={CORE.cpp_standard}")
# Sort to avoid changing build flags order
CORE.add_platformio_option("build_flags", sorted(CORE.build_flags))
@@ -58,7 +73,6 @@ def get_ini_content():
def write_ini(content):
- update_storage_json()
path = CORE.relative_build_path("platformio.ini")
if path.is_file():
diff --git a/esphome/bundle.py b/esphome/bundle.py
index 70c4fad0fd..d38f68ebfd 100644
--- a/esphome/bundle.py
+++ b/esphome/bundle.py
@@ -260,42 +260,20 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
- Deliberately uses a fresh re-parse and force-loads every deferred
- ``IncludeFile`` to include *all* potentially-reachable includes,
- even branches not selected by the local substitutions. Bundles are
- meant to be compiled on another system where command-line
- substitution overrides may choose a different branch — e.g.
- ``!include network/${eth_model}/config.yaml`` must ship every
- candidate so the remote build can pick any one.
-
- Entries with unresolved substitution variables in the filename
- path are skipped with a warning (they cannot be resolved without
- the substitution pass).
-
- Secrets files are tracked separately so we can filter them to
- only include the keys this config actually references.
+ Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
+ fresh re-parse and force-loads every deferred ``IncludeFile`` so that
+ *all* potentially-reachable includes are captured (even branches not
+ selected by local substitutions). Bundles are meant to be compiled on
+ another system where command-line substitution overrides may choose a
+ different branch — e.g. ``!include network/${eth_model}/config.yaml``
+ must ship every candidate so the remote build can pick any one.
"""
- # Must be a fresh parse: IncludeFile.load() caches its result in
- # _content, and we discover files by listening for loader calls. On
- # an already-parsed tree the cache is populated, .load() returns
- # without calling the loader, the listener never fires, and the
- # referenced files would be silently dropped from the bundle.
- with yaml_util.track_yaml_loads() as loaded_files:
- try:
- data = yaml_util.load_yaml(self._config_path)
- except EsphomeError:
- _LOGGER.debug(
- "Bundle: re-loading YAML for include discovery failed, "
- "proceeding with partial file list"
- )
- else:
- _force_load_include_files(data)
-
- for fpath in loaded_files:
- if fpath == self._config_path.resolve():
+ discovered = yaml_util.discover_user_yaml_files(self._config_path)
+ self._secrets_paths.update(discovered.secrets)
+ config_resolved = self._config_path.resolve()
+ for fpath in discovered.files:
+ if fpath == config_resolved:
continue # Already added as config
- if fpath.name in const.SECRETS_FILES:
- self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -434,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())
@@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
-def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
- """Recursively resolve any ``IncludeFile`` instances in a YAML tree.
-
- Nested ``!include`` returns a deferred ``IncludeFile`` that is only
- resolved during the substitution pass. During bundle discovery we need
- the referenced files to actually load so the ``track_yaml_loads``
- listener fires for them.
-
- ``IncludeFile`` instances with unresolved substitution variables in the
- filename cannot be loaded — we skip and warn about those.
- """
- if _seen is None:
- _seen = set()
-
- if isinstance(obj, yaml_util.IncludeFile):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- if obj.has_unresolved_expressions():
- _LOGGER.warning(
- "Bundle: cannot resolve !include %s (referenced from %s) "
- "with substitutions in path",
- obj.file,
- obj.parent_file,
- )
- return
- try:
- loaded = obj.load()
- except EsphomeError as err:
- _LOGGER.warning(
- "Bundle: failed to load !include %s (referenced from %s): %s",
- obj.file,
- obj.parent_file,
- err,
- )
- return
- _force_load_include_files(loaded, _seen)
- elif isinstance(obj, dict):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for value in obj.values():
- _force_load_include_files(value, _seen)
- elif isinstance(obj, (list, tuple)):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for item in obj:
- _force_load_include_files(item, _seen)
-
-
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py
index 92cbb7348a..f4fd205285 100644
--- a/esphome/compiled_config.py
+++ b/esphome/compiled_config.py
@@ -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))
diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py
index aed63c7dfa..e69de29bb2 100644
--- a/esphome/components/ade7880/__init__.py
+++ b/esphome/components/ade7880/__init__.py
@@ -1 +0,0 @@
-CODEOWNERS = ["@kpfleming"]
diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp
index 9d19770c57..0f4189ad90 100644
--- a/esphome/components/ade7880/ade7880.cpp
+++ b/esphome/components/ade7880/ade7880.cpp
@@ -87,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_
sensor->publish_state(f(val));
}
-template
-void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) {
- if (sensor == nullptr) {
+void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) {
+ if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) {
return;
}
- float val = this->read_s32_register16_(a_register);
- sensor->publish_state(f(val));
+ // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers
+ // accumulate signed energy since the last read (positive = imported/forward, negative = exported/
+ // reverse), so split the value by sign into the forward and reverse running totals.
+ float val = this->read_s32_register16_(a_register) / 14400.0f;
+ if (val >= 0.0f) {
+ if (channel->forward_active_energy != nullptr) {
+ channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val);
+ }
+ } else {
+ if (channel->reverse_active_energy != nullptr) {
+ channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val);
+ }
+ }
}
void ADE7880::update() {
@@ -117,12 +127,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, APF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, AWATTHR);
}
if (this->channel_b_ != nullptr) {
@@ -133,12 +138,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, BPF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, BWATTHR);
}
if (this->channel_c_ != nullptr) {
@@ -149,12 +149,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, CPF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, CWATTHR);
}
ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start);
diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h
index 69c8e5abba..53f501dee2 100644
--- a/esphome/components/ade7880/ade7880.h
+++ b/esphome/components/ade7880/ade7880.h
@@ -105,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent {
// the callable will be passed a 'float' value and is expected to return a 'float'
template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
- template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
+
+ void update_active_energy_(PowerChannel *channel, uint16_t a_register);
void reset_device_();
diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h
index aee4e42445..8b0b86fe7a 100644
--- a/esphome/components/ade7880/ade7880_registers.h
+++ b/esphome/components/ade7880/ade7880_registers.h
@@ -84,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402;
constexpr uint16_t AFWATTHR = 0xE403;
constexpr uint16_t BFWATTHR = 0xE404;
constexpr uint16_t CFWATTHR = 0xE405;
-constexpr uint16_t ARWATTHR = 0xE406;
-constexpr uint16_t BRWATTHR = 0xE407;
-constexpr uint16_t CRWATTHR = 0xE408;
+// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation)
constexpr uint16_t AFVARHR = 0xE409;
constexpr uint16_t BFVARHR = 0xE40A;
constexpr uint16_t CFVARHR = 0xE40B;
diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py
index c3f3b8f199..dee26b524a 100644
--- a/esphome/components/airthings_wave_base/__init__.py
+++ b/esphome/components/airthings_wave_base/__init__.py
@@ -21,7 +21,7 @@ from esphome.const import (
UNIT_VOLT,
)
-CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"]
+CODEOWNERS = ["@ncareau", "@jeromelaban"]
DEPENDENCIES = ["ble_client"]
diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py
index e9630f5266..9c9c7e3871 100644
--- a/esphome/components/animation/__init__.py
+++ b/esphome/components/animation/__init__.py
@@ -2,6 +2,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
+from esphome.components.const import CONF_LOOP
import esphome.components.image as espImage
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
@@ -14,7 +15,6 @@ DEPENDENCIES = ["display"]
MULTI_CONF = True
MULTI_CONF_NO_DEFAULT = True
-CONF_LOOP = "loop"
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py
index ca74483a2b..932702d47a 100644
--- a/esphome/components/api/__init__.py
+++ b/esphome/components/api/__init__.py
@@ -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),
}
)
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index cd5b3fd694..2b1458e2ae 100644
--- a/esphome/components/api/api_connection.cpp
+++ b/esphome/components/api/api_connection.cpp
@@ -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.
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index c30bd2e612..ddd03ace4a 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -1,6 +1,7 @@
#include "api_server.h"
#ifdef USE_API
#include
+#include
#include "api_connection.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
@@ -185,8 +186,12 @@ void APIServer::remove_client_(uint8_t client_index) {
if (client_index < last_index) {
std::swap(this->clients_[client_index], this->clients_[last_index]);
}
- this->clients_[last_index].reset();
+ // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the
+ // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state ->
+ // on_*_update iterating active_clients()). Excluding the dying slot from the active range first
+ // keeps that reentrant iteration from dereferencing the now-null slot.
this->api_connection_count_--;
+ this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
@@ -677,7 +682,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
// Schedule automatic cleanup after timeout (client will have given up by then)
// Uses numeric ID overload to avoid heap allocation from str_sprintf
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
- ESP_LOGD(TAG, "Action call %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 +726,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
- ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %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 +738,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
- ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %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
diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py
index d6150fbd29..44edc035f9 100644
--- a/esphome/components/api/client.py
+++ b/esphome/components/api/client.py
@@ -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
diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py
index 444306cec3..c05e556376 100644
--- a/esphome/components/as5600/__init__.py
+++ b/esphome/components/as5600/__init__.py
@@ -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),
diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py
index 13b379ba3a..2aceff0c97 100644
--- a/esphome/components/audio/__init__.py
+++ b/esphome/components/audio/__init__.py
@@ -335,7 +335,7 @@ async def to_code(config):
add_idf_component(
name="esphome/esp-audio-libs",
- ref="3.0.0",
+ ref="3.2.1",
)
data = _get_data()
@@ -395,7 +395,7 @@ async def to_code(config):
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
- add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
+ add_idf_component(name="esphome/micro-mp3", ref="0.2.3")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h
index 62c57b18cf..36780a3055 100644
--- a/esphome/components/audio/audio.h
+++ b/esphome/components/audio/audio.h
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/defines.h"
+#include "esphome/core/helpers.h" // for ESPDEPRECATED
#include
#include
@@ -143,6 +144,8 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url);
/// @param output_buffer Buffer to store the scaled samples
/// @param scale_factor Q15 fixed point scaling factor
/// @param samples_to_scale Number of samples to scale
+// Remove before 2026.12.0
+ESPDEPRECATED("Use esp_audio_libs::gain::apply() (from ) instead. Removed in 2026.12.0.", "2026.6.0")
void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor,
size_t samples_to_scale);
diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp
index d4ff59fc36..f709c23fb6 100644
--- a/esphome/components/audio/audio_decoder.cpp
+++ b/esphome/components/audio/audio_decoder.cpp
@@ -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 &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;
diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h
index c34ebbc613..e772b7eb5f 100644
--- a/esphome/components/audio/audio_decoder.h
+++ b/esphome/components/audio/audio_decoder.h
@@ -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 &input_ring_buffer);
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp
index c04cc881f5..bef62ce190 100644
--- a/esphome/components/audio/audio_resampler.cpp
+++ b/esphome/components/audio/audio_resampler.cpp
@@ -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 &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 &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(
@@ -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(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);
}
diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h
index 575ad13692..c09070c0ce 100644
--- a/esphome/components/audio/audio_resampler.h
+++ b/esphome/components/audio/audio_resampler.h
@@ -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 &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 input_transfer_buffer_;
+ std::shared_ptr source_ring_buffer_;
+ std::unique_ptr audio_source_;
std::unique_ptr output_transfer_buffer_;
size_t input_buffer_size_;
diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp
index d9ce8060e2..a611549e58 100644
--- a/esphome/components/audio/audio_transfer_buffer.cpp
+++ b/esphome/components/audio/audio_transfer_buffer.cpp
@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
}
}
+void RingBufferAudioSource::clear_buffered_data() {
+ // Release the held item before reset() so the source no longer references memory the reset will reclaim.
+ if (this->acquired_item_ != nullptr) {
+ this->ring_buffer_->receive_release(this->acquired_item_);
+ this->acquired_item_ = nullptr;
+ }
+ this->current_data_ = nullptr;
+ this->current_available_ = 0;
+ this->queued_data_ = nullptr;
+ this->queued_length_ = 0;
+ this->item_trailing_ptr_ = nullptr;
+ this->item_trailing_length_ = 0;
+ this->splice_length_ = 0;
+ this->ring_buffer_->reset();
+}
+
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h
index b713326141..074684f068 100644
--- a/esphome/components/audio/audio_transfer_buffer.h
+++ b/esphome/components/audio/audio_transfer_buffer.h
@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
+ /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
+ /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
+ void clear_buffered_data();
+
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py
index 23c90e9b76..53193c8008 100644
--- a/esphome/components/audio_file/__init__.py
+++ b/esphome/components/audio_file/__init__.py
@@ -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:
diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py
index 635a51b610..0710582813 100644
--- a/esphome/components/audio_file/media_source/__init__.py
+++ b/esphome/components/audio_file/media_source/__init__.py
@@ -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()
diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py
index 519d8df698..e8acbc81af 100644
--- a/esphome/components/audio_http/media_source.py
+++ b/esphome/components/audio_http/media_source.py
@@ -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]))
diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py
index 5083d283ef..62cd9e2e36 100644
--- a/esphome/components/bme68x_bsec2/__init__.py
+++ b/esphome/components/bme68x_bsec2/__init__.py
@@ -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(
diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py
new file mode 100644
index 0000000000..0e67e41a0e
--- /dev/null
+++ b/esphome/components/bmi270/__init__.py
@@ -0,0 +1,10 @@
+import esphome.codegen as cg
+from esphome.components import i2c
+from esphome.components.motion import MotionComponent
+
+CODEOWNERS = ["@clydebarrow"]
+
+CONF_BMI270_ID = "bmi270_id"
+# C++ namespace / class
+bmi270_ns = cg.esphome_ns.namespace("bmi270")
+BMI270Component = bmi270_ns.class_("BMI270Component", MotionComponent, i2c.I2CDevice)
diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp
new file mode 100644
index 0000000000..acb93158d4
--- /dev/null
+++ b/esphome/components/bmi270/bmi270.cpp
@@ -0,0 +1,209 @@
+#include "bmi270.h"
+#include "bmi270_config.h"
+#include "esphome/core/log.h"
+#include "esphome/core/hal.h"
+
+namespace esphome::bmi270 {
+
+static const char *const TAG = "bmi270";
+
+#if defined(USE_ARDUINO) && !defined(USE_ESP32)
+static const size_t MAX_I2C_BUFFER_SIZE = 32;
+#else
+static const size_t MAX_I2C_BUFFER_SIZE = 256;
+#endif
+
+// Configuration blob upload
+// The BMI270 requires a firmware config blob to be written to its internal
+// memory after every power-on before sensors can be used.
+
+bool BMI270Component::load_config_file_() {
+ // 1. Disable advanced power-save so the config port is accessible
+ if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00))
+ return false;
+ delay(1);
+
+ // 2. Prepare config load: write 0x00 to INIT_CTRL to start
+ if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00))
+ return false;
+
+ // 3. Burst-write the config in pages
+ const uint8_t *cfg = BMI270_CONFIG_FILE;
+ constexpr size_t cfg_len = sizeof(BMI270_CONFIG_FILE);
+ size_t index = 0;
+
+ while (index != cfg_len) {
+ // Set the page address in INIT_ADDR registers
+ uint8_t addr_lsb = (uint8_t) ((index / 2) & 0x0F);
+ uint8_t addr_msb = (uint8_t) ((index / 2) >> 4);
+ if (!this->write_byte(BMI270_REG_INIT_ADDR_0, addr_lsb))
+ return false;
+ if (!this->write_byte(BMI270_REG_INIT_ADDR_0 + 1, addr_msb))
+ return false;
+
+ // Write a burst of up to the maximum allowed size
+ size_t burst = clamp_at_most(cfg_len - index, MAX_I2C_BUFFER_SIZE);
+ if (this->write_register(BMI270_REG_INIT_DATA, cfg + index, burst) != i2c::ERROR_OK)
+ return false;
+
+ index += burst;
+ }
+
+ // 4. Signal end of config load
+ if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01))
+ return false;
+ delay(20); // spec: wait ≥20 ms for init to complete
+
+ // 5. Check INTERNAL_STATUS: bit[0:3] should be 0x01 ("initialisation OK")
+ uint8_t status = 0;
+ if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status))
+ return false;
+ if ((status & 0x0F) != 0x01) {
+ ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status);
+ return false;
+ }
+ return true;
+}
+
+// setup() ─
+
+void BMI270Component::setup() {
+ MotionComponent::setup();
+ // 1. Verify chip ID
+ uint8_t chip_id = 0;
+ if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) {
+ ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address");
+ this->mark_failed();
+ return;
+ }
+ if (chip_id != BMI270_CHIP_ID_VALUE) {
+ ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE);
+ this->mark_failed();
+ return;
+ }
+ ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id);
+
+ // 2. Soft-reset via CMD register (0x7E = 0xB6)
+ if (!this->write_byte(0x7E, 0xB6)) {
+ this->mark_failed();
+ return;
+ }
+ delay(20);
+
+ // 4. Upload the configuration blob
+ if (!load_config_file_()) {
+ ESP_LOGE(TAG, "Config file upload failed");
+ this->mark_failed();
+ return;
+ }
+ ESP_LOGD(TAG, "Config blob uploaded ✓");
+
+ // 5. Configure accelerometer
+ // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1)
+ uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7);
+ if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) {
+ this->mark_failed();
+ return;
+ }
+ if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) {
+ this->mark_failed();
+ return;
+ }
+
+ // 6. Configure gyroscope
+ // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1)
+ uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7);
+ if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) {
+ this->mark_failed();
+ return;
+ }
+ if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) {
+ this->mark_failed();
+ return;
+ }
+
+ // 7. Enable accelerometer, gyroscope, and temperature sensor
+ // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1]
+ if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) {
+ this->mark_failed();
+ return;
+ }
+ delay(5);
+
+ // 8. Re-enable advanced power save (optional; keeps current low between reads)
+ // Disabled here for simplicity – leave in performance mode
+ if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup
+ this->mark_failed();
+ return;
+ }
+
+ ESP_LOGCONFIG(TAG, "BMI270 initialised successfully");
+}
+
+void BMI270Component::dump_config() {
+ ESP_LOGCONFIG(TAG, "BMI270 IMU:");
+ LOG_I2C_DEVICE(this);
+ if (this->is_failed()) {
+ ESP_LOGE(TAG, " Communication failed!");
+ return;
+ }
+
+ static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"};
+ static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"};
+
+ ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]);
+ ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]);
+ MotionComponent::dump_config();
+}
+
+// update() ─
+// Reads all 6 axes + temperature in one block
+
+bool BMI270Component::update_data(motion::MotionData &data) {
+ if (this->is_failed())
+ return false;
+
+ // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb)
+ uint8_t raw_data[REG_READ_LEN];
+ if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) {
+ ESP_LOGW(TAG, "Failed to read IMU data");
+ return false;
+ }
+ // Scale factor: LSB/g depends on range
+ // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb
+ static constexpr float ACCEL_SCALE[] = {
+ 2.0f / 32768.0f,
+ 4.0f / 32768.0f,
+ 8.0f / 32768.0f,
+ 16.0f / 32768.0f,
+ };
+ float scale = ACCEL_SCALE[this->accel_range_];
+
+ data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale;
+ data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale;
+ data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale;
+
+ // Gyroscope: registers 0x12–0x17 (6 bytes)
+ // Scale: full-scale range / 2^15
+ static constexpr float GYRO_SCALE[] = {
+ 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f,
+ };
+ static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8;
+ scale = GYRO_SCALE[this->gyro_range_];
+
+ data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale;
+ data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale;
+ data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale;
+
+ if (this->temperature_callback_.empty())
+ return true;
+ // Temperature: registers 0x22–0x23
+ // Formula from datasheet: T[°C] = raw / 512 + 23
+ static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8;
+ int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
+ float temperature = (raw_t / 512.0f) + 23.0f;
+ this->temperature_callback_.call(temperature);
+ return true;
+}
+
+} // namespace esphome::bmi270
diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h
new file mode 100644
index 0000000000..7c5a2db015
--- /dev/null
+++ b/esphome/components/bmi270/bmi270.h
@@ -0,0 +1,108 @@
+#pragma once
+
+#include "esphome/components/motion/motion_component.h"
+#include "esphome/core/component.h"
+#include "esphome/core/helpers.h"
+#include "esphome/components/i2c/i2c.h"
+#include
+
+namespace esphome::bmi270 {
+
+// Register map
+static const uint8_t BMI270_REG_CHIP_ID = 0x00;
+static const uint8_t BMI270_REG_ERR_REG = 0x02;
+static const uint8_t BMI270_REG_STATUS = 0x03;
+static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB
+static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB
+static const uint8_t BMI270_REG_TEMP_0 = 0x22;
+static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish)
+
+static constexpr uint8_t REG_READ_LEN =
+ BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 +
+ 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4)
+
+static const uint8_t BMI270_REG_PWR_CONF = 0x7C;
+static const uint8_t BMI270_REG_PWR_CTRL = 0x7D;
+static const uint8_t BMI270_REG_INIT_CTRL = 0x59;
+static const uint8_t BMI270_REG_INIT_DATA = 0x5E;
+static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B;
+static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21;
+static const uint8_t BMI270_REG_ACC_CONF = 0x40;
+static const uint8_t BMI270_REG_ACC_RANGE = 0x41;
+static const uint8_t BMI270_REG_GYR_CONF = 0x42;
+static const uint8_t BMI270_REG_GYR_RANGE = 0x43;
+
+static const uint8_t BMI270_CHIP_ID_VALUE = 0x24;
+
+// Accelerometer range options
+enum BMI270AccelRange : uint8_t {
+ BMI270_ACCEL_RANGE_2G = 0x00,
+ BMI270_ACCEL_RANGE_4G = 0x01,
+ BMI270_ACCEL_RANGE_8G = 0x02,
+ BMI270_ACCEL_RANGE_16G = 0x03,
+};
+
+// Accelerometer ODR options
+enum BMI270AccelODR : uint8_t {
+ BMI270_ACCEL_ODR_12_5 = 0x05,
+ BMI270_ACCEL_ODR_25 = 0x06,
+ BMI270_ACCEL_ODR_50 = 0x07,
+ BMI270_ACCEL_ODR_100 = 0x08,
+ BMI270_ACCEL_ODR_200 = 0x09,
+ BMI270_ACCEL_ODR_400 = 0x0A,
+ BMI270_ACCEL_ODR_800 = 0x0B,
+ BMI270_ACCEL_ODR_1600 = 0x0C,
+};
+
+// Gyroscope range options
+enum BMI270GyroRange : uint8_t {
+ BMI270_GYRO_RANGE_2000 = 0x00,
+ BMI270_GYRO_RANGE_1000 = 0x01,
+ BMI270_GYRO_RANGE_500 = 0x02,
+ BMI270_GYRO_RANGE_250 = 0x03,
+ BMI270_GYRO_RANGE_125 = 0x04,
+};
+
+// Gyroscope ODR options
+enum BMI270GyroODR : uint8_t {
+ BMI270_GYRO_ODR_25 = 0x06,
+ BMI270_GYRO_ODR_50 = 0x07,
+ BMI270_GYRO_ODR_100 = 0x08,
+ BMI270_GYRO_ODR_200 = 0x09,
+ BMI270_GYRO_ODR_400 = 0x0A,
+ BMI270_GYRO_ODR_800 = 0x0B,
+ BMI270_GYRO_ODR_1600 = 0x0C,
+ BMI270_GYRO_ODR_3200 = 0x0D,
+};
+
+// ---Data class
+
+// Main component class
+class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice {
+ public:
+ // Lifecycle
+ void setup() override;
+ void dump_config() override;
+ float get_setup_priority() const override { return setup_priority::DATA; }
+
+ // Configuration setters
+ void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; }
+ void set_accel_odr(BMI270AccelODR o) { this->accel_odr_ = o; }
+ void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; }
+ void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; }
+ template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); }
+
+ protected:
+ bool update_data(motion::MotionData &data) override;
+ bool load_config_file_();
+
+ // Config
+ BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G};
+ BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100};
+ BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000};
+ BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200};
+
+ LazyCallbackManager temperature_callback_{};
+};
+
+} // namespace esphome::bmi270
diff --git a/esphome/components/bmi270/bmi270_config.h b/esphome/components/bmi270/bmi270_config.h
new file mode 100644
index 0000000000..4243f4e157
--- /dev/null
+++ b/esphome/components/bmi270/bmi270_config.h
@@ -0,0 +1,483 @@
+#pragma once
+#include
+
+namespace esphome::bmi270 {
+
+/**
+ BMI270 configuration file (chip ID 0x24, firmware v2.86.1)
+ Source: Bosch Sensortec BMI270_SensorAPI (BSD-3-Clause)
+ https://github.com/boschsensortec/BMI270_SensorAPI
+
+Copyright (c) 2023 Bosch Sensortec GmbH. All rights reserved.
+
+BSD-3-Clause
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
+IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+
+ This blob MUST be written to the chip's internal INIT_DATA register
+ after every power cycle, before any sensor data can be read.
+ --------------------------------------------------------------------------- */
+
+static constexpr uint8_t BMI270_CONFIG_FILE[] = {
+ 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x3d, 0xb1, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x91, 0x03, 0x80, 0x2e, 0xbc,
+ 0xb0, 0x80, 0x2e, 0xa3, 0x03, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x00, 0xb0, 0x50, 0x30, 0x21, 0x2e, 0x59, 0xf5,
+ 0x10, 0x30, 0x21, 0x2e, 0x6a, 0xf5, 0x80, 0x2e, 0x3b, 0x03, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x01, 0x00, 0x22,
+ 0x00, 0x75, 0x00, 0x00, 0x10, 0x00, 0x10, 0xd1, 0x00, 0xb3, 0x43, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0xe0, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x92, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
+ 0xe0, 0xaa, 0x38, 0x05, 0xe0, 0x90, 0x30, 0xfa, 0x00, 0x96, 0x00, 0x4b, 0x09, 0x11, 0x00, 0x11, 0x00, 0x02, 0x00,
+ 0x2d, 0x01, 0xd4, 0x7b, 0x3b, 0x01, 0xdb, 0x7a, 0x04, 0x00, 0x3f, 0x7b, 0xcd, 0x6c, 0xc3, 0x04, 0x85, 0x09, 0xc3,
+ 0x04, 0xec, 0xe6, 0x0c, 0x46, 0x01, 0x00, 0x27, 0x00, 0x19, 0x00, 0x96, 0x00, 0xa0, 0x00, 0x01, 0x00, 0x0c, 0x00,
+ 0xf0, 0x3c, 0x00, 0x01, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x32, 0x00, 0x05, 0x00, 0xee,
+ 0x06, 0x04, 0x00, 0xc8, 0x00, 0x00, 0x00, 0x04, 0x00, 0xa8, 0x05, 0xee, 0x06, 0x00, 0x04, 0xbc, 0x02, 0xb3, 0x00,
+ 0x85, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0xb4, 0x00, 0x01, 0x00, 0xb9, 0x00, 0x01, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x01, 0x00, 0x80, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0xde,
+ 0x00, 0xeb, 0x00, 0xda, 0x00, 0x00, 0x0c, 0xff, 0x0f, 0x00, 0x04, 0xc0, 0x00, 0x5b, 0xf5, 0xc9, 0x01, 0x1e, 0xf2,
+ 0x80, 0x00, 0x3f, 0xff, 0x19, 0xf4, 0x58, 0xf5, 0x66, 0xf5, 0x64, 0xf5, 0xc0, 0xf1, 0xf0, 0x00, 0xe0, 0x00, 0xcd,
+ 0x01, 0xd3, 0x01, 0xdb, 0x01, 0xff, 0x7f, 0xff, 0x01, 0xe4, 0x00, 0x74, 0xf7, 0xf3, 0x00, 0xfa, 0x00, 0xff, 0x3f,
+ 0xca, 0x03, 0x6c, 0x38, 0x56, 0xfe, 0x44, 0xfd, 0xbc, 0x02, 0xf9, 0x06, 0x00, 0xfc, 0x12, 0x02, 0xae, 0x01, 0x58,
+ 0xfa, 0x9a, 0xfd, 0x77, 0x05, 0xbb, 0x02, 0x96, 0x01, 0x95, 0x01, 0x7f, 0x01, 0x82, 0x01, 0x89, 0x01, 0x87, 0x01,
+ 0x88, 0x01, 0x8a, 0x01, 0x8c, 0x01, 0x8f, 0x01, 0x8d, 0x01, 0x92, 0x01, 0x91, 0x01, 0xdd, 0x00, 0x9f, 0x01, 0x7e,
+ 0x01, 0xdb, 0x00, 0xb6, 0x01, 0x70, 0x69, 0x26, 0xd3, 0x9c, 0x07, 0x1f, 0x05, 0x9d, 0x00, 0x00, 0x08, 0xbc, 0x05,
+ 0x37, 0xfa, 0xa2, 0x01, 0xaa, 0x01, 0xa1, 0x01, 0xa8, 0x01, 0xa0, 0x01, 0xa8, 0x05, 0xb4, 0x01, 0xb4, 0x01, 0xce,
+ 0x00, 0xd0, 0x00, 0xfc, 0x00, 0xc5, 0x01, 0xff, 0xfb, 0xb1, 0x00, 0x00, 0x38, 0x00, 0x30, 0xfd, 0xf5, 0xfc, 0xf5,
+ 0xcd, 0x01, 0xa0, 0x00, 0x5f, 0xff, 0x00, 0x40, 0xff, 0x00, 0x00, 0x80, 0x6d, 0x0f, 0xeb, 0x00, 0x7f, 0xff, 0xc2,
+ 0xf5, 0x68, 0xf7, 0xb3, 0xf1, 0x67, 0x0f, 0x5b, 0x0f, 0x61, 0x0f, 0x80, 0x0f, 0x58, 0xf7, 0x5b, 0xf7, 0x83, 0x0f,
+ 0x86, 0x00, 0x72, 0x0f, 0x85, 0x0f, 0xc6, 0xf1, 0x7f, 0x0f, 0x6c, 0xf7, 0x00, 0xe0, 0x00, 0xff, 0xd1, 0xf5, 0x87,
+ 0x0f, 0x8a, 0x0f, 0xff, 0x03, 0xf0, 0x3f, 0x8b, 0x00, 0x8e, 0x00, 0x90, 0x00, 0xb9, 0x00, 0x2d, 0xf5, 0xca, 0xf5,
+ 0xcb, 0x01, 0x20, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x50, 0x98, 0x2e,
+ 0xd7, 0x0e, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, 0xf0, 0x7f, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x00,
+ 0x2e, 0x01, 0x80, 0x08, 0xa2, 0xfb, 0x2f, 0x98, 0x2e, 0xba, 0x03, 0x21, 0x2e, 0x19, 0x00, 0x01, 0x2e, 0xee, 0x00,
+ 0x00, 0xb2, 0x07, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x03, 0x2f, 0x01, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x07,
+ 0xcc, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x27, 0x2f, 0x05, 0x2e, 0x8a, 0x00, 0x05, 0x52, 0x98, 0x2e, 0xc7, 0xc1,
+ 0x03, 0x2e, 0xe9, 0x00, 0x40, 0xb2, 0xf0, 0x7f, 0x08, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x00,
+ 0x30, 0x21, 0x2e, 0xe9, 0x00, 0x98, 0x2e, 0xb4, 0xb1, 0x01, 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x10, 0x2f, 0x05, 0x50,
+ 0x98, 0x2e, 0x4d, 0xc3, 0x05, 0x50, 0x98, 0x2e, 0x5a, 0xc7, 0x98, 0x2e, 0xf9, 0xb4, 0x98, 0x2e, 0x54, 0xb2, 0x98,
+ 0x2e, 0x67, 0xb6, 0x98, 0x2e, 0x17, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x2e, 0xef, 0x00, 0x00, 0xb2,
+ 0x04, 0x2f, 0x98, 0x2e, 0x7a, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xef, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xae, 0x0b,
+ 0x2f, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x07, 0x2f, 0x05, 0x52, 0x98, 0x2e, 0x8e, 0x0e, 0x00, 0xb2, 0x02, 0x2f,
+ 0x10, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x01, 0x2e, 0x7d, 0x00, 0x00, 0x90, 0x90, 0x2e, 0xf1, 0x02, 0x01, 0x2e, 0xd7,
+ 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x2f, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7b, 0x00,
+ 0x00, 0xb2, 0x12, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x00, 0x90, 0x02, 0x2f, 0x98, 0x2e, 0x1f, 0x0e, 0x09, 0x2d, 0x98,
+ 0x2e, 0x81, 0x0d, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0x90, 0x02, 0x2f, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30,
+ 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7c, 0x00, 0x00, 0xb2, 0x90, 0x2e, 0x09, 0x03, 0x01, 0x2e, 0x7c, 0x00, 0x01,
+ 0x31, 0x01, 0x08, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x47, 0xcb, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x81, 0x30,
+ 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, 0xb2, 0x61, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x98,
+ 0xbc, 0x98, 0xb8, 0x05, 0xb2, 0x0f, 0x58, 0x23, 0x2f, 0x07, 0x90, 0x09, 0x54, 0x00, 0x30, 0x37, 0x2f, 0x15, 0x41,
+ 0x04, 0x41, 0xdc, 0xbe, 0x44, 0xbe, 0xdc, 0xba, 0x2c, 0x01, 0x61, 0x00, 0x0f, 0x56, 0x4a, 0x0f, 0x0c, 0x2f, 0xd1,
+ 0x42, 0x94, 0xb8, 0xc1, 0x42, 0x11, 0x30, 0x05, 0x2e, 0x6a, 0xf7, 0x2c, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x08, 0x22,
+ 0x98, 0x2e, 0xc3, 0xb7, 0x21, 0x2d, 0x61, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x98, 0x2e, 0xc3, 0xb7, 0x00, 0x30, 0x21,
+ 0x2e, 0x5a, 0xf5, 0x18, 0x2d, 0xe1, 0x7f, 0x50, 0x30, 0x98, 0x2e, 0xfa, 0x03, 0x0f, 0x52, 0x07, 0x50, 0x50, 0x42,
+ 0x70, 0x30, 0x0d, 0x54, 0x42, 0x42, 0x7e, 0x82, 0xe2, 0x6f, 0x80, 0xb2, 0x42, 0x42, 0x05, 0x2f, 0x21, 0x2e, 0xd4,
+ 0x00, 0x10, 0x30, 0x98, 0x2e, 0xc3, 0xb7, 0x03, 0x2d, 0x60, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x01, 0x2e, 0xd4, 0x00,
+ 0x06, 0x90, 0x18, 0x2f, 0x01, 0x2e, 0x76, 0x00, 0x0b, 0x54, 0x07, 0x52, 0xe0, 0x7f, 0x98, 0x2e, 0x7a, 0xc1, 0xe1,
+ 0x6f, 0x08, 0x1a, 0x40, 0x30, 0x08, 0x2f, 0x21, 0x2e, 0xd4, 0x00, 0x20, 0x30, 0x98, 0x2e, 0xaf, 0xb7, 0x50, 0x32,
+ 0x98, 0x2e, 0xfa, 0x03, 0x05, 0x2d, 0x98, 0x2e, 0x38, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x00, 0x30, 0x21,
+ 0x2e, 0x7c, 0x00, 0x18, 0x2d, 0x01, 0x2e, 0xd4, 0x00, 0x03, 0xaa, 0x01, 0x2f, 0x98, 0x2e, 0x45, 0x0e, 0x01, 0x2e,
+ 0xd4, 0x00, 0x3f, 0x80, 0x03, 0xa2, 0x01, 0x2f, 0x00, 0x2e, 0x02, 0x2d, 0x98, 0x2e, 0x5b, 0x0e, 0x30, 0x30, 0x98,
+ 0x2e, 0xce, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x01, 0x2e, 0x77, 0x00,
+ 0x00, 0xb2, 0x24, 0x2f, 0x98, 0x2e, 0xf5, 0xcb, 0x03, 0x2e, 0xd5, 0x00, 0x11, 0x54, 0x01, 0x0a, 0xbc, 0x84, 0x83,
+ 0x86, 0x21, 0x2e, 0xc9, 0x01, 0xe0, 0x40, 0x13, 0x52, 0xc4, 0x40, 0x82, 0x40, 0xa8, 0xb9, 0x52, 0x42, 0x43, 0xbe,
+ 0x53, 0x42, 0x04, 0x0a, 0x50, 0x42, 0xe1, 0x7f, 0xf0, 0x31, 0x41, 0x40, 0xf2, 0x6f, 0x25, 0xbd, 0x08, 0x08, 0x02,
+ 0x0a, 0xd0, 0x7f, 0x98, 0x2e, 0xa8, 0xcf, 0x06, 0xbc, 0xd1, 0x6f, 0xe2, 0x6f, 0x08, 0x0a, 0x80, 0x42, 0x98, 0x2e,
+ 0x58, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x21, 0x2e, 0x77, 0x00, 0x21, 0x2e, 0xdd, 0x00, 0x80, 0x2e, 0xf4,
+ 0x01, 0x1a, 0x24, 0x22, 0x00, 0x80, 0x2e, 0xec, 0x01, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50,
+ 0xfb, 0x6f, 0x01, 0x30, 0x71, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, 0x2f, 0xc0, 0x2e, 0x01, 0x42, 0xf0, 0x5f, 0x80,
+ 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x01,
+ 0x34, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x06, 0x32, 0x0f, 0x2e, 0x61, 0xf5, 0xfe, 0x09, 0xc0, 0xb3, 0x04,
+ 0x2f, 0x17, 0x30, 0x2f, 0x2e, 0xef, 0x00, 0x2d, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e,
+ 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x46, 0x30, 0x0f, 0x2e, 0xa4, 0xf1, 0xbe, 0x09, 0x80, 0xb3, 0x06, 0x2f, 0x0d,
+ 0x2e, 0xd4, 0x00, 0x84, 0xaf, 0x02, 0x2f, 0x16, 0x30, 0x2d, 0x2e, 0x7b, 0x00, 0x86, 0x30, 0x2d, 0x2e, 0x60, 0xf5,
+ 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, 0x01, 0x2e, 0x77, 0xf7, 0x09, 0xbc, 0x0f, 0xb8, 0x00, 0xb2, 0x10,
+ 0x50, 0xfb, 0x7f, 0x10, 0x30, 0x0b, 0x2f, 0x03, 0x2e, 0x8a, 0x00, 0x96, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x05, 0x2f,
+ 0x03, 0x2e, 0x68, 0xf7, 0x9e, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x07, 0x2f, 0x03, 0x2e, 0x7e, 0x00, 0x41, 0x90, 0x01,
+ 0x2f, 0x98, 0x2e, 0xdc, 0x03, 0x03, 0x2c, 0x00, 0x30, 0x21, 0x2e, 0x7e, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e,
+ 0x20, 0x50, 0xe0, 0x7f, 0xfb, 0x7f, 0x00, 0x2e, 0x27, 0x50, 0x98, 0x2e, 0x3b, 0xc8, 0x29, 0x50, 0x98, 0x2e, 0xa7,
+ 0xc8, 0x01, 0x50, 0x98, 0x2e, 0x55, 0xcc, 0xe1, 0x6f, 0x2b, 0x50, 0x98, 0x2e, 0xe0, 0xc9, 0xfb, 0x6f, 0x00, 0x30,
+ 0xe0, 0x5f, 0x21, 0x2e, 0x7e, 0x00, 0xb8, 0x2e, 0x73, 0x50, 0x01, 0x30, 0x57, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc,
+ 0x2f, 0xb8, 0x2e, 0x21, 0x2e, 0x59, 0xf5, 0x10, 0x30, 0xc0, 0x2e, 0x21, 0x2e, 0x4a, 0xf1, 0x90, 0x50, 0xf7, 0x7f,
+ 0xe6, 0x7f, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0xa1, 0x7f, 0x90, 0x7f, 0x82, 0x7f, 0x7b, 0x7f, 0x98, 0x2e, 0x35,
+ 0xb7, 0x00, 0xb2, 0x90, 0x2e, 0x97, 0xb0, 0x03, 0x2e, 0x8f, 0x00, 0x07, 0x2e, 0x91, 0x00, 0x05, 0x2e, 0xb1, 0x00,
+ 0x3f, 0xba, 0x9f, 0xb8, 0x01, 0x2e, 0xb1, 0x00, 0xa3, 0xbd, 0x4c, 0x0a, 0x05, 0x2e, 0xb1, 0x00, 0x04, 0xbe, 0xbf,
+ 0xb9, 0xcb, 0x0a, 0x4f, 0xba, 0x22, 0xbd, 0x01, 0x2e, 0xb3, 0x00, 0xdc, 0x0a, 0x2f, 0xb9, 0x03, 0x2e, 0xb8, 0x00,
+ 0x0a, 0xbe, 0x9a, 0x0a, 0xcf, 0xb9, 0x9b, 0xbc, 0x01, 0x2e, 0x97, 0x00, 0x9f, 0xb8, 0x93, 0x0a, 0x0f, 0xbc, 0x91,
+ 0x0a, 0x0f, 0xb8, 0x90, 0x0a, 0x25, 0x2e, 0x18, 0x00, 0x05, 0x2e, 0xc1, 0xf5, 0x2e, 0xbd, 0x2e, 0xb9, 0x01, 0x2e,
+ 0x19, 0x00, 0x31, 0x30, 0x8a, 0x04, 0x00, 0x90, 0x07, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xa2, 0x03, 0x2f, 0x01,
+ 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x0c, 0x2f, 0x19, 0x50, 0x05, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x05, 0x2e, 0x78, 0x00,
+ 0x80, 0x90, 0x10, 0x30, 0x01, 0x2f, 0x21, 0x2e, 0x78, 0x00, 0x25, 0x2e, 0xdd, 0x00, 0x98, 0x2e, 0x3e, 0xb7, 0x00,
+ 0xb2, 0x02, 0x30, 0x01, 0x30, 0x04, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x00, 0x2f, 0x21, 0x30, 0x01, 0x2e,
+ 0xea, 0x00, 0x08, 0x1a, 0x0e, 0x2f, 0x23, 0x2e, 0xea, 0x00, 0x33, 0x30, 0x1b, 0x50, 0x0b, 0x09, 0x01, 0x40, 0x17,
+ 0x56, 0x46, 0xbe, 0x4b, 0x08, 0x4c, 0x0a, 0x01, 0x42, 0x0a, 0x80, 0x15, 0x52, 0x01, 0x42, 0x00, 0x2e, 0x01, 0x2e,
+ 0x18, 0x00, 0x00, 0xb2, 0x1f, 0x2f, 0x03, 0x2e, 0xc0, 0xf5, 0xf0, 0x30, 0x48, 0x08, 0x47, 0xaa, 0x74, 0x30, 0x07,
+ 0x2e, 0x7a, 0x00, 0x61, 0x22, 0x4b, 0x1a, 0x05, 0x2f, 0x07, 0x2e, 0x66, 0xf5, 0xbf, 0xbd, 0xbf, 0xb9, 0xc0, 0x90,
+ 0x0b, 0x2f, 0x1d, 0x56, 0x2b, 0x30, 0xd2, 0x42, 0xdb, 0x42, 0x01, 0x04, 0xc2, 0x42, 0x04, 0xbd, 0xfe, 0x80, 0x81,
+ 0x84, 0x23, 0x2e, 0x7a, 0x00, 0x02, 0x42, 0x02, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x05, 0x2e, 0xd6, 0x00, 0x81, 0x84,
+ 0x25, 0x2e, 0xd6, 0x00, 0x02, 0x31, 0x25, 0x2e, 0x60, 0xf5, 0x05, 0x2e, 0x8a, 0x00, 0x0b, 0x50, 0x90, 0x08, 0x80,
+ 0xb2, 0x0b, 0x2f, 0x05, 0x2e, 0xca, 0xf5, 0xf0, 0x3e, 0x90, 0x08, 0x25, 0x2e, 0xca, 0xf5, 0x05, 0x2e, 0x59, 0xf5,
+ 0xe0, 0x3f, 0x90, 0x08, 0x25, 0x2e, 0x59, 0xf5, 0x90, 0x6f, 0xa1, 0x6f, 0xb3, 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0xe6,
+ 0x6f, 0xf7, 0x6f, 0x7b, 0x6f, 0x82, 0x6f, 0x70, 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0x90, 0x7f, 0xe5, 0x7f, 0xd4, 0x7f,
+ 0xc3, 0x7f, 0xb1, 0x7f, 0xa2, 0x7f, 0x87, 0x7f, 0xf6, 0x7f, 0x7b, 0x7f, 0x00, 0x2e, 0x01, 0x2e, 0x60, 0xf5, 0x60,
+ 0x7f, 0x98, 0x2e, 0x35, 0xb7, 0x02, 0x30, 0x63, 0x6f, 0x15, 0x52, 0x50, 0x7f, 0x62, 0x7f, 0x5a, 0x2c, 0x02, 0x32,
+ 0x1a, 0x09, 0x00, 0xb3, 0x14, 0x2f, 0x00, 0xb2, 0x03, 0x2f, 0x09, 0x2e, 0x18, 0x00, 0x00, 0x91, 0x0c, 0x2f, 0x43,
+ 0x7f, 0x98, 0x2e, 0x97, 0xb7, 0x1f, 0x50, 0x02, 0x8a, 0x02, 0x32, 0x04, 0x30, 0x25, 0x2e, 0x64, 0xf5, 0x15, 0x52,
+ 0x50, 0x6f, 0x43, 0x6f, 0x44, 0x43, 0x25, 0x2e, 0x60, 0xf5, 0xd9, 0x08, 0xc0, 0xb2, 0x36, 0x2f, 0x98, 0x2e, 0x3e,
+ 0xb7, 0x00, 0xb2, 0x06, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x02, 0x2f, 0x50, 0x6f, 0x00, 0x90, 0x0a, 0x2f,
+ 0x01, 0x2e, 0x79, 0x00, 0x00, 0x90, 0x19, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x79, 0x00, 0x00, 0x30, 0x98, 0x2e, 0xdc,
+ 0x03, 0x13, 0x2d, 0x01, 0x2e, 0xc3, 0xf5, 0x0c, 0xbc, 0x0f, 0xb8, 0x12, 0x30, 0x10, 0x04, 0x03, 0xb0, 0x26, 0x25,
+ 0x21, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x10, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x02, 0x30, 0x60, 0x7f, 0x25,
+ 0x2e, 0x79, 0x00, 0x60, 0x6f, 0x00, 0x90, 0x05, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0xea, 0x00, 0x15, 0x50, 0x21, 0x2e,
+ 0x64, 0xf5, 0x15, 0x52, 0x23, 0x2e, 0x60, 0xf5, 0x02, 0x32, 0x50, 0x6f, 0x00, 0x90, 0x02, 0x2f, 0x03, 0x30, 0x27,
+ 0x2e, 0x78, 0x00, 0x07, 0x2e, 0x60, 0xf5, 0x1a, 0x09, 0x00, 0x91, 0xa3, 0x2f, 0x19, 0x09, 0x00, 0x91, 0xa0, 0x2f,
+ 0x90, 0x6f, 0xa2, 0x6f, 0xb1, 0x6f, 0xc3, 0x6f, 0xd4, 0x6f, 0xe5, 0x6f, 0x7b, 0x6f, 0xf6, 0x6f, 0x87, 0x6f, 0x40,
+ 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x26, 0x30, 0x0f, 0x2e, 0x61, 0xf5, 0x2f, 0x2e, 0x7c, 0x00,
+ 0x0f, 0x2e, 0x7c, 0x00, 0xbe, 0x09, 0xa2, 0x7f, 0x80, 0x7f, 0x80, 0xb3, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0x91,
+ 0x7f, 0x7b, 0x7f, 0x0b, 0x2f, 0x23, 0x50, 0x1a, 0x25, 0x12, 0x40, 0x42, 0x7f, 0x74, 0x82, 0x12, 0x40, 0x52, 0x7f,
+ 0x00, 0x2e, 0x00, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0x6a, 0xd6, 0x81, 0x30, 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00,
+ 0xb2, 0x42, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0x89, 0x00, 0x97, 0xbc, 0x06, 0xbc, 0x9f, 0xb8, 0x0f, 0xb8,
+ 0x00, 0x90, 0x23, 0x2e, 0xd8, 0x00, 0x10, 0x30, 0x01, 0x30, 0x2a, 0x2f, 0x03, 0x2e, 0xd4, 0x00, 0x44, 0xb2, 0x05,
+ 0x2f, 0x47, 0xb2, 0x00, 0x30, 0x2d, 0x2f, 0x21, 0x2e, 0x7c, 0x00, 0x2b, 0x2d, 0x03, 0x2e, 0xfd, 0xf5, 0x9e, 0xbc,
+ 0x9f, 0xb8, 0x40, 0x90, 0x14, 0x2f, 0x03, 0x2e, 0xfc, 0xf5, 0x99, 0xbc, 0x9f, 0xb8, 0x40, 0x90, 0x0e, 0x2f, 0x03,
+ 0x2e, 0x49, 0xf1, 0x25, 0x54, 0x4a, 0x08, 0x40, 0x90, 0x08, 0x2f, 0x98, 0x2e, 0x35, 0xb7, 0x00, 0xb2, 0x10, 0x30,
+ 0x03, 0x2f, 0x50, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x10, 0x2d, 0x98, 0x2e, 0xaf, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7c,
+ 0x00, 0x0a, 0x2d, 0x05, 0x2e, 0x69, 0xf7, 0x2d, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x01, 0x2f, 0x21, 0x2e, 0x7d, 0x00,
+ 0x23, 0x2e, 0x7c, 0x00, 0xe0, 0x31, 0x21, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0x80, 0x6f, 0xa2, 0x6f, 0xb3,
+ 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0x7b, 0x6f, 0x91, 0x6f, 0x40, 0x5f, 0xc8, 0x2e, 0x60, 0x51, 0x0a, 0x25, 0x36, 0x88,
+ 0xf4, 0x7f, 0xeb, 0x7f, 0x00, 0x32, 0x31, 0x52, 0x32, 0x30, 0x13, 0x30, 0x98, 0x2e, 0x15, 0xcb, 0x0a, 0x25, 0x33,
+ 0x84, 0xd2, 0x7f, 0x43, 0x30, 0x05, 0x50, 0x2d, 0x52, 0x98, 0x2e, 0x95, 0xc1, 0xd2, 0x6f, 0x27, 0x52, 0x98, 0x2e,
+ 0xd7, 0xc7, 0x2a, 0x25, 0xb0, 0x86, 0xc0, 0x7f, 0xd3, 0x7f, 0xaf, 0x84, 0x29, 0x50, 0xf1, 0x6f, 0x98, 0x2e, 0x4d,
+ 0xc8, 0x2a, 0x25, 0xae, 0x8a, 0xaa, 0x88, 0xf2, 0x6e, 0x2b, 0x50, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x7f, 0x98, 0x2e,
+ 0xb6, 0xc8, 0xe0, 0x6e, 0x00, 0xb2, 0x32, 0x2f, 0x33, 0x54, 0x83, 0x86, 0xf1, 0x6f, 0xc3, 0x7f, 0x04, 0x30, 0x30,
+ 0x30, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0xe3, 0x30, 0xc5, 0x6f, 0x56, 0x40, 0x45, 0x41, 0x28, 0x08, 0x03, 0x14,
+ 0x0e, 0xb4, 0x08, 0xbc, 0x82, 0x40, 0x10, 0x0a, 0x2f, 0x54, 0x26, 0x05, 0x91, 0x7f, 0x44, 0x28, 0xa3, 0x7f, 0x98,
+ 0x2e, 0xd9, 0xc0, 0x08, 0xb9, 0x33, 0x30, 0x53, 0x09, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x6f, 0x83, 0x17, 0x47, 0x40,
+ 0x6c, 0x15, 0xb2, 0x6f, 0xbe, 0x09, 0x75, 0x0b, 0x90, 0x42, 0x45, 0x42, 0x51, 0x0e, 0x32, 0xbc, 0x02, 0x89, 0xa1,
+ 0x6f, 0x7e, 0x86, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0x04, 0x30, 0x91, 0x6f, 0xd6, 0x2f, 0xeb, 0x6f, 0xa0, 0x5e,
+ 0xb8, 0x2e, 0x03, 0x2e, 0x97, 0x00, 0x1b, 0xbc, 0x60, 0x50, 0x9f, 0xbc, 0x0c, 0xb8, 0xf0, 0x7f, 0x40, 0xb2, 0xeb,
+ 0x7f, 0x2b, 0x2f, 0x03, 0x2e, 0x7f, 0x00, 0x41, 0x40, 0x01, 0x2e, 0xc8, 0x00, 0x01, 0x1a, 0x11, 0x2f, 0x37, 0x58,
+ 0x23, 0x2e, 0xc8, 0x00, 0x10, 0x41, 0xa0, 0x7f, 0x38, 0x81, 0x01, 0x41, 0xd0, 0x7f, 0xb1, 0x7f, 0x98, 0x2e, 0x64,
+ 0xcf, 0xd0, 0x6f, 0x07, 0x80, 0xa1, 0x6f, 0x11, 0x42, 0x00, 0x2e, 0xb1, 0x6f, 0x01, 0x42, 0x11, 0x30, 0x01, 0x2e,
+ 0xfc, 0x00, 0x00, 0xa8, 0x03, 0x30, 0xcb, 0x22, 0x4a, 0x25, 0x01, 0x2e, 0x7f, 0x00, 0x3c, 0x89, 0x35, 0x52, 0x05,
+ 0x54, 0x98, 0x2e, 0xc4, 0xce, 0xc1, 0x6f, 0xf0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x04, 0x2d, 0x01, 0x30, 0xf0, 0x6f,
+ 0x98, 0x2e, 0x95, 0xcf, 0xeb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, 0x03, 0x2e, 0xb3, 0x00, 0x02, 0x32, 0xf0, 0x30, 0x03,
+ 0x31, 0x30, 0x50, 0x8a, 0x08, 0x08, 0x08, 0xcb, 0x08, 0xe0, 0x7f, 0x80, 0xb2, 0xf3, 0x7f, 0xdb, 0x7f, 0x25, 0x2f,
+ 0x03, 0x2e, 0xca, 0x00, 0x41, 0x90, 0x04, 0x2f, 0x01, 0x30, 0x23, 0x2e, 0xca, 0x00, 0x98, 0x2e, 0x3f, 0x03, 0xc0,
+ 0xb2, 0x05, 0x2f, 0x03, 0x2e, 0xda, 0x00, 0x00, 0x30, 0x41, 0x04, 0x23, 0x2e, 0xda, 0x00, 0x98, 0x2e, 0x92, 0xb2,
+ 0x10, 0x25, 0xf0, 0x6f, 0x00, 0xb2, 0x05, 0x2f, 0x01, 0x2e, 0xda, 0x00, 0x02, 0x30, 0x10, 0x04, 0x21, 0x2e, 0xda,
+ 0x00, 0x40, 0xb2, 0x01, 0x2f, 0x23, 0x2e, 0xc8, 0x01, 0xdb, 0x6f, 0xe0, 0x6f, 0xd0, 0x5f, 0x80, 0x2e, 0x95, 0xcf,
+ 0x01, 0x30, 0xe0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x11, 0x30, 0x23, 0x2e, 0xca, 0x00, 0xdb, 0x6f, 0xd0, 0x5f, 0xb8,
+ 0x2e, 0xd0, 0x50, 0x0a, 0x25, 0x33, 0x84, 0x55, 0x50, 0xd2, 0x7f, 0xe2, 0x7f, 0x03, 0x8c, 0xc0, 0x7f, 0xbb, 0x7f,
+ 0x00, 0x30, 0x05, 0x5a, 0x39, 0x54, 0x51, 0x41, 0xa5, 0x7f, 0x96, 0x7f, 0x80, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0x05,
+ 0x30, 0xf5, 0x7f, 0x20, 0x25, 0x91, 0x6f, 0x3b, 0x58, 0x3d, 0x5c, 0x3b, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xc1, 0x6f,
+ 0xd5, 0x6f, 0x52, 0x40, 0x50, 0x43, 0xc1, 0x7f, 0xd5, 0x7f, 0x10, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98,
+ 0x2e, 0x74, 0xc0, 0x86, 0x6f, 0x30, 0x28, 0x92, 0x6f, 0x82, 0x8c, 0xa5, 0x6f, 0x6f, 0x52, 0x69, 0x0e, 0x39, 0x54,
+ 0xdb, 0x2f, 0x19, 0xa0, 0x15, 0x30, 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x81, 0x01, 0x0a, 0x2d, 0x01, 0x2e, 0x81,
+ 0x01, 0x05, 0x28, 0x42, 0x36, 0x21, 0x2e, 0x81, 0x01, 0x02, 0x0e, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50,
+ 0x12, 0x30, 0x01, 0x40, 0x98, 0x2e, 0xfe, 0xc9, 0x51, 0x6f, 0x0b, 0x5c, 0x8e, 0x0e, 0x3b, 0x6f, 0x57, 0x58, 0x02,
+ 0x30, 0x21, 0x2e, 0x95, 0x01, 0x45, 0x6f, 0x2a, 0x8d, 0xd2, 0x7f, 0xcb, 0x7f, 0x13, 0x2f, 0x02, 0x30, 0x3f, 0x50,
+ 0xd2, 0x7f, 0xa8, 0x0e, 0x0e, 0x2f, 0xc0, 0x6f, 0x53, 0x54, 0x02, 0x00, 0x51, 0x54, 0x42, 0x0e, 0x10, 0x30, 0x59,
+ 0x52, 0x02, 0x30, 0x01, 0x2f, 0x00, 0x2e, 0x03, 0x2d, 0x50, 0x42, 0x42, 0x42, 0x12, 0x30, 0xd2, 0x7f, 0x80, 0xb2,
+ 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x80, 0x01, 0x12, 0x2d, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x80, 0x05, 0x2e, 0x80,
+ 0x01, 0x11, 0x30, 0x91, 0x28, 0x00, 0x40, 0x25, 0x2e, 0x80, 0x01, 0x10, 0x0e, 0x05, 0x2f, 0x01, 0x2e, 0x7f, 0x01,
+ 0x01, 0x90, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x00, 0x2e, 0xa0, 0x41, 0x01, 0x90, 0xa6, 0x7f, 0x90, 0x2e, 0xe3,
+ 0xb4, 0x01, 0x2e, 0x95, 0x01, 0x00, 0xa8, 0x90, 0x2e, 0xe3, 0xb4, 0x5b, 0x54, 0x95, 0x80, 0x82, 0x40, 0x80, 0xb2,
+ 0x02, 0x40, 0x2d, 0x8c, 0x3f, 0x52, 0x96, 0x7f, 0x90, 0x2e, 0xc2, 0xb3, 0x29, 0x0e, 0x76, 0x2f, 0x01, 0x2e, 0xc9,
+ 0x00, 0x00, 0x40, 0x81, 0x28, 0x45, 0x52, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, 0x5d, 0x54, 0x80, 0x7f, 0x00, 0x2e,
+ 0xa1, 0x40, 0x72, 0x7f, 0x82, 0x80, 0x82, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74,
+ 0xc0, 0x62, 0x6f, 0x05, 0x30, 0x87, 0x40, 0xc0, 0x91, 0x04, 0x30, 0x05, 0x2f, 0x05, 0x2e, 0x83, 0x01, 0x80, 0xb2,
+ 0x14, 0x30, 0x00, 0x2f, 0x04, 0x30, 0x05, 0x2e, 0xc9, 0x00, 0x73, 0x6f, 0x81, 0x40, 0xe2, 0x40, 0x69, 0x04, 0x11,
+ 0x0f, 0xe1, 0x40, 0x16, 0x30, 0xfe, 0x29, 0xcb, 0x40, 0x02, 0x2f, 0x83, 0x6f, 0x83, 0x0f, 0x22, 0x2f, 0x47, 0x56,
+ 0x13, 0x0f, 0x12, 0x30, 0x77, 0x2f, 0x49, 0x54, 0x42, 0x0e, 0x12, 0x30, 0x73, 0x2f, 0x00, 0x91, 0x0a, 0x2f, 0x01,
+ 0x2e, 0x8b, 0x01, 0x19, 0xa8, 0x02, 0x30, 0x6c, 0x2f, 0x63, 0x50, 0x00, 0x2e, 0x17, 0x42, 0x05, 0x42, 0x68, 0x2c,
+ 0x12, 0x30, 0x0b, 0x25, 0x08, 0x0f, 0x50, 0x30, 0x02, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x03, 0x2d, 0x40, 0x30, 0x21,
+ 0x2e, 0x83, 0x01, 0x2b, 0x2e, 0x85, 0x01, 0x5a, 0x2c, 0x12, 0x30, 0x00, 0x91, 0x2b, 0x25, 0x04, 0x2f, 0x63, 0x50,
+ 0x02, 0x30, 0x17, 0x42, 0x17, 0x2c, 0x02, 0x42, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, 0xc0, 0x05,
+ 0x2e, 0xc9, 0x00, 0x81, 0x84, 0x5b, 0x30, 0x82, 0x40, 0x37, 0x2e, 0x83, 0x01, 0x02, 0x0e, 0x07, 0x2f, 0x5f, 0x52,
+ 0x40, 0x30, 0x62, 0x40, 0x41, 0x40, 0x91, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x05, 0x30, 0x2b, 0x2e, 0x85,
+ 0x01, 0x12, 0x30, 0x36, 0x2c, 0x16, 0x30, 0x15, 0x25, 0x81, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e,
+ 0x74, 0xc0, 0x19, 0xa2, 0x16, 0x30, 0x15, 0x2f, 0x05, 0x2e, 0x97, 0x01, 0x80, 0x6f, 0x82, 0x0e, 0x05, 0x2f, 0x01,
+ 0x2e, 0x86, 0x01, 0x06, 0x28, 0x21, 0x2e, 0x86, 0x01, 0x0b, 0x2d, 0x03, 0x2e, 0x87, 0x01, 0x5f, 0x54, 0x4e, 0x28,
+ 0x91, 0x42, 0x00, 0x2e, 0x82, 0x40, 0x90, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x88, 0x01, 0x02, 0x30, 0x13, 0x2c, 0x05,
+ 0x30, 0xc0, 0x6f, 0x08, 0x1c, 0xa8, 0x0f, 0x16, 0x30, 0x05, 0x30, 0x5b, 0x50, 0x09, 0x2f, 0x02, 0x80, 0x2d, 0x2e,
+ 0x82, 0x01, 0x05, 0x42, 0x05, 0x80, 0x00, 0x2e, 0x02, 0x42, 0x3e, 0x80, 0x00, 0x2e, 0x06, 0x42, 0x02, 0x30, 0x90,
+ 0x6f, 0x3e, 0x88, 0x01, 0x40, 0x04, 0x41, 0x4c, 0x28, 0x01, 0x42, 0x07, 0x80, 0x10, 0x25, 0x24, 0x40, 0x00, 0x40,
+ 0x00, 0xa8, 0xf5, 0x22, 0x23, 0x29, 0x44, 0x42, 0x7a, 0x82, 0x7e, 0x88, 0x43, 0x40, 0x04, 0x41, 0x00, 0xab, 0xf5,
+ 0x23, 0xdf, 0x28, 0x43, 0x42, 0xd9, 0xa0, 0x14, 0x2f, 0x00, 0x90, 0x02, 0x2f, 0xd2, 0x6f, 0x81, 0xb2, 0x05, 0x2f,
+ 0x63, 0x54, 0x06, 0x28, 0x90, 0x42, 0x85, 0x42, 0x09, 0x2c, 0x02, 0x30, 0x5b, 0x50, 0x03, 0x80, 0x29, 0x2e, 0x7e,
+ 0x01, 0x2b, 0x2e, 0x82, 0x01, 0x05, 0x42, 0x12, 0x30, 0x2b, 0x2e, 0x83, 0x01, 0x45, 0x82, 0x00, 0x2e, 0x40, 0x40,
+ 0x7a, 0x82, 0x02, 0xa0, 0x08, 0x2f, 0x63, 0x50, 0x3b, 0x30, 0x15, 0x42, 0x05, 0x42, 0x37, 0x80, 0x37, 0x2e, 0x7e,
+ 0x01, 0x05, 0x42, 0x12, 0x30, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x8c, 0x40, 0x40, 0x84, 0x41, 0x7a, 0x8c, 0x04, 0x0f,
+ 0x03, 0x2f, 0x01, 0x2e, 0x8b, 0x01, 0x19, 0xa4, 0x04, 0x2f, 0x2b, 0x2e, 0x82, 0x01, 0x98, 0x2e, 0xf3, 0x03, 0x12,
+ 0x30, 0x81, 0x90, 0x61, 0x52, 0x08, 0x2f, 0x65, 0x42, 0x65, 0x42, 0x43, 0x80, 0x39, 0x84, 0x82, 0x88, 0x05, 0x42,
+ 0x45, 0x42, 0x85, 0x42, 0x05, 0x43, 0x00, 0x2e, 0x80, 0x41, 0x00, 0x90, 0x90, 0x2e, 0xe1, 0xb4, 0x65, 0x54, 0xc1,
+ 0x6f, 0x80, 0x40, 0x00, 0xb2, 0x43, 0x58, 0x69, 0x50, 0x44, 0x2f, 0x55, 0x5c, 0xb7, 0x87, 0x8c, 0x0f, 0x0d, 0x2e,
+ 0x96, 0x01, 0xc4, 0x40, 0x36, 0x2f, 0x41, 0x56, 0x8b, 0x0e, 0x2a, 0x2f, 0x0b, 0x52, 0xa1, 0x0e, 0x0a, 0x2f, 0x05,
+ 0x2e, 0x8f, 0x01, 0x14, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x4b, 0x54, 0x02, 0x0f, 0x69, 0x50, 0x05, 0x30, 0x65, 0x54,
+ 0x15, 0x2f, 0x03, 0x2e, 0x8e, 0x01, 0x4d, 0x5c, 0x8e, 0x0f, 0x3a, 0x2f, 0x05, 0x2e, 0x8f, 0x01, 0x98, 0x2e, 0xfe,
+ 0xc9, 0x4f, 0x54, 0x82, 0x0f, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x30, 0x2f, 0x6d, 0x52, 0x15, 0x30, 0x42, 0x8c,
+ 0x45, 0x42, 0x04, 0x30, 0x2b, 0x2c, 0x84, 0x43, 0x6b, 0x52, 0x42, 0x8c, 0x00, 0x2e, 0x85, 0x43, 0x15, 0x30, 0x24,
+ 0x2c, 0x45, 0x42, 0x8e, 0x0f, 0x20, 0x2f, 0x0d, 0x2e, 0x8e, 0x01, 0xb1, 0x0e, 0x1c, 0x2f, 0x23, 0x2e, 0x8e, 0x01,
+ 0x1a, 0x2d, 0x0e, 0x0e, 0x17, 0x2f, 0xa1, 0x0f, 0x15, 0x2f, 0x23, 0x2e, 0x8d, 0x01, 0x13, 0x2d, 0x98, 0x2e, 0x74,
+ 0xc0, 0x43, 0x54, 0xc2, 0x0e, 0x0a, 0x2f, 0x65, 0x50, 0x04, 0x80, 0x0b, 0x30, 0x06, 0x82, 0x0b, 0x42, 0x79, 0x80,
+ 0x41, 0x40, 0x12, 0x30, 0x25, 0x2e, 0x8c, 0x01, 0x01, 0x42, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x84, 0x82, 0x43,
+ 0x84, 0xbe, 0x8c, 0x84, 0x40, 0x86, 0x41, 0x26, 0x29, 0x94, 0x42, 0xbe, 0x8e, 0xd5, 0x7f, 0x19, 0xa1, 0x43, 0x40,
+ 0x0b, 0x2e, 0x8c, 0x01, 0x84, 0x40, 0xc7, 0x41, 0x5d, 0x29, 0x27, 0x29, 0x45, 0x42, 0x84, 0x42, 0xc2, 0x7f, 0x01,
+ 0x2f, 0xc0, 0xb3, 0x1d, 0x2f, 0x05, 0x2e, 0x94, 0x01, 0x99, 0xa0, 0x01, 0x2f, 0x80, 0xb3, 0x13, 0x2f, 0x80, 0xb3,
+ 0x18, 0x2f, 0xc0, 0xb3, 0x16, 0x2f, 0x12, 0x40, 0x01, 0x40, 0x92, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x92, 0x6f, 0x10,
+ 0x0f, 0x20, 0x30, 0x03, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x0a, 0x2d, 0x21, 0x2e, 0x7e, 0x01, 0x07, 0x2d,
+ 0x20, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x03, 0x2d, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0xc2, 0x6f, 0x01, 0x2e, 0xc9,
+ 0x00, 0xbc, 0x84, 0x02, 0x80, 0x82, 0x40, 0x00, 0x40, 0x90, 0x0e, 0xd5, 0x6f, 0x02, 0x2f, 0x15, 0x30, 0x98, 0x2e,
+ 0xf3, 0x03, 0x41, 0x91, 0x05, 0x30, 0x07, 0x2f, 0x67, 0x50, 0x3d, 0x80, 0x2b, 0x2e, 0x8f, 0x01, 0x05, 0x42, 0x04,
+ 0x80, 0x00, 0x2e, 0x05, 0x42, 0x02, 0x2c, 0x00, 0x30, 0x00, 0x30, 0xa2, 0x6f, 0x98, 0x8a, 0x86, 0x40, 0x80, 0xa7,
+ 0x05, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0xc0, 0x30, 0x21, 0x2e, 0x95, 0x01, 0x06, 0x25, 0x1a, 0x25, 0xe2, 0x6f, 0x76,
+ 0x82, 0x96, 0x40, 0x56, 0x43, 0x51, 0x0e, 0xfb, 0x2f, 0xbb, 0x6f, 0x30, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xb8, 0x00,
+ 0x01, 0x31, 0x41, 0x08, 0x40, 0xb2, 0x20, 0x50, 0xf2, 0x30, 0x02, 0x08, 0xfb, 0x7f, 0x01, 0x30, 0x10, 0x2f, 0x05,
+ 0x2e, 0xcc, 0x00, 0x81, 0x90, 0xe0, 0x7f, 0x03, 0x2f, 0x23, 0x2e, 0xcc, 0x00, 0x98, 0x2e, 0x55, 0xb6, 0x98, 0x2e,
+ 0x1d, 0xb5, 0x10, 0x25, 0xfb, 0x6f, 0xe0, 0x6f, 0xe0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, 0x98, 0x2e, 0x95, 0xcf, 0x10,
+ 0x30, 0x21, 0x2e, 0xcc, 0x00, 0xfb, 0x6f, 0xe0, 0x5f, 0xb8, 0x2e, 0x00, 0x51, 0x05, 0x58, 0xeb, 0x7f, 0x2a, 0x25,
+ 0x89, 0x52, 0x6f, 0x5a, 0x89, 0x50, 0x13, 0x41, 0x06, 0x40, 0xb3, 0x01, 0x16, 0x42, 0xcb, 0x16, 0x06, 0x40, 0xf3,
+ 0x02, 0x13, 0x42, 0x65, 0x0e, 0xf5, 0x2f, 0x05, 0x40, 0x14, 0x30, 0x2c, 0x29, 0x04, 0x42, 0x08, 0xa1, 0x00, 0x30,
+ 0x90, 0x2e, 0x52, 0xb6, 0xb3, 0x88, 0xb0, 0x8a, 0xb6, 0x84, 0xa4, 0x7f, 0xc4, 0x7f, 0xb5, 0x7f, 0xd5, 0x7f, 0x92,
+ 0x7f, 0x73, 0x30, 0x04, 0x30, 0x55, 0x40, 0x42, 0x40, 0x8a, 0x17, 0xf3, 0x08, 0x6b, 0x01, 0x90, 0x02, 0x53, 0xb8,
+ 0x4b, 0x82, 0xad, 0xbe, 0x71, 0x7f, 0x45, 0x0a, 0x09, 0x54, 0x84, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0xa3, 0x6f, 0x7b,
+ 0x54, 0xd0, 0x42, 0xa3, 0x7f, 0xf2, 0x7f, 0x60, 0x7f, 0x20, 0x25, 0x71, 0x6f, 0x75, 0x5a, 0x77, 0x58, 0x79, 0x5c,
+ 0x75, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xb1, 0x6f, 0x62, 0x6f, 0x50, 0x42, 0xb1, 0x7f, 0xb3, 0x30, 0x10, 0x25, 0x98,
+ 0x2e, 0x0f, 0xca, 0x84, 0x6f, 0x20, 0x29, 0x71, 0x6f, 0x92, 0x6f, 0xa5, 0x6f, 0x76, 0x82, 0x6a, 0x0e, 0x73, 0x30,
+ 0x00, 0x30, 0xd0, 0x2f, 0xd2, 0x6f, 0xd1, 0x7f, 0xb4, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x02,
+ 0x0a, 0xc2, 0x6f, 0xc0, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x42, 0x0a, 0xc0, 0x6f, 0x08, 0x17,
+ 0x41, 0x18, 0x89, 0x16, 0xe1, 0x18, 0xd0, 0x18, 0xa1, 0x7f, 0x27, 0x25, 0x16, 0x25, 0x98, 0x2e, 0x79, 0xc0, 0x8b,
+ 0x54, 0x90, 0x7f, 0xb3, 0x30, 0x82, 0x40, 0x80, 0x90, 0x0d, 0x2f, 0x7d, 0x52, 0x92, 0x6f, 0x98, 0x2e, 0x0f, 0xca,
+ 0xb2, 0x6f, 0x90, 0x0e, 0x06, 0x2f, 0x8b, 0x50, 0x14, 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x14, 0x42, 0x12, 0x42, 0x01,
+ 0x42, 0x00, 0x2e, 0x31, 0x6f, 0x98, 0x2e, 0x74, 0xc0, 0x41, 0x6f, 0x80, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x82, 0x6f,
+ 0x10, 0x04, 0x43, 0x52, 0x01, 0x0f, 0x05, 0x2e, 0xcb, 0x00, 0x00, 0x30, 0x04, 0x30, 0x21, 0x2f, 0x51, 0x6f, 0x43,
+ 0x58, 0x8c, 0x0e, 0x04, 0x30, 0x1c, 0x2f, 0x85, 0x88, 0x41, 0x6f, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, 0x16, 0x2f,
+ 0x84, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x0f, 0x2f, 0x82, 0x88, 0x31, 0x6f, 0x04,
+ 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x08, 0x2f, 0x83, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30,
+ 0x02, 0x2f, 0x21, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x00, 0x91, 0x14, 0x2f, 0x03, 0x2e, 0xa1, 0x01, 0x41, 0x90, 0x0e,
+ 0x2f, 0x03, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x4c, 0x28, 0x23, 0x2e, 0xad, 0x01, 0x46, 0xa0, 0x06, 0x2f, 0x81, 0x84,
+ 0x8d, 0x52, 0x48, 0x82, 0x82, 0x40, 0x21, 0x2e, 0xa1, 0x01, 0x42, 0x42, 0x5c, 0x2c, 0x02, 0x30, 0x05, 0x2e, 0xaa,
+ 0x01, 0x80, 0xb2, 0x02, 0x30, 0x55, 0x2f, 0x03, 0x2e, 0xa9, 0x01, 0x92, 0x6f, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca,
+ 0xb2, 0x6f, 0x90, 0x0f, 0x00, 0x30, 0x02, 0x30, 0x4a, 0x2f, 0xa2, 0x6f, 0x87, 0x52, 0x91, 0x00, 0x85, 0x52, 0x51,
+ 0x0e, 0x02, 0x2f, 0x00, 0x2e, 0x43, 0x2c, 0x02, 0x30, 0xc2, 0x6f, 0x7f, 0x52, 0x91, 0x0e, 0x02, 0x30, 0x3c, 0x2f,
+ 0x51, 0x6f, 0x81, 0x54, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0xb3, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x32,
+ 0x6f, 0xc0, 0x7f, 0xb3, 0x30, 0x12, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x42, 0x6f, 0xb0, 0x7f, 0xb3, 0x30, 0x12, 0x25,
+ 0x98, 0x2e, 0x0f, 0xca, 0xb2, 0x6f, 0x90, 0x28, 0x83, 0x52, 0x98, 0x2e, 0xfe, 0xc9, 0xc2, 0x6f, 0x90, 0x0f, 0x00,
+ 0x30, 0x02, 0x30, 0x1d, 0x2f, 0x05, 0x2e, 0xa1, 0x01, 0x80, 0xb2, 0x12, 0x30, 0x0f, 0x2f, 0x42, 0x6f, 0x03, 0x2e,
+ 0xab, 0x01, 0x91, 0x0e, 0x02, 0x30, 0x12, 0x2f, 0x52, 0x6f, 0x03, 0x2e, 0xac, 0x01, 0x91, 0x0f, 0x02, 0x30, 0x0c,
+ 0x2f, 0x21, 0x2e, 0xaa, 0x01, 0x0a, 0x2c, 0x12, 0x30, 0x03, 0x2e, 0xcb, 0x00, 0x8d, 0x58, 0x08, 0x89, 0x41, 0x40,
+ 0x11, 0x43, 0x00, 0x43, 0x25, 0x2e, 0xa1, 0x01, 0xd4, 0x6f, 0x8f, 0x52, 0x00, 0x43, 0x3a, 0x89, 0x00, 0x2e, 0x10,
+ 0x43, 0x10, 0x43, 0x61, 0x0e, 0xfb, 0x2f, 0x03, 0x2e, 0xa0, 0x01, 0x11, 0x1a, 0x02, 0x2f, 0x02, 0x25, 0x21, 0x2e,
+ 0xa0, 0x01, 0xeb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x91, 0x52, 0x10, 0x30, 0x02, 0x30, 0x95, 0x56, 0x52, 0x42, 0x4b,
+ 0x0e, 0xfc, 0x2f, 0x8d, 0x54, 0x88, 0x82, 0x93, 0x56, 0x80, 0x42, 0x53, 0x42, 0x40, 0x42, 0x42, 0x86, 0x83, 0x54,
+ 0xc0, 0x2e, 0xc2, 0x42, 0x00, 0x2e, 0xa3, 0x52, 0x00, 0x51, 0x52, 0x40, 0x47, 0x40, 0x1a, 0x25, 0x01, 0x2e, 0x97,
+ 0x00, 0x8f, 0xbe, 0x72, 0x86, 0xfb, 0x7f, 0x0b, 0x30, 0x7c, 0xbf, 0xa5, 0x50, 0x10, 0x08, 0xdf, 0xba, 0x70, 0x88,
+ 0xf8, 0xbf, 0xcb, 0x42, 0xd3, 0x7f, 0x6c, 0xbb, 0xfc, 0xbb, 0xc5, 0x0a, 0x90, 0x7f, 0x1b, 0x7f, 0x0b, 0x43, 0xc0,
+ 0xb2, 0xe5, 0x7f, 0xb7, 0x7f, 0xa6, 0x7f, 0xc4, 0x7f, 0x90, 0x2e, 0x1c, 0xb7, 0x07, 0x2e, 0xd2, 0x00, 0xc0, 0xb2,
+ 0x0b, 0x2f, 0x97, 0x52, 0x01, 0x2e, 0xcd, 0x00, 0x82, 0x7f, 0x98, 0x2e, 0xbb, 0xcc, 0x0b, 0x30, 0x37, 0x2e, 0xd2,
+ 0x00, 0x82, 0x6f, 0x90, 0x6f, 0x1a, 0x25, 0x00, 0xb2, 0x8b, 0x7f, 0x14, 0x2f, 0xa6, 0xbd, 0x25, 0xbd, 0xb6, 0xb9,
+ 0x2f, 0xb9, 0x80, 0xb2, 0xd4, 0xb0, 0x0c, 0x2f, 0x99, 0x54, 0x9b, 0x56, 0x0b, 0x30, 0x0b, 0x2e, 0xb1, 0x00, 0xa1,
+ 0x58, 0x9b, 0x42, 0xdb, 0x42, 0x6c, 0x09, 0x2b, 0x2e, 0xb1, 0x00, 0x8b, 0x42, 0xcb, 0x42, 0x86, 0x7f, 0x73, 0x84,
+ 0xa7, 0x56, 0xc3, 0x08, 0x39, 0x52, 0x05, 0x50, 0x72, 0x7f, 0x63, 0x7f, 0x98, 0x2e, 0xc2, 0xc0, 0xe1, 0x6f, 0x62,
+ 0x6f, 0xd1, 0x0a, 0x01, 0x2e, 0xcd, 0x00, 0xd5, 0x6f, 0xc4, 0x6f, 0x72, 0x6f, 0x97, 0x52, 0x9d, 0x5c, 0x98, 0x2e,
+ 0x06, 0xcd, 0x23, 0x6f, 0x90, 0x6f, 0x99, 0x52, 0xc0, 0xb2, 0x04, 0xbd, 0x54, 0x40, 0xaf, 0xb9, 0x45, 0x40, 0xe1,
+ 0x7f, 0x02, 0x30, 0x06, 0x2f, 0xc0, 0xb2, 0x02, 0x30, 0x03, 0x2f, 0x9b, 0x5c, 0x12, 0x30, 0x94, 0x43, 0x85, 0x43,
+ 0x03, 0xbf, 0x6f, 0xbb, 0x80, 0xb3, 0x20, 0x2f, 0x06, 0x6f, 0x26, 0x01, 0x16, 0x6f, 0x6e, 0x03, 0x45, 0x42, 0xc0,
+ 0x90, 0x29, 0x2e, 0xce, 0x00, 0x9b, 0x52, 0x14, 0x2f, 0x9b, 0x5c, 0x00, 0x2e, 0x93, 0x41, 0x86, 0x41, 0xe3, 0x04,
+ 0xae, 0x07, 0x80, 0xab, 0x04, 0x2f, 0x80, 0x91, 0x0a, 0x2f, 0x86, 0x6f, 0x73, 0x0f, 0x07, 0x2f, 0x83, 0x6f, 0xc0,
+ 0xb2, 0x04, 0x2f, 0x54, 0x42, 0x45, 0x42, 0x12, 0x30, 0x04, 0x2c, 0x11, 0x30, 0x02, 0x2c, 0x11, 0x30, 0x11, 0x30,
+ 0x02, 0xbc, 0x0f, 0xb8, 0xd2, 0x7f, 0x00, 0xb2, 0x0a, 0x2f, 0x01, 0x2e, 0xfc, 0x00, 0x05, 0x2e, 0xc7, 0x01, 0x10,
+ 0x1a, 0x02, 0x2f, 0x21, 0x2e, 0xc7, 0x01, 0x03, 0x2d, 0x02, 0x2c, 0x01, 0x30, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e,
+ 0x95, 0xcf, 0xd1, 0x6f, 0xa0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0xe2, 0x6f, 0x9f, 0x52, 0x01, 0x2e, 0xce, 0x00, 0x82,
+ 0x40, 0x50, 0x42, 0x0c, 0x2c, 0x42, 0x42, 0x11, 0x30, 0x23, 0x2e, 0xd2, 0x00, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e,
+ 0x95, 0xcf, 0xa0, 0x6f, 0x01, 0x30, 0x98, 0x2e, 0x95, 0xcf, 0x00, 0x2e, 0xfb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x83,
+ 0x86, 0x01, 0x30, 0x00, 0x30, 0x94, 0x40, 0x24, 0x18, 0x06, 0x00, 0x53, 0x0e, 0x4f, 0x02, 0xf9, 0x2f, 0xb8, 0x2e,
+ 0xa9, 0x52, 0x00, 0x2e, 0x60, 0x40, 0x41, 0x40, 0x0d, 0xbc, 0x98, 0xbc, 0xc0, 0x2e, 0x01, 0x0a, 0x0f, 0xb8, 0xab,
+ 0x52, 0x53, 0x3c, 0x52, 0x40, 0x40, 0x40, 0x4b, 0x00, 0x82, 0x16, 0x26, 0xb9, 0x01, 0xb8, 0x41, 0x40, 0x10, 0x08,
+ 0x97, 0xb8, 0x01, 0x08, 0xc0, 0x2e, 0x11, 0x30, 0x01, 0x08, 0x43, 0x86, 0x25, 0x40, 0x04, 0x40, 0xd8, 0xbe, 0x2c,
+ 0x0b, 0x22, 0x11, 0x54, 0x42, 0x03, 0x80, 0x4b, 0x0e, 0xf6, 0x2f, 0xb8, 0x2e, 0x9f, 0x50, 0x10, 0x50, 0xad, 0x52,
+ 0x05, 0x2e, 0xd3, 0x00, 0xfb, 0x7f, 0x00, 0x2e, 0x13, 0x40, 0x93, 0x42, 0x41, 0x0e, 0xfb, 0x2f, 0x98, 0x2e, 0xa5,
+ 0xb7, 0x98, 0x2e, 0x87, 0xcf, 0x01, 0x2e, 0xd9, 0x00, 0x00, 0xb2, 0xfb, 0x6f, 0x0b, 0x2f, 0x01, 0x2e, 0x69, 0xf7,
+ 0xb1, 0x3f, 0x01, 0x08, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd9, 0x00, 0x21, 0x2e, 0x69, 0xf7, 0x80, 0x2e, 0x7a,
+ 0xb7, 0xf0, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xc0, 0xf8, 0x03, 0x2e, 0xfc, 0xf5, 0x15, 0x54, 0xaf, 0x56, 0x82, 0x08,
+ 0x0b, 0x2e, 0x69, 0xf7, 0xcb, 0x0a, 0xb1, 0x58, 0x80, 0x90, 0xdd, 0xbe, 0x4c, 0x08, 0x5f, 0xb9, 0x59, 0x22, 0x80,
+ 0x90, 0x07, 0x2f, 0x03, 0x34, 0xc3, 0x08, 0xf2, 0x3a, 0x0a, 0x08, 0x02, 0x35, 0xc0, 0x90, 0x4a, 0x0a, 0x48, 0x22,
+ 0xc0, 0x2e, 0x23, 0x2e, 0xfc, 0xf5, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x56, 0xc7, 0x98, 0x2e, 0x49, 0xc3, 0x10,
+ 0x30, 0xfb, 0x6f, 0xf0, 0x5f, 0x21, 0x2e, 0xcc, 0x00, 0x21, 0x2e, 0xca, 0x00, 0xb8, 0x2e, 0x03, 0x2e, 0xd3, 0x00,
+ 0x16, 0xb8, 0x02, 0x34, 0x4a, 0x0c, 0x21, 0x2e, 0x2d, 0xf5, 0xc0, 0x2e, 0x23, 0x2e, 0xd3, 0x00, 0x03, 0xbc, 0x21,
+ 0x2e, 0xd5, 0x00, 0x03, 0x2e, 0xd5, 0x00, 0x40, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x30, 0x05, 0x2f,
+ 0x05, 0x2e, 0xd8, 0x00, 0x80, 0x90, 0x01, 0x2f, 0x23, 0x2e, 0x6f, 0xf5, 0xc0, 0x2e, 0x21, 0x2e, 0xd9, 0x00, 0x11,
+ 0x30, 0x81, 0x08, 0x01, 0x2e, 0x6a, 0xf7, 0x71, 0x3f, 0x23, 0xbd, 0x01, 0x08, 0x02, 0x0a, 0xc0, 0x2e, 0x21, 0x2e,
+ 0x6a, 0xf7, 0x30, 0x25, 0x00, 0x30, 0x21, 0x2e, 0x5a, 0xf5, 0x10, 0x50, 0x21, 0x2e, 0x7b, 0x00, 0x21, 0x2e, 0x7c,
+ 0x00, 0xfb, 0x7f, 0x98, 0x2e, 0xc3, 0xb7, 0x40, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0x03, 0x25,
+ 0x80, 0x2e, 0xaf, 0xb7, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x01, 0x2e, 0x5d, 0xf7, 0x08, 0xbc, 0x80, 0xac, 0x0e, 0xbb, 0x02, 0x2f,
+ 0x00, 0x30, 0x41, 0x04, 0x82, 0x06, 0xc0, 0xa4, 0x00, 0x30, 0x11, 0x2f, 0x40, 0xa9, 0x03, 0x2f, 0x40, 0x91, 0x0d,
+ 0x2f, 0x00, 0xa7, 0x0b, 0x2f, 0x80, 0xb3, 0xb3, 0x58, 0x02, 0x2f, 0x90, 0xa1, 0x26, 0x13, 0x20, 0x23, 0x80, 0x90,
+ 0x10, 0x30, 0x01, 0x2f, 0xcc, 0x0e, 0x00, 0x2f, 0x00, 0x30, 0xb8, 0x2e, 0xb5, 0x50, 0x18, 0x08, 0x08, 0xbc, 0x88,
+ 0xb6, 0x0d, 0x17, 0xc6, 0xbd, 0x56, 0xbc, 0xb7, 0x58, 0xda, 0xba, 0x04, 0x01, 0x1d, 0x0a, 0x10, 0x50, 0x05, 0x30,
+ 0x32, 0x25, 0x45, 0x03, 0xfb, 0x7f, 0xf6, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x37, 0xca, 0x16, 0xb5, 0x9a, 0xbc, 0x06,
+ 0xb8, 0x80, 0xa8, 0x41, 0x0a, 0x0e, 0x2f, 0x80, 0x90, 0x02, 0x2f, 0x2d, 0x50, 0x48, 0x0f, 0x09, 0x2f, 0xbf, 0xa0,
+ 0x04, 0x2f, 0xbf, 0x90, 0x06, 0x2f, 0xb7, 0x54, 0xca, 0x0f, 0x03, 0x2f, 0x00, 0x2e, 0x02, 0x2c, 0xb7, 0x52, 0x2d,
+ 0x52, 0xf2, 0x33, 0x98, 0x2e, 0xd9, 0xc0, 0xfb, 0x6f, 0xf1, 0x37, 0xc0, 0x2e, 0x01, 0x08, 0xf0, 0x5f, 0xbf, 0x56,
+ 0xb9, 0x54, 0xd0, 0x40, 0xc4, 0x40, 0x0b, 0x2e, 0xfd, 0xf3, 0xbf, 0x52, 0x90, 0x42, 0x94, 0x42, 0x95, 0x42, 0x05,
+ 0x30, 0xc1, 0x50, 0x0f, 0x88, 0x06, 0x40, 0x04, 0x41, 0x96, 0x42, 0xc5, 0x42, 0x48, 0xbe, 0x73, 0x30, 0x0d, 0x2e,
+ 0xd8, 0x00, 0x4f, 0xba, 0x84, 0x42, 0x03, 0x42, 0x81, 0xb3, 0x02, 0x2f, 0x2b, 0x2e, 0x6f, 0xf5, 0x06, 0x2d, 0x05,
+ 0x2e, 0x77, 0xf7, 0xbd, 0x56, 0x93, 0x08, 0x25, 0x2e, 0x77, 0xf7, 0xbb, 0x54, 0x25, 0x2e, 0xc2, 0xf5, 0x07, 0x2e,
+ 0xfd, 0xf3, 0x42, 0x30, 0xb4, 0x33, 0xda, 0x0a, 0x4c, 0x00, 0x27, 0x2e, 0xfd, 0xf3, 0x43, 0x40, 0xd4, 0x3f, 0xdc,
+ 0x08, 0x43, 0x42, 0x00, 0x2e, 0x00, 0x2e, 0x43, 0x40, 0x24, 0x30, 0xdc, 0x0a, 0x43, 0x42, 0x04, 0x80, 0x03, 0x2e,
+ 0xfd, 0xf3, 0x4a, 0x0a, 0x23, 0x2e, 0xfd, 0xf3, 0x61, 0x34, 0xc0, 0x2e, 0x01, 0x42, 0x00, 0x2e, 0x60, 0x50, 0x1a,
+ 0x25, 0x7a, 0x86, 0xe0, 0x7f, 0xf3, 0x7f, 0x03, 0x25, 0xc3, 0x52, 0x41, 0x84, 0xdb, 0x7f, 0x33, 0x30, 0x98, 0x2e,
+ 0x16, 0xc2, 0x1a, 0x25, 0x7d, 0x82, 0xf0, 0x6f, 0xe2, 0x6f, 0x32, 0x25, 0x16, 0x40, 0x94, 0x40, 0x26, 0x01, 0x85,
+ 0x40, 0x8e, 0x17, 0xc4, 0x42, 0x6e, 0x03, 0x95, 0x42, 0x41, 0x0e, 0xf4, 0x2f, 0xdb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e,
+ 0xb0, 0x51, 0xfb, 0x7f, 0x98, 0x2e, 0xe8, 0x0d, 0x5a, 0x25, 0x98, 0x2e, 0x0f, 0x0e, 0xcb, 0x58, 0x32, 0x87, 0xc4,
+ 0x7f, 0x65, 0x89, 0x6b, 0x8d, 0xc5, 0x5a, 0x65, 0x7f, 0xe1, 0x7f, 0x83, 0x7f, 0xa6, 0x7f, 0x74, 0x7f, 0xd0, 0x7f,
+ 0xb6, 0x7f, 0x94, 0x7f, 0x17, 0x30, 0xc7, 0x52, 0xc9, 0x54, 0x51, 0x7f, 0x00, 0x2e, 0x85, 0x6f, 0x42, 0x7f, 0x00,
+ 0x2e, 0x51, 0x41, 0x45, 0x81, 0x42, 0x41, 0x13, 0x40, 0x3b, 0x8a, 0x00, 0x40, 0x4b, 0x04, 0xd0, 0x06, 0xc0, 0xac,
+ 0x85, 0x7f, 0x02, 0x2f, 0x02, 0x30, 0x51, 0x04, 0xd3, 0x06, 0x41, 0x84, 0x05, 0x30, 0x5d, 0x02, 0xc9, 0x16, 0xdf,
+ 0x08, 0xd3, 0x00, 0x8d, 0x02, 0xaf, 0xbc, 0xb1, 0xb9, 0x59, 0x0a, 0x65, 0x6f, 0x11, 0x43, 0xa1, 0xb4, 0x52, 0x41,
+ 0x53, 0x41, 0x01, 0x43, 0x34, 0x7f, 0x65, 0x7f, 0x26, 0x31, 0xe5, 0x6f, 0xd4, 0x6f, 0x98, 0x2e, 0x37, 0xca, 0x32,
+ 0x6f, 0x75, 0x6f, 0x83, 0x40, 0x42, 0x41, 0x23, 0x7f, 0x12, 0x7f, 0xf6, 0x30, 0x40, 0x25, 0x51, 0x25, 0x98, 0x2e,
+ 0x37, 0xca, 0x14, 0x6f, 0x20, 0x05, 0x70, 0x6f, 0x25, 0x6f, 0x69, 0x07, 0xa2, 0x6f, 0x31, 0x6f, 0x0b, 0x30, 0x04,
+ 0x42, 0x9b, 0x42, 0x8b, 0x42, 0x55, 0x42, 0x32, 0x7f, 0x40, 0xa9, 0xc3, 0x6f, 0x71, 0x7f, 0x02, 0x30, 0xd0, 0x40,
+ 0xc3, 0x7f, 0x03, 0x2f, 0x40, 0x91, 0x15, 0x2f, 0x00, 0xa7, 0x13, 0x2f, 0x00, 0xa4, 0x11, 0x2f, 0x84, 0xbd, 0x98,
+ 0x2e, 0x79, 0xca, 0x55, 0x6f, 0xb7, 0x54, 0x54, 0x41, 0x82, 0x00, 0xf3, 0x3f, 0x45, 0x41, 0xcb, 0x02, 0xf6, 0x30,
+ 0x98, 0x2e, 0x37, 0xca, 0x35, 0x6f, 0xa4, 0x6f, 0x41, 0x43, 0x03, 0x2c, 0x00, 0x43, 0xa4, 0x6f, 0x35, 0x6f, 0x17,
+ 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x93, 0x40, 0x42, 0x82, 0x00, 0x41, 0xc3, 0x00, 0x03, 0x43, 0x51, 0x7f, 0x00, 0x2e,
+ 0x94, 0x40, 0x41, 0x41, 0x4c, 0x02, 0xc4, 0x6f, 0xd1, 0x56, 0x63, 0x0e, 0x74, 0x6f, 0x51, 0x43, 0xa5, 0x7f, 0x8a,
+ 0x2f, 0x09, 0x2e, 0xd8, 0x00, 0x01, 0xb3, 0x21, 0x2f, 0xcb, 0x58, 0x90, 0x6f, 0x13, 0x41, 0xb6, 0x6f, 0xe4, 0x7f,
+ 0x00, 0x2e, 0x91, 0x41, 0x14, 0x40, 0x92, 0x41, 0x15, 0x40, 0x17, 0x2e, 0x6f, 0xf5, 0xb6, 0x7f, 0xd0, 0x7f, 0xcb,
+ 0x7f, 0x98, 0x2e, 0x00, 0x0c, 0x07, 0x15, 0xc2, 0x6f, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0xc3, 0xa3, 0xc1, 0x8f,
+ 0xe4, 0x6f, 0xd0, 0x6f, 0xe6, 0x2f, 0x14, 0x30, 0x05, 0x2e, 0x6f, 0xf5, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0x18,
+ 0x2d, 0xcd, 0x56, 0x04, 0x32, 0xb5, 0x6f, 0x1c, 0x01, 0x51, 0x41, 0x52, 0x41, 0xc3, 0x40, 0xb5, 0x7f, 0xe4, 0x7f,
+ 0x98, 0x2e, 0x1f, 0x0c, 0xe4, 0x6f, 0x21, 0x87, 0x00, 0x43, 0x04, 0x32, 0xcf, 0x54, 0x5a, 0x0e, 0xef, 0x2f, 0x15,
+ 0x54, 0x09, 0x2e, 0x77, 0xf7, 0x22, 0x0b, 0x29, 0x2e, 0x77, 0xf7, 0xfb, 0x6f, 0x50, 0x5e, 0xb8, 0x2e, 0x10, 0x50,
+ 0x01, 0x2e, 0xd4, 0x00, 0x00, 0xb2, 0xfb, 0x7f, 0x51, 0x2f, 0x01, 0xb2, 0x48, 0x2f, 0x02, 0xb2, 0x42, 0x2f, 0x03,
+ 0x90, 0x56, 0x2f, 0xd7, 0x52, 0x79, 0x80, 0x42, 0x40, 0x81, 0x84, 0x00, 0x40, 0x42, 0x42, 0x98, 0x2e, 0x93, 0x0c,
+ 0xd9, 0x54, 0xd7, 0x50, 0xa1, 0x40, 0x98, 0xbd, 0x82, 0x40, 0x3e, 0x82, 0xda, 0x0a, 0x44, 0x40, 0x8b, 0x16, 0xe3,
+ 0x00, 0x53, 0x42, 0x00, 0x2e, 0x43, 0x40, 0x9a, 0x02, 0x52, 0x42, 0x00, 0x2e, 0x41, 0x40, 0x15, 0x54, 0x4a, 0x0e,
+ 0x3a, 0x2f, 0x3a, 0x82, 0x00, 0x30, 0x41, 0x40, 0x21, 0x2e, 0x85, 0x0f, 0x40, 0xb2, 0x0a, 0x2f, 0x98, 0x2e, 0xb1,
+ 0x0c, 0x98, 0x2e, 0x45, 0x0e, 0x98, 0x2e, 0x5b, 0x0e, 0xfb, 0x6f, 0xf0, 0x5f, 0x00, 0x30, 0x80, 0x2e, 0xce, 0xb7,
+ 0xdd, 0x52, 0xd3, 0x54, 0x42, 0x42, 0x4f, 0x84, 0x73, 0x30, 0xdb, 0x52, 0x83, 0x42, 0x1b, 0x30, 0x6b, 0x42, 0x23,
+ 0x30, 0x27, 0x2e, 0xd7, 0x00, 0x37, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0xd6, 0x00, 0x7a, 0x84, 0x17, 0x2c, 0x42, 0x42,
+ 0x30, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x12, 0x2d, 0x21, 0x30, 0x00, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0x7b,
+ 0xf7, 0x0b, 0x2d, 0x17, 0x30, 0x98, 0x2e, 0x51, 0x0c, 0xd5, 0x50, 0x0c, 0x82, 0x72, 0x30, 0x2f, 0x2e, 0xd4, 0x00,
+ 0x25, 0x2e, 0x7b, 0xf7, 0x40, 0x42, 0x00, 0x2e, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, 0x70, 0x50, 0x0a, 0x25, 0x39,
+ 0x86, 0xfb, 0x7f, 0xe1, 0x32, 0x62, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xb5, 0x56, 0xa5, 0x6f, 0xab, 0x08, 0x91, 0x6f,
+ 0x4b, 0x08, 0xdf, 0x56, 0xc4, 0x6f, 0x23, 0x09, 0x4d, 0xba, 0x93, 0xbc, 0x8c, 0x0b, 0xd1, 0x6f, 0x0b, 0x09, 0xcb,
+ 0x52, 0xe1, 0x5e, 0x56, 0x42, 0xaf, 0x09, 0x4d, 0xba, 0x23, 0xbd, 0x94, 0x0a, 0xe5, 0x6f, 0x68, 0xbb, 0xeb, 0x08,
+ 0xbd, 0xb9, 0x63, 0xbe, 0xfb, 0x6f, 0x52, 0x42, 0xe3, 0x0a, 0xc0, 0x2e, 0x43, 0x42, 0x90, 0x5f, 0xd1, 0x50, 0x03,
+ 0x2e, 0x25, 0xf3, 0x13, 0x40, 0x00, 0x40, 0x9b, 0xbc, 0x9b, 0xb4, 0x08, 0xbd, 0xb8, 0xb9, 0x98, 0xbc, 0xda, 0x0a,
+ 0x08, 0xb6, 0x89, 0x16, 0xc0, 0x2e, 0x19, 0x00, 0x62, 0x02, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x81, 0x0d, 0x01,
+ 0x2e, 0xd4, 0x00, 0x31, 0x30, 0x08, 0x04, 0xfb, 0x6f, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd6, 0x00, 0x21, 0x2e,
+ 0xd7, 0x00, 0xb8, 0x2e, 0x01, 0x2e, 0xd7, 0x00, 0x03, 0x2e, 0xd6, 0x00, 0x48, 0x0e, 0x01, 0x2f, 0x80, 0x2e, 0x1f,
+ 0x0e, 0xb8, 0x2e, 0xe3, 0x50, 0x21, 0x34, 0x01, 0x42, 0x82, 0x30, 0xc1, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x01, 0x00,
+ 0x22, 0x30, 0x01, 0x40, 0x4a, 0x0a, 0x01, 0x42, 0xb8, 0x2e, 0xe3, 0x54, 0xf0, 0x3b, 0x83, 0x40, 0xd8, 0x08, 0xe5,
+ 0x52, 0x83, 0x42, 0x00, 0x30, 0x83, 0x30, 0x50, 0x42, 0xc4, 0x32, 0x27, 0x2e, 0x64, 0xf5, 0x94, 0x00, 0x50, 0x42,
+ 0x40, 0x42, 0xd3, 0x3f, 0x84, 0x40, 0x7d, 0x82, 0xe3, 0x08, 0x40, 0x42, 0x83, 0x42, 0xb8, 0x2e, 0xdd, 0x52, 0x00,
+ 0x30, 0x40, 0x42, 0x7c, 0x86, 0xb9, 0x52, 0x09, 0x2e, 0x70, 0x0f, 0xbf, 0x54, 0xc4, 0x42, 0xd3, 0x86, 0x54, 0x40,
+ 0x55, 0x40, 0x94, 0x42, 0x85, 0x42, 0x21, 0x2e, 0xd7, 0x00, 0x42, 0x40, 0x25, 0x2e, 0xfd, 0xf3, 0xc0, 0x42, 0x7e,
+ 0x82, 0x05, 0x2e, 0x7d, 0x00, 0x80, 0xb2, 0x14, 0x2f, 0x05, 0x2e, 0x89, 0x00, 0x27, 0xbd, 0x2f, 0xb9, 0x80, 0x90,
+ 0x02, 0x2f, 0x21, 0x2e, 0x6f, 0xf5, 0x0c, 0x2d, 0x07, 0x2e, 0x71, 0x0f, 0x14, 0x30, 0x1c, 0x09, 0x05, 0x2e, 0x77,
+ 0xf7, 0xbd, 0x56, 0x47, 0xbe, 0x93, 0x08, 0x94, 0x0a, 0x25, 0x2e, 0x77, 0xf7, 0xe7, 0x54, 0x50, 0x42, 0x4a, 0x0e,
+ 0xfc, 0x2f, 0xb8, 0x2e, 0x50, 0x50, 0x02, 0x30, 0x43, 0x86, 0xe5, 0x50, 0xfb, 0x7f, 0xe3, 0x7f, 0xd2, 0x7f, 0xc0,
+ 0x7f, 0xb1, 0x7f, 0x00, 0x2e, 0x41, 0x40, 0x00, 0x40, 0x48, 0x04, 0x98, 0x2e, 0x74, 0xc0, 0x1e, 0xaa, 0xd3, 0x6f,
+ 0x14, 0x30, 0xb1, 0x6f, 0xe3, 0x22, 0xc0, 0x6f, 0x52, 0x40, 0xe4, 0x6f, 0x4c, 0x0e, 0x12, 0x42, 0xd3, 0x7f, 0xeb,
+ 0x2f, 0x03, 0x2e, 0x86, 0x0f, 0x40, 0x90, 0x11, 0x30, 0x03, 0x2f, 0x23, 0x2e, 0x86, 0x0f, 0x02, 0x2c, 0x00, 0x30,
+ 0xd0, 0x6f, 0xfb, 0x6f, 0xb0, 0x5f, 0xb8, 0x2e, 0x40, 0x50, 0xf1, 0x7f, 0x0a, 0x25, 0x3c, 0x86, 0xeb, 0x7f, 0x41,
+ 0x33, 0x22, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xd3, 0x6f, 0xf4, 0x30, 0xdc, 0x09, 0x47, 0x58, 0xc2, 0x6f, 0x94, 0x09,
+ 0xeb, 0x58, 0x6a, 0xbb, 0xdc, 0x08, 0xb4, 0xb9, 0xb1, 0xbd, 0xe9, 0x5a, 0x95, 0x08, 0x21, 0xbd, 0xf6, 0xbf, 0x77,
+ 0x0b, 0x51, 0xbe, 0xf1, 0x6f, 0xeb, 0x6f, 0x52, 0x42, 0x54, 0x42, 0xc0, 0x2e, 0x43, 0x42, 0xc0, 0x5f, 0x50, 0x50,
+ 0xf5, 0x50, 0x31, 0x30, 0x11, 0x42, 0xfb, 0x7f, 0x7b, 0x30, 0x0b, 0x42, 0x11, 0x30, 0x02, 0x80, 0x23, 0x33, 0x01,
+ 0x42, 0x03, 0x00, 0x07, 0x2e, 0x80, 0x03, 0x05, 0x2e, 0xd3, 0x00, 0x23, 0x52, 0xe2, 0x7f, 0xd3, 0x7f, 0xc0, 0x7f,
+ 0x98, 0x2e, 0xb6, 0x0e, 0xd1, 0x6f, 0x08, 0x0a, 0x1a, 0x25, 0x7b, 0x86, 0xd0, 0x7f, 0x01, 0x33, 0x12, 0x30, 0x98,
+ 0x2e, 0xc2, 0xc4, 0xd1, 0x6f, 0x08, 0x0a, 0x00, 0xb2, 0x0d, 0x2f, 0xe3, 0x6f, 0x01, 0x2e, 0x80, 0x03, 0x51, 0x30,
+ 0xc7, 0x86, 0x23, 0x2e, 0x21, 0xf2, 0x08, 0xbc, 0xc0, 0x42, 0x98, 0x2e, 0xa5, 0xb7, 0x00, 0x2e, 0x00, 0x2e, 0xd0,
+ 0x2e, 0xb0, 0x6f, 0x0b, 0xb8, 0x03, 0x2e, 0x1b, 0x00, 0x08, 0x1a, 0xb0, 0x7f, 0x70, 0x30, 0x04, 0x2f, 0x21, 0x2e,
+ 0x21, 0xf2, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x98, 0x2e, 0x6d, 0xc0, 0x98, 0x2e, 0x5d, 0xc0, 0xed, 0x50, 0x98,
+ 0x2e, 0x44, 0xcb, 0xef, 0x50, 0x98, 0x2e, 0x46, 0xc3, 0xf1, 0x50, 0x98, 0x2e, 0x53, 0xc7, 0x35, 0x50, 0x98, 0x2e,
+ 0x64, 0xcf, 0x10, 0x30, 0x98, 0x2e, 0xdc, 0x03, 0x20, 0x26, 0xc0, 0x6f, 0x02, 0x31, 0x12, 0x42, 0xab, 0x33, 0x0b,
+ 0x42, 0x37, 0x80, 0x01, 0x30, 0x01, 0x42, 0xf3, 0x37, 0xf7, 0x52, 0xfb, 0x50, 0x44, 0x40, 0xa2, 0x0a, 0x42, 0x42,
+ 0x8b, 0x31, 0x09, 0x2e, 0x5e, 0xf7, 0xf9, 0x54, 0xe3, 0x08, 0x83, 0x42, 0x1b, 0x42, 0x23, 0x33, 0x4b, 0x00, 0xbc,
+ 0x84, 0x0b, 0x40, 0x33, 0x30, 0x83, 0x42, 0x0b, 0x42, 0xe0, 0x7f, 0xd1, 0x7f, 0x98, 0x2e, 0x58, 0xb7, 0xd1, 0x6f,
+ 0x80, 0x30, 0x40, 0x42, 0x03, 0x30, 0xe0, 0x6f, 0xf3, 0x54, 0x04, 0x30, 0x00, 0x2e, 0x00, 0x2e, 0x01, 0x89, 0x62,
+ 0x0e, 0xfa, 0x2f, 0x43, 0x42, 0x11, 0x30, 0xfb, 0x6f, 0xc0, 0x2e, 0x01, 0x42, 0xb0, 0x5f, 0xc1, 0x4a, 0x00, 0x00,
+ 0x6d, 0x57, 0x00, 0x00, 0x77, 0x8e, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xd3, 0xff, 0xff, 0xff, 0xe5, 0xff, 0xff,
+ 0xff, 0xee, 0xe1, 0xff, 0xff, 0x7c, 0x13, 0x00, 0x00, 0x46, 0xe6, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1,
+ 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00,
+ 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e,
+ 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80,
+ 0x2e, 0x00, 0xc1
+
+};
+
+} // namespace esphome::bmi270
diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py
new file mode 100644
index 0000000000..c1616665f9
--- /dev/null
+++ b/esphome/components/bmi270/motion.py
@@ -0,0 +1,91 @@
+import esphome.codegen as cg
+from esphome.components import i2c
+from esphome.components.const import (
+ CONF_ACCELEROMETER_ODR,
+ CONF_ACCELEROMETER_RANGE,
+ CONF_GYROSCOPE_ODR,
+ CONF_GYROSCOPE_RANGE,
+)
+from esphome.components.motion import motion_schema, new_motion_component
+import esphome.config_validation as cv
+
+from . import BMI270Component, bmi270_ns
+
+DEPENDENCIES = ["i2c"]
+
+# Enum proxies (must match the C++ enum values exactly)
+BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange")
+ACCEL_RANGE_OPTIONS = {
+ "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G,
+ "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G,
+ "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G,
+ "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G,
+}
+
+BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange")
+GYRO_RANGE_OPTIONS = {
+ "2000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_2000,
+ "1000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_1000,
+ "500DPS": BMI270GyroRange.BMI270_GYRO_RANGE_500,
+ "250DPS": BMI270GyroRange.BMI270_GYRO_RANGE_250,
+ "125DPS": BMI270GyroRange.BMI270_GYRO_RANGE_125,
+}
+
+BMI270AccelODR = bmi270_ns.enum("BMI270AccelODR")
+ACCEL_ODR_OPTIONS = {
+ "12_5HZ": BMI270AccelODR.BMI270_ACCEL_ODR_12_5,
+ "25HZ": BMI270AccelODR.BMI270_ACCEL_ODR_25,
+ "50HZ": BMI270AccelODR.BMI270_ACCEL_ODR_50,
+ "100HZ": BMI270AccelODR.BMI270_ACCEL_ODR_100,
+ "200HZ": BMI270AccelODR.BMI270_ACCEL_ODR_200,
+ "400HZ": BMI270AccelODR.BMI270_ACCEL_ODR_400,
+ "800HZ": BMI270AccelODR.BMI270_ACCEL_ODR_800,
+ "1600HZ": BMI270AccelODR.BMI270_ACCEL_ODR_1600,
+}
+
+BMI270GyroODR = bmi270_ns.enum("BMI270GyroODR")
+GYRO_ODR_OPTIONS = {
+ "25HZ": BMI270GyroODR.BMI270_GYRO_ODR_25,
+ "50HZ": BMI270GyroODR.BMI270_GYRO_ODR_50,
+ "100HZ": BMI270GyroODR.BMI270_GYRO_ODR_100,
+ "200HZ": BMI270GyroODR.BMI270_GYRO_ODR_200,
+ "400HZ": BMI270GyroODR.BMI270_GYRO_ODR_400,
+ "800HZ": BMI270GyroODR.BMI270_GYRO_ODR_800,
+ "1600HZ": BMI270GyroODR.BMI270_GYRO_ODR_1600,
+ "3200HZ": BMI270GyroODR.BMI270_GYRO_ODR_3200,
+}
+
+# Top-level CONFIG_SCHEMA
+CONFIG_SCHEMA = (
+ motion_schema(BMI270Component, has_accel=True, has_gyro=True)
+ .extend(
+ {
+ cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum(
+ ACCEL_RANGE_OPTIONS, upper=True
+ ),
+ cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum(
+ ACCEL_ODR_OPTIONS, upper=True
+ ),
+ cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum(
+ GYRO_RANGE_OPTIONS, upper=True
+ ),
+ cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum(
+ GYRO_ODR_OPTIONS, upper=True
+ ),
+ }
+ )
+ .extend(i2c.i2c_device_schema(0x68))
+)
+
+
+# Code generation
+async def to_code(config):
+ var = await new_motion_component(config)
+ await i2c.register_i2c_device(var, config)
+
+ # Accelerometer sensors
+ # Hardware configuration
+ cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE]))
+ cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR]))
+ cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
+ cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR]))
diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py
new file mode 100644
index 0000000000..69235ed8dc
--- /dev/null
+++ b/esphome/components/bmi270/sensor.py
@@ -0,0 +1,41 @@
+# YAML config keys
+import esphome.codegen as cg
+from esphome.components import sensor
+import esphome.config_validation as cv
+from esphome.const import (
+ CONF_TEMPERATURE,
+ CONF_TYPE,
+ DEVICE_CLASS_TEMPERATURE,
+ ICON_THERMOMETER,
+ STATE_CLASS_MEASUREMENT,
+ UNIT_CELSIUS,
+)
+from esphome.cpp_generator import MockObj
+
+from . import CONF_BMI270_ID, BMI270Component
+
+AUTO_LOAD = ["bmi270"]
+
+CONFIG_SCHEMA = sensor.sensor_schema(
+ unit_of_measurement=UNIT_CELSIUS,
+ icon=ICON_THERMOMETER,
+ accuracy_decimals=2,
+ state_class=STATE_CLASS_MEASUREMENT,
+ device_class=DEVICE_CLASS_TEMPERATURE,
+).extend(
+ {
+ cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
+ cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component),
+ }
+)
+
+
+async def to_code(config):
+ var = await sensor.new_sensor(config)
+ parent = await cg.get_variable(config[CONF_BMI270_ID])
+ data = MockObj("data")
+ value_lambda = await cg.process_lambda(
+ var.publish_state(data),
+ [(cg.float_, str(data))],
+ )
+ cg.add(parent.add_temperature_listener(value_lambda))
diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp
index ff12e6157d..ff38ab1740 100644
--- a/esphome/components/bthome_mithermometer/bthome_ble.cpp
+++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp
@@ -222,6 +222,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da
}
size_t plaintext_length;
+ // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped
psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE),
nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size,
payload.data(), ciphertext_size, &plaintext_length);
diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py
index a0c59a517a..344248fcf3 100644
--- a/esphome/components/camera_encoder/__init__.py
+++ b/esphome/components/camera_encoder/__init__.py
@@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None:
buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID])
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")
+ add_idf_component(name="espressif/esp32-camera", ref="2.1.7")
cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER")
var = cg.new_Pvariable(
config[CONF_ID],
diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py
index 6f418b48ea..9951243f0d 100644
--- a/esphome/components/const/__init__.py
+++ b/esphome/components/const/__init__.py
@@ -5,6 +5,8 @@ CODEOWNERS = ["@esphome/core"]
BYTE_ORDER_LITTLE = "little_endian"
BYTE_ORDER_BIG = "big_endian"
+CONF_ACCELEROMETER_ODR = "accelerometer_odr"
+CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_B_CONSTANT = "b_constant"
CONF_BYTE_ORDER = "byte_order"
CONF_CLIMATE_ID = "climate_id"
@@ -13,8 +15,11 @@ CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLED = "enabled"
+CONF_GYROSCOPE_ODR = "gyroscope_odr"
+CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_LIBRETINY = "libretiny"
+CONF_LOOP = "loop"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_STATE_CHANGE = "on_state_change"
@@ -22,6 +27,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"
diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp
index a455e2fd7f..e31f72dfb9 100644
--- a/esphome/components/daikin_arc/daikin_arc.cpp
+++ b/esphome/components/daikin_arc/daikin_arc.cpp
@@ -216,7 +216,7 @@ uint8_t DaikinArcClimate::temperature_() {
return 0xc0;
default:
float new_temp = clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX);
- uint8_t temperature = (uint8_t) floor(new_temp);
+ uint8_t temperature = (uint8_t) std::floor(new_temp);
return temperature << 1 | (new_temp - temperature > 0 ? 0x01 : 0);
}
}
diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py
index 7796f5d891..d589381461 100644
--- a/esphome/components/dfplayer/__init__.py
+++ b/esphome/components/dfplayer/__init__.py
@@ -1,6 +1,7 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import uart
+from esphome.components.const import CONF_LOOP
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME
@@ -15,7 +16,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_(
MULTI_CONF = True
CONF_FOLDER = "folder"
-CONF_LOOP = "loop"
CONF_EQ_PRESET = "eq_preset"
CONF_ON_FINISHED_PLAYBACK = "on_finished_playback"
diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py
index 744b5d16c4..7a66da11f2 100644
--- a/esphome/components/display/__init__.py
+++ b/esphome/components/display/__init__.py
@@ -3,11 +3,18 @@ from dataclasses import dataclass
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
-from esphome.components.const import KEY_METADATA
+from esphome.components.const import (
+ BYTE_ORDER_BIG,
+ CONF_BYTE_ORDER,
+ CONF_DRAW_ROUNDING,
+ KEY_METADATA,
+)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
+ CONF_DIMENSIONS,
CONF_FROM,
+ CONF_HEIGHT,
CONF_ID,
CONF_LAMBDA,
CONF_PAGE_ID,
@@ -16,10 +23,11 @@ from esphome.const import (
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
+ CONF_WIDTH,
SCHEDULER_DONT_RUN,
)
-from esphome.core import CORE, CoroPriority, coroutine_with_priority
-from esphome.cpp_generator import MockObj
+from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
+from esphome.final_validate import full_config
DOMAIN = "display"
IS_PLATFORM_COMPONENT = True
@@ -159,29 +167,97 @@ async def setup_display_core_(var, config):
class DisplayMetaData:
width: int = 0
height: int = 0
- has_writer: bool = False
has_hardware_rotation: bool = False
+ byte_order: str = BYTE_ORDER_BIG
+ has_writer: bool = False
+ rotation: int = 0
+ draw_rounding: int = 0
+
+
+def _get_metadata_list() -> list[tuple]:
+ """Get the raw metadata list. Each entry is (id, DisplayMetaData)."""
+ return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, [])
def get_all_display_metadata() -> dict[str, DisplayMetaData]:
- """Get all display metadata."""
- return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
+ """Get all display metadata as a dict keyed by resolved ID strings.
+
+ Must not be called before IDs have been finalised.
+ """
+ entries = _get_metadata_list()
+ assert all(id_.id is not None for id_, _ in entries), (
+ "get_all_display_metadata called before display IDs have been resolved"
+ )
+ return {id_.id: meta for id_, meta in entries}
-def get_display_metadata(display_id: str) -> DisplayMetaData | None:
- """Get display metadata by ID for use by other components."""
- return get_all_display_metadata().get(display_id, DisplayMetaData())
+def get_display_metadata(display_id: ID) -> DisplayMetaData:
+ """Get display metadata by ID object
+
+ Must not be called before IDs have been finalised.
+ """
+ for id_, meta in _get_metadata_list():
+ if id_ is display_id:
+ return meta
+ assert id_.id is not None, (
+ "get_display_metadata called before display IDs have been resolved"
+ )
+ if id_.id == display_id.id:
+ return meta
+ # No metadata found, display driver may not yet support it.
+ # Read the raw config to populate the returned data
+ global_config = full_config.get()
+ path = global_config.get_path_for_id(display_id)[:-1]
+ disp_config = global_config.get_config_for_path(path)
+ dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0))
+ if isinstance(dimensions, dict):
+ dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0))
+ elif not isinstance(dimensions, tuple) or len(dimensions) != 2:
+ dimensions = (0, 0)
+
+ meta = DisplayMetaData(
+ width=dimensions[0],
+ height=dimensions[1],
+ has_hardware_rotation=False,
+ byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED),
+ has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True
+ or disp_config.get(CONF_PAGES) is not None
+ or disp_config.get(CONF_LAMBDA) is not None
+ or disp_config.get(CONF_SHOW_TEST_CARD) is True,
+ rotation=disp_config.get(CONF_ROTATION, 0),
+ draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0),
+ )
+ _get_metadata_list().append((display_id, meta))
+ return meta
def add_metadata(
- id: str | MockObj,
+ id: ID,
width: int,
height: int,
- has_writer: bool,
has_hardware_rotation: bool = False,
+ byte_order: str = BYTE_ORDER_BIG,
+ has_writer: bool = False,
+ rotation: int = 0,
+ draw_rounding: int = 0,
):
- get_all_display_metadata()[str(id)] = DisplayMetaData(
- width, height, has_writer, has_hardware_rotation
+ entries = _get_metadata_list()
+ assert not any(existing_id is id for existing_id, _ in entries), (
+ f"Duplicate display metadata for ID {id}"
+ )
+ entries.append(
+ (
+ id,
+ DisplayMetaData(
+ width=width,
+ height=height,
+ has_hardware_rotation=has_hardware_rotation,
+ byte_order=byte_order,
+ has_writer=has_writer,
+ rotation=rotation,
+ draw_rounding=draw_rounding,
+ ),
+ )
)
diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp
index cd2d2143f5..b24c099bce 100644
--- a/esphome/components/display/display.cpp
+++ b/esphome/components/display/display.cpp
@@ -1,4 +1,5 @@
#include "display.h"
+#include
#include
#include
#include "display_color_utils.h"
@@ -238,7 +239,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2,
int lhline_width = -(dxmax - dxmin) + 1;
if (progress >= 50) {
if (float(dymax) < float(-dxmax) * tan_a) {
- upd_dxmax = ceil(float(dymax) / tan_a);
+ upd_dxmax = std::ceil(float(dymax) / tan_a);
} else {
upd_dxmax = -dxmax;
}
@@ -253,7 +254,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2,
}
} else {
if (float(dymin) > float(-dxmin) * tan_a) {
- upd_dxmin = ceil(float(dymin) / tan_a);
+ upd_dxmin = std::ceil(float(dymin) / tan_a);
} else {
upd_dxmin = -dxmin;
}
@@ -268,12 +269,12 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2,
int hline_width = 2 * (-dxmax) + 1;
if (progress >= 50) {
if (dymax < float(-dxmax) * tan_a) {
- upd_dxmax = ceil(float(dymax) / tan_a);
+ upd_dxmax = std::ceil(float(dymax) / tan_a);
hline_width = -dxmax + upd_dxmax + 1;
}
} else {
if (dymax < float(-dxmax) * tan_a) {
- upd_dxmax = ceil(float(dymax) / tan_a);
+ upd_dxmax = std::ceil(float(dymax) / tan_a);
hline_width = -dxmax - upd_dxmax + 1;
} else {
hline_width = 0;
@@ -452,8 +453,8 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int *
rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0;
float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians;
- *vertex_x = (int) round(cos(vertex_angle) * radius) + center_x;
- *vertex_y = (int) round(sin(vertex_angle) * radius) + center_y;
+ *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x;
+ *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y;
}
}
diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py
index c22ab7b552..7094699b0b 100644
--- a/esphome/components/dlms_meter/__init__.py
+++ b/esphome/components/dlms_meter/__init__.py
@@ -1,57 +1,258 @@
-import esphome.codegen as cg
-from esphome.components import uart
-import esphome.config_validation as cv
-from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266
+import logging
+import re
-CODEOWNERS = ["@SimonFischer04"]
+import esphome.codegen as cg
+from esphome.components import esp32, uart
+import esphome.config_validation as cv
+from esphome.const import (
+ CONF_ID,
+ CONF_NAME,
+ CONF_PATTERN,
+ CONF_PRIORITY,
+ CONF_RECEIVE_TIMEOUT,
+)
+from esphome.core import CORE
+
+_LOGGER = logging.getLogger(__name__)
+
+CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"]
DEPENDENCIES = ["uart"]
CONF_DLMS_METER_ID = "dlms_meter_id"
CONF_DECRYPTION_KEY = "decryption_key"
+CONF_AUTH_KEY = "auth_key"
+CONF_OBIS_CODE = "obis_code"
+CONF_CUSTOM_PATTERNS = "custom_patterns"
+CONF_SKIP_CRC = "skip_crc"
+CONF_DEFAULT_OBIS = "default_obis"
CONF_PROVIDER = "provider"
-PROVIDERS = {"generic": 0, "netznoe": 1}
-
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
DlmsMeterComponent = dlms_meter_component_ns.class_(
"DlmsMeterComponent", cg.Component, uart.UARTDevice
)
-def validate_key(value):
- value = cv.string_strict(value)
- if len(value) != 32:
- raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)")
- try:
- return [int(value[i : i + 2], 16) for i in range(0, 32, 2)]
- except ValueError as exc:
- raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc
+def obis_code(value):
+ # Normalize the OBIS code to the strict A.B.C.D.E.F format
+ bytes_list = parse_obis_code_bytes(value)
+ return ".".join(str(b) for b in bytes_list)
+def parse_obis_code_bytes(value):
+ value = cv.string(value)
+ normalized = re.sub(r"[\-\:\*]", ".", value)
+ parts = normalized.split(".")
+ if len(parts) < 5 or len(parts) > 6:
+ raise cv.Invalid("OBIS code must have 5 or 6 parts")
+ try:
+ bytes_list = [int(p) for p in parts]
+ except ValueError as exc:
+ raise cv.Invalid("OBIS code parts must be integers") from exc
+ for b in bytes_list:
+ if b < 0 or b > 255:
+ raise cv.Invalid("OBIS code parts must be between 0 and 255")
+ if len(bytes_list) == 5:
+ bytes_list.append(255)
+ return bytes_list
+
+
+def custom_pattern_dict(value):
+ if isinstance(value, str):
+ return {CONF_PATTERN: value}
+ return value
+
+
+def validate_custom_pattern(value):
+ if CONF_DEFAULT_OBIS in value and CONF_NAME not in value:
+ raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set")
+ return value
+
+
+def validate_provider_deprecation(config):
+ if CONF_PROVIDER in config:
+ provider = str(config[CONF_PROVIDER]).lower()
+ if provider == "netznoe":
+ _LOGGER.warning(
+ "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. "
+ "The required custom patterns have been added automatically for this release, but you must update your configuration.\n"
+ "Please remove the 'provider' key and explicitly replace it with the following:\n\n"
+ "custom_patterns:\n"
+ ' - pattern: "L, TSTR"\n'
+ ' name: "MeterID"\n'
+ ' default_obis: "0.0.96.1.0.255"\n'
+ ' - pattern: "F, TDTM"\n'
+ ' name: "DateTime"\n'
+ ' default_obis: "0.0.1.0.0.255"\n'
+ )
+ patterns = config.get(CONF_CUSTOM_PATTERNS, [])
+
+ # Ensure "L, TSTR" for MeterID is present
+ if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns):
+ patterns.append(
+ {
+ CONF_PATTERN: "L, TSTR",
+ CONF_NAME: "MeterID",
+ CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255],
+ CONF_PRIORITY: 0,
+ }
+ )
+
+ # Ensure "F, TDTM" for DateTime is present
+ if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns):
+ patterns.append(
+ {
+ CONF_PATTERN: "F, TDTM",
+ CONF_NAME: "DateTime",
+ CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255],
+ CONF_PRIORITY: 0,
+ }
+ )
+
+ config[CONF_CUSTOM_PATTERNS] = patterns
+ else:
+ _LOGGER.warning(
+ "The 'provider' option is deprecated and will be removed in 2026.11.0. "
+ "The dlms_parser library now handles quirks dynamically. "
+ "Please remove this option from your configuration."
+ )
+ return config
+
+
+CUSTOM_PATTERN_SCHEMA = cv.All(
+ custom_pattern_dict,
+ cv.Schema(
+ {
+ cv.Required(CONF_PATTERN): cv.string,
+ cv.Optional(CONF_NAME): cv.string,
+ cv.Optional(CONF_PRIORITY, default=0): cv.int_,
+ cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes,
+ }
+ ),
+ validate_custom_pattern,
+)
+
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DlmsMeterComponent),
- cv.Required(CONF_DECRYPTION_KEY): validate_key,
- cv.Optional(CONF_PROVIDER, default="generic"): cv.enum(
- PROVIDERS, lower=True
+ cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key(
+ value, name="Decryption key"
),
+ cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key(
+ value, name="Authentication key"
+ ),
+ cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA),
+ cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean,
+ cv.Optional(CONF_PROVIDER): cv.string,
+ cv.Optional(
+ CONF_RECEIVE_TIMEOUT, default="1000ms"
+ ): cv.positive_time_period_milliseconds,
}
)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
- cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]),
+ validate_provider_deprecation,
)
-FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
- "dlms_meter", baud_rate=2400, require_rx=True
-)
+FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True)
async def to_code(config):
- var = cg.new_Pvariable(config[CONF_ID])
+ dec_key_expr = cg.RawExpression("std::nullopt")
+ if dec_key := config.get(CONF_DECRYPTION_KEY):
+ key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)]
+ dec_key_expr = cg.RawExpression(
+ f"std::array{{{', '.join(key_bytes)}}}"
+ )
+
+ auth_key_expr = cg.RawExpression("std::nullopt")
+ if auth_key := config.get(CONF_AUTH_KEY):
+ key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)]
+ auth_key_expr = cg.RawExpression(
+ f"std::array{{{', '.join(key_bytes)}}}"
+ )
+
+ patterns = []
+ if custom_patterns := config.get(CONF_CUSTOM_PATTERNS):
+ for p in custom_patterns:
+ name_expr = cg.RawExpression("std::nullopt")
+ if name_val := p.get(CONF_NAME):
+ name_expr = name_val
+
+ if obis_vals := p.get(CONF_DEFAULT_OBIS):
+ obis_expr = cg.RawExpression(
+ f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}"
+ )
+ else:
+ obis_expr = cg.RawExpression("std::nullopt")
+
+ patterns.append(
+ cg.ArrayInitializer(
+ p[CONF_PATTERN],
+ name_expr,
+ p.get(CONF_PRIORITY, 0),
+ obis_expr,
+ )
+ )
+
+ patterns_expr = (
+ cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}")
+ )
+
+ var = cg.new_Pvariable(
+ config[CONF_ID],
+ config[CONF_RECEIVE_TIMEOUT],
+ config[CONF_SKIP_CRC],
+ dec_key_expr,
+ auth_key_expr,
+ patterns_expr,
+ )
+
+ hub_id = config[CONF_ID].id
+
+ sensor_count = 0
+ for sens_conf in CORE.config.get("sensor", []):
+ if (
+ sens_conf.get("platform") == "dlms_meter"
+ and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
+ ):
+ if CONF_OBIS_CODE in sens_conf:
+ sensor_count += 1
+ else:
+ from .sensor import NUMERIC_KEYS
+
+ sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf)
+
+ text_sensor_count = 0
+ for sens_conf in CORE.config.get("text_sensor", []):
+ if (
+ sens_conf.get("platform") == "dlms_meter"
+ and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
+ ):
+ if CONF_OBIS_CODE in sens_conf:
+ text_sensor_count += 1
+ else:
+ from .text_sensor import TEXT_KEYS
+
+ text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf)
+
+ binary_sensor_count = 0
+ for sens_conf in CORE.config.get("binary_sensor", []):
+ if (
+ sens_conf.get("platform") == "dlms_meter"
+ and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id
+ ):
+ binary_sensor_count += 1
+
+ cg.add_define("DLMS_MAX_SENSORS", sensor_count)
+ cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count)
+ cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count)
+
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
- key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY])
- cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}")))
- cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]]))
+
+ if CORE.is_esp32:
+ esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0")
+ else:
+ cg.add_library("esphome/dlms_parser", "1.1.0")
diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py
new file mode 100644
index 0000000000..f9bc1d9df7
--- /dev/null
+++ b/esphome/components/dlms_meter/binary_sensor/__init__.py
@@ -0,0 +1,20 @@
+import esphome.codegen as cg
+from esphome.components import binary_sensor
+import esphome.config_validation as cv
+
+from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
+
+DEPENDENCIES = ["dlms_meter"]
+
+CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
+ {
+ cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
+ cv.Required(CONF_OBIS_CODE): obis_code,
+ }
+)
+
+
+async def to_code(config):
+ hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
+ var = await binary_sensor.new_binary_sensor(config)
+ cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var))
diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h
deleted file mode 100644
index a3d8f62ce6..0000000000
--- a/esphome/components/dlms_meter/dlms.h
+++ /dev/null
@@ -1,71 +0,0 @@
-#pragma once
-
-#include
-
-namespace esphome::dlms_meter {
-
-/*
-+-------------------------------+
-| Ciphering Service |
-+-------------------------------+
-| System Title Length |
-+-------------------------------+
-| |
-| |
-| |
-| System |
-| Title |
-| |
-| |
-| |
-+-------------------------------+
-| Length | (1 or 3 Bytes)
-+-------------------------------+
-| Security Control Byte |
-+-------------------------------+
-| |
-| Frame |
-| Counter |
-| |
-+-------------------------------+
-| |
-~ ~
- Encrypted Payload
-~ ~
-| |
-+-------------------------------+
-
-Ciphering Service: 0xDB (General-Glo-Ciphering)
-System Title Length: 0x08
-System Title: Unique ID of meter
-Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length)
-Security Control Byte:
-- Bit 3…0: Security_Suite_Id
-- Bit 4: "A" subfield: indicates that authentication is applied
-- Bit 5: "E" subfield: indicates that encryption is applied
-- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast
-- Bit 7: Indicates the use of compression.
- */
-
-static constexpr uint8_t DLMS_HEADER_LENGTH = 16;
-static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header
-static constexpr uint8_t DLMS_CIPHER_OFFSET = 0;
-static constexpr uint8_t DLMS_SYST_OFFSET = 1;
-static constexpr uint8_t DLMS_LENGTH_OFFSET = 10;
-static constexpr uint8_t TWO_BYTE_LENGTH = 0x82;
-static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field
-static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11;
-static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12;
-static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4;
-static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16;
-static constexpr uint8_t GLO_CIPHERING = 0xDB;
-static constexpr uint8_t DATA_NOTIFICATION = 0x0F;
-static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C;
-static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header).
-
-// Provider specific quirks
-static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE
-static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8;
-static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20;
-
-} // namespace esphome::dlms_meter
diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp
index b732e71d24..bdbf798df5 100644
--- a/esphome/components/dlms_meter/dlms_meter.cpp
+++ b/esphome/components/dlms_meter/dlms_meter.cpp
@@ -1,516 +1,236 @@
#include "dlms_meter.h"
+#include "esphome/core/log.h"
-#include
-
-#if defined(USE_ESP8266_FRAMEWORK_ARDUINO)
-#include
-#elif defined(USE_ESP32)
-#include
-#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
-#include
-#else
-#include "mbedtls/esp_config.h"
-#include "mbedtls/gcm.h"
-#endif
-#endif
+#include
namespace esphome::dlms_meter {
-static constexpr const char *TAG = "dlms_meter";
+static const char *const TAG = "dlms_meter";
+static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) {
+ std::array buf;
+ vsnprintf(buf.data(), buf.size(), fmt, args);
+ switch (level) {
+ case dlms_parser::LogLevel::ERROR:
+ ESP_LOGE(TAG, "%s", buf.data());
+ break;
+ case dlms_parser::LogLevel::WARNING:
+ ESP_LOGW(TAG, "%s", buf.data());
+ break;
+ case dlms_parser::LogLevel::INFO:
+ ESP_LOGI(TAG, "%s", buf.data());
+ break;
+ case dlms_parser::LogLevel::VERBOSE:
+ ESP_LOGV(TAG, "%s", buf.data());
+ break;
+ case dlms_parser::LogLevel::VERY_VERBOSE:
+ ESP_LOGVV(TAG, "%s", buf.data());
+ break;
+ case dlms_parser::LogLevel::DEBUG:
+ ESP_LOGD(TAG, "%s", buf.data());
+ break;
+ }
+}
+
+DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check,
+ std::optional> decryption_key,
+ std::optional> authentication_key,
+ std::vector custom_patterns)
+ : receive_timeout_ms_(receive_timeout_ms),
+ skip_crc_check_(skip_crc_check),
+ custom_patterns_(std::move(custom_patterns)),
+ parser_(&decryptor_) {
+ dlms_parser::Logger::set_log_function(log_callback);
+
+ if (decryption_key.has_value()) {
+#ifdef DLMS_METER_NO_CRYPTO
+ ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)");
+#else
+ auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value());
+ if (opt_key) {
+ this->parser_.set_decryption_key(*opt_key);
+ } else {
+ ESP_LOGE(TAG, "Failed to set decryption key: invalid key format");
+ }
+#endif
+ }
+
+ if (authentication_key.has_value()) {
+#ifdef DLMS_METER_NO_CRYPTO
+ ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)");
+#else
+ auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value());
+ if (opt_key) {
+ this->parser_.set_authentication_key(*opt_key);
+ } else {
+ ESP_LOGE(TAG, "Failed to set authentication key: invalid key format");
+ }
+#endif
+ }
+
+ this->parser_.set_skip_crc_check(this->skip_crc_check_);
+
+ this->parser_.load_default_patterns();
+ for (const auto &pattern : this->custom_patterns_) {
+ if (pattern.default_obis.has_value() && pattern.name.has_value()) {
+ this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority,
+ pattern.default_obis.value());
+ } else if (pattern.name.has_value()) {
+ this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority);
+ } else {
+ this->parser_.register_pattern(pattern.pattern.c_str());
+ }
+ }
+}
+
+void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); }
void DlmsMeterComponent::dump_config() {
- const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic";
- ESP_LOGCONFIG(TAG,
- "DLMS Meter:\n"
- " Provider: %s\n"
- " Read Timeout: %" PRIu32 " ms",
- provider_name, this->read_timeout_);
-#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_);
- DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, )
-#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_);
- DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, )
+ ESP_LOGCONFIG(TAG, "DLMS Meter:");
+ ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_);
+ ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_));
+
+ for (const auto &pattern : this->custom_patterns_) {
+ if (pattern.default_obis.has_value() && pattern.name.has_value()) {
+ const auto &obis = pattern.default_obis.value();
+ ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)",
+ pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2],
+ obis[3], obis[4], obis[5]);
+ } else if (pattern.name.has_value()) {
+ ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(),
+ pattern.name->c_str(), pattern.priority);
+ } else {
+ ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str());
+ }
+ }
+
+#ifdef USE_SENSOR
+ for (const auto &entry : this->sensors_) {
+ LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor);
+ ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
+ }
+#endif
+#ifdef USE_TEXT_SENSOR
+ for (const auto &entry : this->text_sensors_) {
+ LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor);
+ ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
+ }
+#endif
+#ifdef USE_BINARY_SENSOR
+ for (const auto &entry : this->binary_sensors_) {
+ LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor);
+ ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str());
+ }
+#endif
}
void DlmsMeterComponent::loop() {
- // Read while data is available, netznoe uses two frames so allow 2x max frame length
- size_t avail = this->available();
- if (avail > 0) {
- size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size();
- if (remaining == 0) {
- ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes");
- } else {
- // Read all available bytes in batches to reduce UART call overhead.
- // Cap reads to remaining buffer capacity.
- if (avail > remaining) {
- avail = remaining;
- }
- uint8_t buf[64];
- while (avail > 0) {
- size_t to_read = std::min(avail, sizeof(buf));
- if (!this->read_array(buf, to_read)) {
- break;
- }
- avail -= to_read;
- this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read);
- this->last_read_ = millis();
- }
- }
- }
-
- if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) {
- this->mbus_payload_.clear();
- if (!this->parse_mbus_(this->mbus_payload_))
- return;
-
- uint16_t message_length;
- uint8_t systitle_length;
- uint16_t header_offset;
- if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset))
- return;
-
- if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) {
- ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length);
- this->receive_buffer_.clear();
- return;
- }
-
- // Decrypt in place and then decode the OBIS codes
- if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset))
- return;
- this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length);
+ this->read_rx_buffer_();
+ if (this->bytes_accumulated_ > 0 &&
+ App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) {
+ this->process_frame_();
}
}
-bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) {
- ESP_LOGV(TAG, "Parsing M-Bus frames");
- uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames
-
- while (frame_offset < this->receive_buffer_.size()) {
- // Ensure enough bytes remain for the minimal intro header before accessing indices
- if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) {
- ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH,
- (this->receive_buffer_.size() - frame_offset));
- this->receive_buffer_.clear();
- return false;
- }
-
- // Check start bytes
- if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME ||
- this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) {
- ESP_LOGE(TAG, "MBUS: Start bytes do not match");
- this->receive_buffer_.clear();
- return false;
- }
-
- // Both length bytes must be identical
- if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] !=
- this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) {
- ESP_LOGE(TAG, "MBUS: Length bytes do not match");
- this->receive_buffer_.clear();
- return false;
- }
-
- uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame
-
- // Check if received data is enough for the given frame length
- if (this->receive_buffer_.size() - frame_offset <
- frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte
- ESP_LOGE(TAG, "MBUS: Frame too big for received data");
- this->receive_buffer_.clear();
- return false;
- }
-
- // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte
- size_t required_total =
- frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes
- if (this->receive_buffer_.size() - frame_offset < required_total) {
- ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total,
- this->receive_buffer_.size() - frame_offset);
- this->receive_buffer_.clear();
- return false;
- }
-
- if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] !=
- STOP_BYTE) {
- ESP_LOGE(TAG, "MBUS: Invalid stop byte");
- this->receive_buffer_.clear();
- return false;
- }
-
- // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte
- uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored
- for (uint16_t i = 0; i < frame_length; i++) {
- checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i];
- }
- if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) {
- ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum,
- this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]);
- this->receive_buffer_.clear();
- return false;
- }
-
- mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH],
- &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]);
-
- frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH;
+void DlmsMeterComponent::flush_rx_buffer_() {
+ while (this->available()) {
+ this->read();
}
- return true;
}
-bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length,
- uint8_t &systitle_length, uint16_t &header_offset) {
- ESP_LOGV(TAG, "Parsing DLMS header");
- if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) {
- ESP_LOGE(TAG, "DLMS: Payload too short");
- this->receive_buffer_.clear();
- return false;
+void DlmsMeterComponent::read_rx_buffer_() {
+ int available = this->available();
+ if (available == 0)
+ return;
+
+ if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) {
+ ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame.");
+ this->bytes_accumulated_ = 0;
+
+ this->flush_rx_buffer_();
+ return;
}
- if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB)
- ESP_LOGE(TAG, "DLMS: Unsupported cipher");
- this->receive_buffer_.clear();
- return false;
+ bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available);
+ if (!success) {
+ ESP_LOGW(TAG, "UART read failed. Dropping frame.");
+ this->bytes_accumulated_ = 0;
+ this->flush_rx_buffer_();
+ return;
}
- systitle_length = mbus_payload[DLMS_SYST_OFFSET];
+ this->bytes_accumulated_ += available;
- if (systitle_length != 0x08) { // Only system titles with length of 8 are supported
- ESP_LOGE(TAG, "DLMS: Unsupported system title length");
- this->receive_buffer_.clear();
- return false;
- }
-
- message_length = mbus_payload[DLMS_LENGTH_OFFSET];
- header_offset = 0;
-
- if (this->provider_ == PROVIDER_NETZNOE) {
- // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next
- // byte. Check some bytes to see if received data still matches expectation
- if (message_length == NETZ_NOE_MAGIC_BYTE &&
- mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH &&
- mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) {
- message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1];
- header_offset = 1;
- } else {
- ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN");
- }
- } else {
- if (message_length == TWO_BYTE_LENGTH) {
- message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]);
- header_offset = DLMS_HEADER_EXT_OFFSET;
- }
- }
- if (message_length < DLMS_LENGTH_CORRECTION) {
- ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length);
- this->receive_buffer_.clear();
- return false;
- }
- message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length
-
- if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) {
- ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(),
- DLMS_HEADER_LENGTH, header_offset, message_length);
- ESP_LOGE(TAG, "DLMS: Message has invalid length");
- this->receive_buffer_.clear();
- return false;
- }
-
- if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 &&
- mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] !=
- 0x20) { // Only certain security suite is supported (0x21 || 0x20)
- ESP_LOGE(TAG, "DLMS: Unsupported security control byte");
- this->receive_buffer_.clear();
- return false;
- }
-
- return true;
+ this->last_rx_char_time_ = App.get_loop_component_start_time();
}
-bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length,
- uint16_t header_offset) {
- ESP_LOGV(TAG, "Decrypting payload");
- uint8_t iv[12]; // Reserve space for the IV, always 12 bytes
- // Copy system title to IV (System title is before length; no header offset needed!)
- // Add 1 to the offset in order to skip the system title length byte
- memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length);
- memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET],
- DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV
+void DlmsMeterComponent::process_frame_() {
+ ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_);
- uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET];
+ auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) {
+ this->on_data_(obis_code, float_val, str_val, is_numeric);
+ };
-#if defined(USE_ESP8266_FRAMEWORK_ARDUINO)
- br_gcm_context gcm_ctx;
- br_aes_ct_ctr_keys bc;
- br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size());
- br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32);
- br_gcm_reset(&gcm_ctx, iv, sizeof(iv));
- br_gcm_flip(&gcm_ctx);
- br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length);
-#elif defined(USE_ESP32)
-#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
- // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior)
- psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT;
- psa_set_key_type(&attributes, PSA_KEY_TYPE_AES);
- psa_set_key_bits(&attributes, this->decryption_key_.size() * 8);
- psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT);
- psa_set_key_algorithm(&attributes, PSA_ALG_GCM);
+ this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback);
- mbedtls_svc_key_id_t key_id;
- bool decrypt_failed = true;
- if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) {
- psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT;
- if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS &&
- psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) {
- size_t outlen = 0;
- if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS &&
- outlen == message_length) {
- decrypt_failed = false;
+ this->bytes_accumulated_ = 0;
+}
+
+void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) {
+ int updated_count = 0;
+
+#ifdef USE_SENSOR
+ if (is_numeric) {
+ for (auto &item : this->sensors_) {
+ if (item.obis_code == obis_code) {
+ item.sensor->publish_state(float_val);
+ updated_count++;
}
}
- psa_aead_abort(&op);
- psa_destroy_key(key_id);
- }
- if (decrypt_failed) {
- ESP_LOGE(TAG, "Decryption failed");
- this->receive_buffer_.clear();
- return false;
- }
-#else
- size_t outlen = 0;
- mbedtls_gcm_context gcm_ctx;
- mbedtls_gcm_init(&gcm_ctx);
- mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8);
- mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv));
- auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen);
- mbedtls_gcm_free(&gcm_ctx);
- if (ret != 0) {
- ESP_LOGE(TAG, "Decryption failed with error: %d", ret);
- this->receive_buffer_.clear();
- return false;
}
#endif
-#else
-#error "Invalid Platform"
+
+#ifdef USE_TEXT_SENSOR
+ if (!is_numeric && str_val != nullptr) {
+ for (auto &item : this->text_sensors_) {
+ if (item.obis_code == obis_code) {
+ item.sensor->publish_state(str_val);
+ updated_count++;
+ }
+ }
+ }
#endif
- if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) {
- ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid");
- this->receive_buffer_.clear();
- return false;
- }
- ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length);
- return true;
-}
-
-void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) {
- ESP_LOGV(TAG, "Decoding payload");
- MeterData data{};
- uint16_t current_position = DECODER_START_OFFSET;
- bool power_factor_found = false;
-
- while (current_position + OBIS_CODE_OFFSET <= message_length) {
- if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) {
- ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]);
- this->receive_buffer_.clear();
- return;
- }
-
- uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET];
- if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) {
- ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length);
- this->receive_buffer_.clear();
- return;
- }
- if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) {
- ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code");
- this->receive_buffer_.clear();
- return;
- }
-
- uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET];
- uint8_t obis_medium = obis_code[OBIS_A];
- uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]);
-
- bool timestamp_found = false;
- bool meter_number_found = false;
- if (this->provider_ == PROVIDER_NETZNOE) {
- // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET
- if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) {
- timestamp_found = true;
- } else if (power_factor_found) {
- meter_number_found = true;
- power_factor_found = false;
- } else {
- current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position
- }
- } else {
- current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type
- }
- if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY &&
- obis_medium != Medium::ABSTRACT) {
- ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium);
- this->receive_buffer_.clear();
- return;
- }
-
- if (current_position >= message_length) {
- ESP_LOGE(TAG, "OBIS: Buffer too short for data type");
- this->receive_buffer_.clear();
- return;
- }
-
- float value = 0.0f;
- uint8_t value_size = 0;
- uint8_t data_type = plaintext[current_position];
- current_position++;
-
- switch (data_type) {
- case DataType::DOUBLE_LONG_UNSIGNED: {
- value_size = 4;
- if (current_position + value_size > message_length) {
- ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED");
- this->receive_buffer_.clear();
- return;
- }
- value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1],
- plaintext[current_position + 2], plaintext[current_position + 3]);
- current_position += value_size;
- break;
- }
- case DataType::LONG_UNSIGNED: {
- value_size = 2;
- if (current_position + value_size > message_length) {
- ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED");
- this->receive_buffer_.clear();
- return;
- }
- value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]);
- current_position += value_size;
- break;
- }
- case DataType::OCTET_STRING: {
- uint8_t data_length = plaintext[current_position];
- current_position++; // Advance past string length
- if (current_position + data_length > message_length) {
- ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING");
- this->receive_buffer_.clear();
- return;
- }
- // Handle timestamp (normal OBIS code or NETZNOE special case)
- if (obis_cd == OBIS_TIMESTAMP || timestamp_found) {
- if (data_length < 8) {
- ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length);
- this->receive_buffer_.clear();
- return;
- }
- uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]);
- uint8_t month = plaintext[current_position + 2];
- uint8_t day = plaintext[current_position + 3];
- uint8_t hour = plaintext[current_position + 5];
- uint8_t minute = plaintext[current_position + 6];
- uint8_t second = plaintext[current_position + 7];
- if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) {
- ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute,
- second);
- this->receive_buffer_.clear();
- return;
- }
- snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour,
- minute, second);
- } else if (meter_number_found) {
- snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]);
- }
- current_position += data_length;
- break;
- }
- default:
- ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type);
- this->receive_buffer_.clear();
- return;
- }
-
- // Skip break after data
- if (this->provider_ == PROVIDER_NETZNOE) {
- // Don't skip the break on the first timestamp, as there's none
- if (!timestamp_found) {
- current_position += 2;
- }
- } else {
- current_position += 2;
- }
-
- // Check for additional data (scaler-unit structure)
- if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) {
- // Apply scaler: real_value = raw_value × 10^scaler
- if (current_position + 1 < message_length) {
- int8_t scaler = static_cast(plaintext[current_position + 1]);
- if (scaler != 0) {
- value *= pow10_int(scaler);
- }
- }
-
- // on EVN Meters there is no additional break
- if (this->provider_ == PROVIDER_NETZNOE) {
- current_position += 4;
- } else {
- current_position += 6;
- }
- }
-
- // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED)
- if (value_size > 0) {
- switch (obis_cd) {
- case OBIS_VOLTAGE_L1:
- data.voltage_l1 = value;
- break;
- case OBIS_VOLTAGE_L2:
- data.voltage_l2 = value;
- break;
- case OBIS_VOLTAGE_L3:
- data.voltage_l3 = value;
- break;
- case OBIS_CURRENT_L1:
- data.current_l1 = value;
- break;
- case OBIS_CURRENT_L2:
- data.current_l2 = value;
- break;
- case OBIS_CURRENT_L3:
- data.current_l3 = value;
- break;
- case OBIS_ACTIVE_POWER_PLUS:
- data.active_power_plus = value;
- break;
- case OBIS_ACTIVE_POWER_MINUS:
- data.active_power_minus = value;
- break;
- case OBIS_ACTIVE_ENERGY_PLUS:
- data.active_energy_plus = value;
- break;
- case OBIS_ACTIVE_ENERGY_MINUS:
- data.active_energy_minus = value;
- break;
- case OBIS_REACTIVE_ENERGY_PLUS:
- data.reactive_energy_plus = value;
- break;
- case OBIS_REACTIVE_ENERGY_MINUS:
- data.reactive_energy_minus = value;
- break;
- case OBIS_POWER_FACTOR:
- data.power_factor = value;
- power_factor_found = true;
- break;
- default:
- ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd);
+#ifdef USE_BINARY_SENSOR
+ if (is_numeric) {
+ bool state = float_val != 0.0f;
+ for (auto &item : this->binary_sensors_) {
+ if (item.obis_code == obis_code) {
+ item.sensor->publish_state(state);
+ updated_count++;
}
}
}
+#endif
- this->receive_buffer_.clear();
-
- ESP_LOGI(TAG, "Received valid data");
- this->publish_sensors(data);
- this->status_clear_warning();
+ if (updated_count == 0) {
+ ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code);
+ }
}
+#ifdef USE_SENSOR
+void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) {
+ this->sensors_.push_back({obis_code, sensor});
+}
+#endif
+#ifdef USE_TEXT_SENSOR
+void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) {
+ this->text_sensors_.push_back({obis_code, sensor});
+}
+#endif
+#ifdef USE_BINARY_SENSOR
+void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) {
+ this->binary_sensors_.push_back({obis_code, sensor});
+}
+#endif
+
} // namespace esphome::dlms_meter
diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h
index c50e6f6b4d..cdc53d5685 100644
--- a/esphome/components/dlms_meter/dlms_meter.h
+++ b/esphome/components/dlms_meter/dlms_meter.h
@@ -2,95 +2,150 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
+#include "esphome/core/application.h"
#include "esphome/core/log.h"
+#include "esphome/core/helpers.h"
+#include "esphome/components/uart/uart.h"
+
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
-#include "esphome/components/uart/uart.h"
+#ifdef USE_BINARY_SENSOR
+#include "esphome/components/binary_sensor/binary_sensor.h"
+#endif
-#include "mbus.h"
-#include "dlms.h"
-#include "obis.h"
+#include
-#include
#include
+#include
+#include
+#include
+#include
+
+#if __has_include()
+#include
+#elif !defined(USE_ESP8266) && __has_include()
+#if __has_include()
+#include
+#endif
+#include
+#elif __has_include()
+#include
+#else
+#define DLMS_METER_NO_CRYPTO
+#endif
+
+#ifndef DLMS_MAX_SENSORS
+static constexpr uint8_t DLMS_MAX_SENSORS = 0;
+#endif
+#ifndef DLMS_MAX_TEXT_SENSORS
+static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0;
+#endif
+#ifndef DLMS_MAX_BINARY_SENSORS
+static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0;
+#endif
namespace esphome::dlms_meter {
-#ifndef DLMS_METER_SENSOR_LIST
-#define DLMS_METER_SENSOR_LIST(F, SEP)
-#endif
-
-#ifndef DLMS_METER_TEXT_SENSOR_LIST
-#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP)
-#endif
-
-struct MeterData {
- float voltage_l1 = 0.0f; // Voltage L1
- float voltage_l2 = 0.0f; // Voltage L2
- float voltage_l3 = 0.0f; // Voltage L3
- float current_l1 = 0.0f; // Current L1
- float current_l2 = 0.0f; // Current L2
- float current_l3 = 0.0f; // Current L3
- float active_power_plus = 0.0f; // Active power taken from grid
- float active_power_minus = 0.0f; // Active power put into grid
- float active_energy_plus = 0.0f; // Active energy taken from grid
- float active_energy_minus = 0.0f; // Active energy put into grid
- float reactive_energy_plus = 0.0f; // Reactive energy taken from grid
- float reactive_energy_minus = 0.0f; // Reactive energy put into grid
- char timestamp[27]{}; // Text sensor for the timestamp value
-
- // Netz NOE
- float power_factor = 0.0f; // Power Factor
- char meternumber[13]{}; // Text sensor for the meterNumber value
+#ifdef DLMS_METER_NO_CRYPTO
+// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy)
+class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor {
+ public:
+ void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {}
+ bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext,
+ std::span aad, std::span tag) override {
+ return false;
+ }
};
+#endif
-// Provider constants
-enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 };
+#if __has_include()
+using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa;
+#elif !defined(USE_ESP8266) && __has_include()
+using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls;
+#elif __has_include()
+using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl;
+#else
+using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy;
+#endif
+
+#ifdef USE_SENSOR
+struct SensorItem {
+ std::string obis_code;
+ sensor::Sensor *sensor;
+};
+#endif
+#ifdef USE_TEXT_SENSOR
+struct TextSensorItem {
+ std::string obis_code;
+ text_sensor::TextSensor *sensor;
+};
+#endif
+#ifdef USE_BINARY_SENSOR
+struct BinarySensorItem {
+ std::string obis_code;
+ binary_sensor::BinarySensor *sensor;
+};
+#endif
+
+struct CustomPattern {
+ std::string pattern;
+ std::optional name;
+ int priority{0};
+ std::optional> default_obis;
+};
class DlmsMeterComponent : public Component, public uart::UARTDevice {
public:
- DlmsMeterComponent() = default;
+ DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check,
+ std::optional> decryption_key,
+ std::optional> authentication_key,
+ std::vector custom_patterns);
+ void setup() override;
void dump_config() override;
void loop() override;
- void set_decryption_key(const std::array &key) { this->decryption_key_ = key; }
- void set_provider(uint32_t provider) { this->provider_ = provider; }
-
- void publish_sensors(MeterData &data) {
-#define DLMS_METER_PUBLISH_SENSOR(s) \
- if (this->s##_sensor_ != nullptr) \
- s##_sensor_->publish_state(data.s);
- DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, )
-
-#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \
- if (this->s##_text_sensor_ != nullptr) \
- s##_text_sensor_->publish_state(data.s);
- DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, )
- }
-
- DLMS_METER_SENSOR_LIST(SUB_SENSOR, )
- DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, )
+#ifdef USE_SENSOR
+ void register_sensor(const std::string &obis_code, sensor::Sensor *sensor);
+#endif
+#ifdef USE_TEXT_SENSOR
+ void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor);
+#endif
+#ifdef USE_BINARY_SENSOR
+ void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor);
+#endif
protected:
- bool parse_mbus_(std::vector &mbus_payload);
- bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length,
- uint16_t &header_offset);
- bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length,
- uint16_t header_offset);
- void decode_obis_(uint8_t *plaintext, uint16_t message_length);
+ void read_rx_buffer_();
+ void flush_rx_buffer_();
+ void process_frame_();
+ void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric);
- std::vector receive_buffer_; // Stores the packet currently being received
- std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn
- uint32_t last_read_ = 0; // Timestamp when data was last read
- uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete
+ std::array rx_buffer_;
+ size_t bytes_accumulated_{0};
+ uint32_t last_rx_char_time_{0};
- uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator
- std::array decryption_key_;
+ uint32_t receive_timeout_ms_{1000};
+ bool skip_crc_check_{false};
+
+ std::vector custom_patterns_;
+
+ Aes128GcmDecryptorImpl decryptor_;
+ dlms_parser::DlmsParser parser_;
+
+#ifdef USE_SENSOR
+ StaticVector sensors_;
+#endif
+#ifdef USE_TEXT_SENSOR
+ StaticVector text_sensors_;
+#endif
+#ifdef USE_BINARY_SENSOR
+ StaticVector binary_sensors_;
+#endif
};
} // namespace esphome::dlms_meter
diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h
deleted file mode 100644
index 293d43a55b..0000000000
--- a/esphome/components/dlms_meter/mbus.h
+++ /dev/null
@@ -1,69 +0,0 @@
-#pragma once
-
-#include
-
-namespace esphome::dlms_meter {
-
-/*
-+----------------------------------------------------+ -
-| Start Character [0x68] | \
-+----------------------------------------------------+ |
-| Data Length (L) | |
-+----------------------------------------------------+ |
-| Data Length Repeat (L) | |
-+----------------------------------------------------+ > M-Bus Data link layer
-| Start Character Repeat [0x68] | |
-+----------------------------------------------------+ |
-| Control/Function Field (C) | |
-+----------------------------------------------------+ |
-| Address Field (A) | /
-+----------------------------------------------------+ -
-| Control Information Field (CI) | \
-+----------------------------------------------------+ |
-| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer
-+----------------------------------------------------+ |
-| Destination Transport Service Access Point (DTSAP) | /
-+----------------------------------------------------+ -
-| | \
-~ ~ |
- Data > DLMS/COSEM Application Layer
-~ ~ |
-| | /
-+----------------------------------------------------+ -
-| Checksum | \
-+----------------------------------------------------+ > M-Bus Data link layer
-| Stop Character [0x16] | /
-+----------------------------------------------------+ -
-
-Data_Length = L - C - A - CI
-Each line (except Data) is one Byte
-
-Possible Values found in publicly available docs:
-- C: 0x53/0x73 (SND_UD)
-- A: FF (Broadcast)
-- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D
-- STSAP: 0x01 (Management Logical Device ID 1 of the meter)
-- DTSAP: 0x67 (Consumer Information Push Client ID 103)
- */
-
-// MBUS start bytes for different telegram formats:
-// - Single Character: 0xE5 (length=1)
-// - Short Frame: 0x10 (length=5)
-// - Control Frame: 0x68 (length=9)
-// - Long Frame: 0x68 (length=9+data_length)
-// This component currently only uses Long Frame.
-static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5;
-static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10;
-static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68;
-static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68;
-static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68)
-static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length
-static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame
-static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame
-static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte
-static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte
-static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte
-static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte
-static constexpr uint8_t STOP_BYTE = 0x16;
-
-} // namespace esphome::dlms_meter
diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h
deleted file mode 100644
index 1bb960e61e..0000000000
--- a/esphome/components/dlms_meter/obis.h
+++ /dev/null
@@ -1,94 +0,0 @@
-#pragma once
-
-#include
-
-namespace esphome::dlms_meter {
-
-// Data types as per specification
-enum DataType {
- NULL_DATA = 0x00,
- BOOLEAN = 0x03,
- BIT_STRING = 0x04,
- DOUBLE_LONG = 0x05,
- DOUBLE_LONG_UNSIGNED = 0x06,
- OCTET_STRING = 0x09,
- VISIBLE_STRING = 0x0A,
- UTF8_STRING = 0x0C,
- BINARY_CODED_DECIMAL = 0x0D,
- INTEGER = 0x0F,
- LONG = 0x10,
- UNSIGNED = 0x11,
- LONG_UNSIGNED = 0x12,
- LONG64 = 0x14,
- LONG64_UNSIGNED = 0x15,
- ENUM = 0x16,
- FLOAT32 = 0x17,
- FLOAT64 = 0x18,
- DATE_TIME = 0x19,
- DATE = 0x1A,
- TIME = 0x1B,
-
- ARRAY = 0x01,
- STRUCTURE = 0x02,
- COMPACT_ARRAY = 0x13
-};
-
-enum Medium {
- ABSTRACT = 0x00,
- ELECTRICITY = 0x01,
- HEAT_COST_ALLOCATOR = 0x04,
- COOLING = 0x05,
- HEAT = 0x06,
- GAS = 0x07,
- COLD_WATER = 0x08,
- HOT_WATER = 0x09,
- OIL = 0x10,
- COMPRESSED_AIR = 0x11,
- NITROGEN = 0x12
-};
-
-// Data structure
-static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block
-static constexpr uint8_t OBIS_TYPE_OFFSET = 0;
-static constexpr uint8_t OBIS_LENGTH_OFFSET = 1;
-static constexpr uint8_t OBIS_CODE_OFFSET = 2;
-static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F)
-static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code
-static constexpr uint8_t OBIS_A = 0;
-static constexpr uint8_t OBIS_B = 1;
-static constexpr uint8_t OBIS_C = 2;
-static constexpr uint8_t OBIS_D = 3;
-static constexpr uint8_t OBIS_E = 4;
-static constexpr uint8_t OBIS_F = 5;
-
-// Metadata
-static constexpr uint16_t OBIS_TIMESTAMP = 0x0100;
-static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001;
-static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00;
-
-// Voltage
-static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007;
-static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407;
-static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807;
-
-// Current
-static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07;
-static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307;
-static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707;
-
-// Power
-static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107;
-static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207;
-
-// Active energy
-static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108;
-static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208;
-
-// Reactive energy
-static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308;
-static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408;
-
-// Netz NOE specific
-static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07;
-
-} // namespace esphome::dlms_meter
diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py
index 27fd44f008..ec4639351d 100644
--- a/esphome/components/dlms_meter/sensor/__init__.py
+++ b/esphome/components/dlms_meter/sensor/__init__.py
@@ -1,8 +1,9 @@
+import logging
+
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
- CONF_ID,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
@@ -16,109 +17,142 @@ from esphome.const import (
UNIT_WATT_HOURS,
)
-from .. import CONF_DLMS_METER_ID, DlmsMeterComponent
+from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
-AUTO_LOAD = ["dlms_meter"]
+_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.Schema(
+DEPENDENCIES = ["dlms_meter"]
+
+NUMERIC_KEYS = {
+ "voltage_l1": "1.0.32.7.0.255",
+ "voltage_l2": "1.0.52.7.0.255",
+ "voltage_l3": "1.0.72.7.0.255",
+ "current_l1": "1.0.31.7.0.255",
+ "current_l2": "1.0.51.7.0.255",
+ "current_l3": "1.0.71.7.0.255",
+ "active_power_plus": "1.0.1.7.0.255",
+ "active_power_minus": "1.0.2.7.0.255",
+ "active_energy_plus": "1.0.1.8.0.255",
+ "active_energy_minus": "1.0.2.8.0.255",
+ "reactive_energy_plus": "1.0.3.8.0.255",
+ "reactive_energy_minus": "1.0.4.8.0.255",
+ "power_factor": "1.0.13.7.0.255",
+}
+
+DYNAMIC_SCHEMA = sensor.sensor_schema().extend(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
- cv.Optional("voltage_l1"): sensor.sensor_schema(
- unit_of_measurement=UNIT_VOLT,
- accuracy_decimals=1,
- device_class=DEVICE_CLASS_VOLTAGE,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("voltage_l2"): sensor.sensor_schema(
- unit_of_measurement=UNIT_VOLT,
- accuracy_decimals=1,
- device_class=DEVICE_CLASS_VOLTAGE,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("voltage_l3"): sensor.sensor_schema(
- unit_of_measurement=UNIT_VOLT,
- accuracy_decimals=1,
- device_class=DEVICE_CLASS_VOLTAGE,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("current_l1"): sensor.sensor_schema(
- unit_of_measurement=UNIT_AMPERE,
- accuracy_decimals=2,
- device_class=DEVICE_CLASS_CURRENT,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("current_l2"): sensor.sensor_schema(
- unit_of_measurement=UNIT_AMPERE,
- accuracy_decimals=2,
- device_class=DEVICE_CLASS_CURRENT,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("current_l3"): sensor.sensor_schema(
- unit_of_measurement=UNIT_AMPERE,
- accuracy_decimals=2,
- device_class=DEVICE_CLASS_CURRENT,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("active_power_plus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_POWER,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("active_power_minus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_POWER,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
- cv.Optional("active_energy_plus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT_HOURS,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_ENERGY,
- state_class=STATE_CLASS_TOTAL_INCREASING,
- ),
- cv.Optional("active_energy_minus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT_HOURS,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_ENERGY,
- state_class=STATE_CLASS_TOTAL_INCREASING,
- ),
- cv.Optional("reactive_energy_plus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT_HOURS,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_ENERGY,
- state_class=STATE_CLASS_TOTAL_INCREASING,
- ),
- cv.Optional("reactive_energy_minus"): sensor.sensor_schema(
- unit_of_measurement=UNIT_WATT_HOURS,
- accuracy_decimals=0,
- device_class=DEVICE_CLASS_ENERGY,
- state_class=STATE_CLASS_TOTAL_INCREASING,
- ),
- # Netz NOE
- cv.Optional("power_factor"): sensor.sensor_schema(
- accuracy_decimals=3,
- device_class=DEVICE_CLASS_POWER_FACTOR,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
+ cv.Required(CONF_OBIS_CODE): obis_code,
}
-).extend(cv.COMPONENT_SCHEMA)
+)
+
+
+def deprecation_warning(config):
+ _LOGGER.warning(
+ "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. "
+ "Please update your configuration to use the new schema with 'obis_code'."
+ )
+ return config
+
+
+OLD_SCHEMA = cv.All(
+ cv.Schema(
+ {
+ cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
+ cv.Optional("voltage_l1"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_VOLT,
+ accuracy_decimals=1,
+ device_class=DEVICE_CLASS_VOLTAGE,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("voltage_l2"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_VOLT,
+ accuracy_decimals=1,
+ device_class=DEVICE_CLASS_VOLTAGE,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("voltage_l3"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_VOLT,
+ accuracy_decimals=1,
+ device_class=DEVICE_CLASS_VOLTAGE,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("current_l1"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_AMPERE,
+ accuracy_decimals=2,
+ device_class=DEVICE_CLASS_CURRENT,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("current_l2"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_AMPERE,
+ accuracy_decimals=2,
+ device_class=DEVICE_CLASS_CURRENT,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("current_l3"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_AMPERE,
+ accuracy_decimals=2,
+ device_class=DEVICE_CLASS_CURRENT,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("active_power_plus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_POWER,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("active_power_minus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_POWER,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ cv.Optional("active_energy_plus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT_HOURS,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_ENERGY,
+ state_class=STATE_CLASS_TOTAL_INCREASING,
+ ),
+ cv.Optional("active_energy_minus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT_HOURS,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_ENERGY,
+ state_class=STATE_CLASS_TOTAL_INCREASING,
+ ),
+ cv.Optional("reactive_energy_plus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT_HOURS,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_ENERGY,
+ state_class=STATE_CLASS_TOTAL_INCREASING,
+ ),
+ cv.Optional("reactive_energy_minus"): sensor.sensor_schema(
+ unit_of_measurement=UNIT_WATT_HOURS,
+ accuracy_decimals=0,
+ device_class=DEVICE_CLASS_ENERGY,
+ state_class=STATE_CLASS_TOTAL_INCREASING,
+ ),
+ cv.Optional("power_factor"): sensor.sensor_schema(
+ accuracy_decimals=3,
+ device_class=DEVICE_CLASS_POWER_FACTOR,
+ state_class=STATE_CLASS_MEASUREMENT,
+ ),
+ }
+ ).extend(cv.COMPONENT_SCHEMA),
+ deprecation_warning,
+)
+
+
+CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA)
async def to_code(config):
hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
- sensors = []
- for key, conf in config.items():
- if not isinstance(conf, dict):
- continue
- id = conf[CONF_ID]
- if id and id.type == sensor.Sensor:
- sens = await sensor.new_sensor(conf)
- cg.add(getattr(hub, f"set_{key}_sensor")(sens))
- sensors.append(f"F({key})")
-
- if sensors:
- cg.add_define(
- "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors))
- )
+ if obis := config.get(CONF_OBIS_CODE):
+ var = await sensor.new_sensor(config)
+ cg.add(hub.register_sensor(obis, var))
+ else:
+ for key, obis_val in NUMERIC_KEYS.items():
+ if sensor_config := config.get(key):
+ sens = await sensor.new_sensor(sensor_config)
+ cg.add(hub.register_sensor(obis_val, sens))
diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py
index 4d2373f4f9..0bfb43a285 100644
--- a/esphome/components/dlms_meter/text_sensor/__init__.py
+++ b/esphome/components/dlms_meter/text_sensor/__init__.py
@@ -1,37 +1,59 @@
+import logging
+
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
-from esphome.const import CONF_ID
-from .. import CONF_DLMS_METER_ID, DlmsMeterComponent
+from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code
-AUTO_LOAD = ["dlms_meter"]
+_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.Schema(
+DEPENDENCIES = ["dlms_meter"]
+
+TEXT_KEYS = {
+ "timestamp": "0.0.1.0.0.255",
+ "meternumber": "0.0.96.1.0.255",
+}
+
+DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend(
{
cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
- cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
- # Netz NOE
- cv.Optional("meternumber"): text_sensor.text_sensor_schema(),
+ cv.Required(CONF_OBIS_CODE): obis_code,
}
-).extend(cv.COMPONENT_SCHEMA)
+)
+
+
+def deprecation_warning(config):
+ _LOGGER.warning(
+ "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. "
+ "Please update your configuration to use the new schema with 'obis_code'."
+ )
+ return config
+
+
+OLD_SCHEMA = cv.All(
+ cv.Schema(
+ {
+ cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent),
+ cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
+ cv.Optional("meternumber"): text_sensor.text_sensor_schema(),
+ }
+ ).extend(cv.COMPONENT_SCHEMA),
+ deprecation_warning,
+)
+
+
+CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA)
async def to_code(config):
hub = await cg.get_variable(config[CONF_DLMS_METER_ID])
- text_sensors = []
- for key, conf in config.items():
- if not isinstance(conf, dict):
- continue
- id = conf[CONF_ID]
- if id and id.type == text_sensor.TextSensor:
- sens = await text_sensor.new_text_sensor(conf)
- cg.add(getattr(hub, f"set_{key}_text_sensor")(sens))
- text_sensors.append(f"F({key})")
-
- if text_sensors:
- cg.add_define(
- "DLMS_METER_TEXT_SENSOR_LIST(F, sep)",
- cg.RawExpression(" sep ".join(text_sensors)),
- )
+ if obis := config.get(CONF_OBIS_CODE):
+ var = await text_sensor.new_text_sensor(config)
+ cg.add(hub.register_text_sensor(obis, var))
+ else:
+ for key, obis_val in TEXT_KEYS.items():
+ if text_sensor_config := config.get(key):
+ sens = await text_sensor.new_text_sensor(text_sensor_config)
+ cg.add(hub.register_text_sensor(obis_val, sens))
diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py
index 31ec1ce5b5..1dc3664602 100644
--- a/esphome/components/dsmr/__init__.py
+++ b/esphome/components/dsmr/__init__.py
@@ -87,7 +87,7 @@ async def to_code(config):
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
- cg.add_library("esphome/dsmr_parser", "1.4.0")
+ cg.add_library("esphome/dsmr_parser", "1.9.0")
def final_validate(config: ConfigType) -> ConfigType:
diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp
index 2fa51f73af..9580464a2e 100644
--- a/esphome/components/dsmr/dsmr.cpp
+++ b/esphome/components/dsmr/dsmr.cpp
@@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() {
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
this->stop_requesting_data_();
- ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
- ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data());
+ ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size());
+ ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.full_content().size()),
+ telegram.full_content().data());
MyData data;
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
@@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram)
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
if (this->s_telegram_ != nullptr) {
- this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
+ this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size());
}
return true;
}
diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h
index 626a389c1f..3642309c26 100644
--- a/esphome/components/dsmr/dsmr.h
+++ b/esphome/components/dsmr/dsmr.h
@@ -16,9 +16,14 @@
#include
#include
+// 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()
#include
-#elif __has_include()
+#elif !defined(USE_ESP8266) && __has_include()
#if __has_include()
#include
#endif
@@ -33,7 +38,7 @@ namespace esphome::dsmr {
#if __has_include()
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
-#elif __has_include()
+#elif !defined(USE_ESP8266) && __has_include()
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
#else
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
@@ -69,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice {
receive_timeout_(receive_timeout),
request_pin_(request_pin),
buffer_(max_telegram_length),
- packet_accumulator_(buffer_, crc_check) {
+ packet_accumulator_(buffer_, crc_check),
+ dlms_decryptor_(gcm_decryptor_, crc_check) {
this->set_decryption_key_(decryption_key);
}
@@ -92,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice {
// Remove before 2026.8.0
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
- void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
+ void set_decryption_key(const std::string &decryption_key) {
+ // Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols,
+ // otherwise `Aes128GcmDecryptionKey::from_hex` will fail.
+ this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str());
+ }
// Sensor setters
#define DSMR_SET_SENSOR(s) \
@@ -138,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice {
std::vector buffer_;
dsmr_parser::PacketAccumulator packet_accumulator_;
Aes128GcmDecryptorImpl gcm_decryptor_;
- dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
+ dsmr_parser::DlmsPacketDecryptor dlms_decryptor_;
std::array uart_chunk_reading_buf_;
};
} // namespace esphome::dsmr
diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py
index 292e5a1156..7d93ee62e1 100644
--- a/esphome/components/dsmr/sensor.py
+++ b/esphome/components/dsmr/sensor.py
@@ -248,10 +248,6 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
- cv.Optional("electricity_switch_position"): sensor.sensor_schema(
- accuracy_decimals=3,
- state_class=STATE_CLASS_MEASUREMENT,
- ),
cv.Optional("electricity_failures"): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
@@ -808,6 +804,10 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
+ cv.Optional("electricity_switch_position"): cv.invalid(
+ "'electricity_switch_position' has moved to the 'text_sensor' platform."
+ "Move it under 'text_sensor' to fix."
+ ),
}
).extend(cv.COMPONENT_SCHEMA)
diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py
index a8f29c7ca8..54b5711923 100644
--- a/esphome/components/dsmr/text_sensor.py
+++ b/esphome/components/dsmr/text_sensor.py
@@ -14,6 +14,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("p1_version"): text_sensor.text_sensor_schema(),
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
+ cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),
diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h
index bfcb0ca7f8..6574037efb 100644
--- a/esphome/components/e131/e131.h
+++ b/esphome/components/e131/e131.h
@@ -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);
diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py
index 658f9e2c4a..b7c56a283a 100644
--- a/esphome/components/epaper_spi/display.py
+++ b/esphome/components/epaper_spi/display.py
@@ -5,7 +5,11 @@ from esphome import core, pins
import esphome.codegen as cg
from esphome.components import display, spi
from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation
-from esphome.components.mipi import flatten_sequence, map_sequence
+from esphome.components.mipi import (
+ flatten_sequence,
+ map_sequence,
+ model_schema_extractor,
+)
import esphome.config_validation as cv
from esphome.config_validation import update_interval
from esphome.const import (
@@ -111,6 +115,7 @@ def model_schema(config):
)
+@model_schema_extractor(MODELS, model_schema)
def customise_schema(config):
"""
Create a customised config schema for a specific model and validate the configuration.
diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py
index 1a95f77437..5d4b3b8b47 100644
--- a/esphome/components/esp32/__init__.py
+++ b/esphome/components/esp32/__init__.py
@@ -1,3 +1,4 @@
+from collections.abc import Callable, Iterable
import contextlib
from dataclasses import dataclass
import itertools
@@ -6,6 +7,7 @@ import os
from pathlib import Path
import re
import subprocess
+from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
@@ -46,17 +48,18 @@ from esphome.const import (
Toolchain,
__version__,
)
-from esphome.core import CORE, EsphomeError, HexInt, Library
+from esphome.core import CORE, EsphomeError, HexInt
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
-from esphome.espidf.component import generate_idf_component
+from esphome.espidf.component import generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
+from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
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 +81,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 +409,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 +473,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
@@ -491,6 +499,32 @@ def get_esp32_variant(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
+def variant_filtered_enum(
+ by_variant: dict[str, Iterable[Any]], **kwargs: Any
+) -> Callable[[Any], Any]:
+ """Build a ``one_of`` validator whose valid set depends on the active variant.
+
+ ``by_variant`` maps each ESP32 variant constant to the iterable of values that
+ are valid on that variant. At validation time the value is checked against the
+ set allowed for the current target variant. For schema extraction the inverted
+ ``{value: [variants, ...]}`` map is returned instead, so the language-schema
+ dump can tag every option with the variants that accept it and frontends can
+ filter to the user's selected variant.
+ """
+ by_value: dict[str, list[str]] = {}
+ for variant, values in by_variant.items():
+ for value in values:
+ by_value.setdefault(str(value), []).append(variant)
+
+ @schema_extractor("variant_enum")
+ def validator(value: Any) -> Any:
+ if value is SCHEMA_EXTRACT:
+ return by_value
+ return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value)
+
+ return validator
+
+
def get_board(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD]
@@ -710,11 +744,15 @@ def _is_framework_url(source: str) -> bool:
# The default/recommended arduino framework version
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
- "recommended": cv.Version(3, 3, 8),
- "latest": cv.Version(3, 3, 8),
- "dev": cv.Version(3, 3, 8),
+ "recommended": cv.Version(3, 3, 9),
+ "latest": cv.Version(3, 3, 9),
+ "dev": cv.Version(3, 3, 9),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
+ cv.Version(
+ 4, 0, 0, "alpha1"
+ ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
+ cv.Version(3, 3, 9): cv.Version(55, 3, 39),
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 +773,8 @@ 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, 9): cv.Version(5, 5, 4),
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),
@@ -767,7 +807,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
- cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"),
+ cv.Version(5, 5, 4): cv.Version(55, 3, 39),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
@@ -787,8 +827,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# The platform-espressif32 version
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
- "recommended": cv.Version(55, 3, 38, "1"),
- "latest": cv.Version(55, 3, 38, "1"),
+ "recommended": cv.Version(55, 3, 39),
+ "latest": cv.Version(55, 3, 39),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}
@@ -829,6 +869,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 +947,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 +959,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 +990,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 +1291,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 +1400,18 @@ 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. zigbee). No-op on
+ IDF < 6.0.0.
+ """
+ if idf_version() < cv.Version(6, 0, 0):
+ return
+ 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 *)
@@ -1560,8 +1644,14 @@ FLASH_SIZES = [
]
CONF_FLASH_SIZE = "flash_size"
+CONF_FLASH_MODE = "flash_mode"
+CONF_FLASH_FREQUENCY = "flash_frequency"
CONF_CPU_FREQUENCY = "cpu_frequency"
CONF_PARTITIONS = "partitions"
+FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"]
+FLASH_FREQUENCIES = [
+ f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16)
+]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -1575,6 +1665,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of(
*FLASH_SIZES, upper=True
),
+ cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True),
+ cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
+ *FLASH_FREQUENCIES, upper=True
+ ),
cv.Optional(CONF_PARTITIONS): cv.Any(
cv.file_,
cv.ensure_list(
@@ -1606,6 +1700,7 @@ CONFIG_SCHEMA = cv.All(
),
}
),
+ _resolve_toolchain,
_detect_variant,
_set_default_framework,
_check_versions,
@@ -1732,6 +1827,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."""
@@ -1790,6 +1905,12 @@ async def to_code(config):
"board_upload.maximum_size",
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
)
+ if flash_mode := config.get(CONF_FLASH_MODE):
+ cg.add_platformio_option("board_build.flash_mode", flash_mode)
+ if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
+ cg.add_platformio_option(
+ "board_build.f_flash", f"{flash_frequency[:-3]}000000L"
+ )
if CONF_SOURCE in conf:
cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]])
@@ -1940,6 +2061,14 @@ async def to_code(config):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True
)
+ if flash_mode := config.get(CONF_FLASH_MODE):
+ add_idf_sdkconfig_option(
+ f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True
+ )
+ if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
+ add_idf_sdkconfig_option(
+ f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
+ )
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
@@ -2011,7 +2140,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
# Setup watchdog
- add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
+ add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
@@ -2053,7 +2182,8 @@ async def to_code(config):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
- add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
+ # Kconfig range is [1,63]; 0 gets clamped to the default.
+ add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
_configure_lwip_max_sockets(conf)
@@ -2145,7 +2275,6 @@ async def to_code(config):
for key, flag in ASSERTION_LEVELS.items():
add_idf_sdkconfig_option(flag, assertion_level == key)
- add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -2265,17 +2394,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
@@ -2300,7 +2420,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
- add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
+ # Kconfig range is [1,10]; 0 gets clamped to the default.
+ add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -2535,13 +2656,6 @@ def _write_sdkconfig():
clean_build(clear_pio_cache=False)
-def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
- dependency: dict[str, str] = {}
- name, _version, path = generate_idf_component(library)
- dependency["override_path"] = str(path)
- return name, dependency
-
-
def _write_idf_component_yml():
yml_path = CORE.relative_build_path("src/idf_component.yml")
dependencies: dict[str, dict] = {}
@@ -2582,6 +2696,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)
@@ -2595,13 +2729,21 @@ def _write_idf_component_yml():
)
if CORE.using_toolchain_esp_idf:
- # Try to convert PlatformIO library to ESP-IDF components
- for name, library in CORE.platformio_libraries.items():
+ # Convert the PlatformIO libraries to ESP-IDF components as a batch so
+ # PlatformIO resolves the whole dependency tree at once -- deduplicating
+ # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c
+ # and esp_wireguard) to a single version instead of clashing
+ # override_path entries.
+ libraries = [
+ library
+ for name, library in CORE.platformio_libraries.items()
# Don't process arduino libraries
- if name in ARDUINO_DISABLED_LIBRARIES:
- continue
- dependency_name, dependency = _platformio_library_to_dependency(library)
- dependencies[dependency_name] = dependency
+ if name not in ARDUINO_DISABLED_LIBRARIES
+ ]
+ for component in generate_idf_components(libraries):
+ dependencies[component.get_sanitized_name()] = {
+ "override_path": str(component.path)
+ }
if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
@@ -2682,7 +2824,7 @@ def _decode_pc(config, 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
diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py
index 2c73fe7d08..6062631d98 100644
--- a/esphome/components/esp32/boards.py
+++ b/esphome/components/esp32/boards.py
@@ -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,
diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py
index d0d00723fc..322054ea91 100644
--- a/esphome/components/esp32/const.py
+++ b/esphome/components/esp32/const.py
@@ -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")
diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp
index 5249f4a59e..098a59937a 100644
--- a/esphome/components/esp32/core.cpp
+++ b/esphome/components/esp32/core.cpp
@@ -8,7 +8,9 @@
void setup(); // NOLINT(readability-redundant-declaration)
-// Weak stub for initArduino - overridden when the Arduino component is present
+// Weak stub for initArduino - overridden when the Arduino component is present.
+// Name must match the Arduino framework's entry point, so the naming check is suppressed.
+// NOLINTNEXTLINE(readability-identifier-naming)
extern "C" __attribute__((weak)) void initArduino() {}
namespace esphome {
diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp
index ed61b61936..a7de48a6ee 100644
--- a/esphome/components/esp32/crash_handler.cpp
+++ b/esphome/components/esp32/crash_handler.cpp
@@ -41,6 +41,7 @@ static inline bool is_return_addr(uint32_t addr) {
// Use memcpy for alignment safety — RISC-V C extension means code addresses
// are only 2-byte aligned, so addr-4 may not be 4-byte aligned.
uint32_t inst;
+ // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point
memcpy(&inst, (const void *) (addr - 4), sizeof(inst));
// RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd
uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode
@@ -51,6 +52,7 @@ static inline bool is_return_addr(uint32_t addr) {
// Check for 2-byte compressed c.jalr before this address (C extension).
// c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10
if (addr >= 2) {
+ // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point
uint16_t c_inst = *(uint16_t *) (addr - 2);
if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0)
return true;
@@ -101,6 +103,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
out[count++] = frame->ra;
}
*reg_count = count;
+ // NOLINTNEXTLINE(performance-no-int-to-ptr) - walking the raw stack by address is the point
auto *scan_start = (uint32_t *) frame->sp;
for (uint32_t i = 0; i < 64 && count < max; i++) {
uint32_t val = scan_start[i];
@@ -354,6 +357,8 @@ void crash_handler_log() {
#if SOC_CPU_CORES_NUM > 1
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
+#else
+ (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread.
#endif
ESP_LOGE(TAG, "%s", hint);
}
diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py
index 36dd44155a..2ff39cab69 100644
--- a/esphome/components/esp32/gpio.py
+++ b/esphome/components/esp32/gpio.py
@@ -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,
+ ),
}
diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py
new file mode 100644
index 0000000000..5ab1b7c074
--- /dev/null
+++ b/esphome/components/esp32/gpio_esp32_h21.py
@@ -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
diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py
new file mode 100644
index 0000000000..86a4d55858
--- /dev/null
+++ b/esphome/components/esp32/gpio_esp32_h4.py
@@ -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
diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py
new file mode 100644
index 0000000000..6a19e3fee4
--- /dev/null
+++ b/esphome/components/esp32/gpio_esp32_s31.py
@@ -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
diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script
index af12275a0b..8728e02a34 100644
--- a/esphome/components/esp32/pre_build.py.script
+++ b/esphome/components/esp32/pre_build.py.script
@@ -1,3 +1,5 @@
+import os
+
Import("env") # noqa: F821
# Remove custom_sdkconfig from the board config as it causes
@@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board:
del board._manifest["espidf"]["custom_sdkconfig"]
if not board._manifest["espidf"]:
del board._manifest["espidf"]
+
+# Referenced by rules in esphome/idf_component.yml; an unset env var is a
+# fatal error there. Always 0: in PlatformIO builds arduino is not a managed
+# IDF component.
+os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0")
diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp
index cc519846be..4d364b4655 100644
--- a/esphome/components/esp32_ble_server/ble_characteristic.cpp
+++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp
@@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
(*this->on_read_callback_)(param->read.conn_id);
}
- uint16_t max_offset = 22;
-
+ // Use the client-supplied offset for long reads; short reads always start at 0.
+ // The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
+ // just provide as much data as we have from the requested offset and let the stack
+ // handle framing. The client issues subsequent blob reads with increasing offsets
+ // until it has received the whole value.
+ const uint16_t offset = param->read.is_long ? param->read.offset : 0;
+ esp_gatt_status_t status = ESP_GATT_OK;
esp_gatt_rsp_t response;
- if (param->read.is_long) {
- if (this->value_read_offset_ >= this->value_.size()) {
- response.attr_value.len = 0;
- response.attr_value.offset = this->value_read_offset_;
- this->value_read_offset_ = 0;
- } else if (this->value_.size() - this->value_read_offset_ < max_offset) {
- // Last message in the chain
- response.attr_value.len = this->value_.size() - this->value_read_offset_;
- response.attr_value.offset = this->value_read_offset_;
- memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
- this->value_read_offset_ = 0;
- } else {
- response.attr_value.len = max_offset;
- response.attr_value.offset = this->value_read_offset_;
- memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
- this->value_read_offset_ += max_offset;
- }
+ response.attr_value.offset = offset;
+
+ if (offset > this->value_.size()) {
+ status = ESP_GATT_INVALID_OFFSET;
+ response.attr_value.len = 0;
} else {
- response.attr_value.offset = 0;
- if (this->value_.size() + 1 > max_offset) {
- response.attr_value.len = max_offset;
- this->value_read_offset_ = max_offset;
- } else {
- response.attr_value.len = this->value_.size();
+ size_t remaining = this->value_.size() - offset;
+ if (remaining > ESP_GATT_MAX_ATTR_LEN) {
+ ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
+ static_cast(remaining), ESP_GATT_MAX_ATTR_LEN);
+ remaining = ESP_GATT_MAX_ATTR_LEN;
}
- memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
+ response.attr_value.len = remaining;
+ memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
}
response.attr_value.handle = this->handle_;
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
esp_err_t err =
- esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
+ esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
}
diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h
index 94c7495cbd..933177a399 100644
--- a/esphome/components/esp32_ble_server/ble_characteristic.h
+++ b/esphome/components/esp32_ble_server/ble_characteristic.h
@@ -79,7 +79,6 @@ class BLECharacteristic {
esp_gatt_char_prop_t properties_;
uint16_t handle_{0xFFFF};
- uint16_t value_read_offset_{0};
std::vector value_;
std::vector descriptors_;
diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py
index 9883a0a43e..c3b35a8279 100644
--- a/esphome/components/esp32_camera/__init__.py
+++ b/esphome/components/esp32_camera/__init__.py
@@ -399,7 +399,7 @@ async def to_code(config):
if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG":
cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION")
- add_idf_component(name="espressif/esp32-camera", ref="2.1.5")
+ add_idf_component(name="espressif/esp32-camera", ref="2.1.7")
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True)
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False)
diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py
index 71d1fd3ac1..7f420f27d8 100644
--- a/esphome/components/esp32_hosted/__init__.py
+++ b/esphome/components/esp32_hosted/__init__.py
@@ -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.9")
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")
diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py
index b258a26b08..8e85cce75a 100644
--- a/esphome/components/esp32_hosted/update/__init__.py
+++ b/esphome/components/esp32_hosted/update/__init__.py
@@ -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)
diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
index 70fa41b312..351b0869b0 100644
--- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
+++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp
@@ -56,7 +56,10 @@ static bool parse_version(const std::string &version_str, int &major, int &minor
major = minor = patch = 0;
const char *ptr = version_str.c_str();
- if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor))
+ if (!parse_int(ptr, major) || *ptr != '.')
+ return false;
+ ++ptr;
+ if (!parse_int(ptr, minor))
return false;
if (*ptr == '.')
parse_int(++ptr, patch);
diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp
index 183820256f..e6fcc018d9 100644
--- a/esphome/components/esp32_improv/esp32_improv_component.cpp
+++ b/esphome/components/esp32_improv/esp32_improv_component.cpp
@@ -338,6 +338,14 @@ void ESP32ImprovComponent::process_incoming_data_() {
this->incoming_data_.clear();
return;
}
+ if (wifi::global_wifi_component->is_disabled()) {
+ // Wi-Fi is disabled, so we can't provision. Respond immediately
+ // instead of letting the client wait out its provisioning timeout.
+ ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision");
+ this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
+ this->incoming_data_.clear();
+ return;
+ }
wifi::WiFiAP sta{};
sta.set_ssid(command.ssid.c_str());
sta.set_password(command.password.c_str());
diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py
index 38df282fb9..db94f0ec6d 100644
--- a/esphome/components/esp8266/__init__.py
+++ b/esphome/components/esp8266/__init__.py
@@ -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
@@ -492,6 +492,15 @@ def _parse_register(config, regex, line):
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
+# Structured crash handler output (crash_handler.cpp) from a previous boot:
+# PC: 0x40220060
+# EXCVADDR: 0x0000008A
+# BT0: 0x40212345
+STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
+STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile(
+ r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})"
+)
+STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})")
STACKTRACE_BAD_ALLOC_RE = re.compile(
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
)
@@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state):
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
)
- # ESP8266 PC/EXCVADDR
+ # ESP8266 PC/EXCVADDR (legacy Arduino postmortem)
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
+ # ESP8266 structured crash handler (crash_handler.cpp) from previous boot
+ _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line)
+ _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line)
+ match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line)
+ if match is not None:
+ _decode_pc(config, match.group(1))
+
# bad alloc
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
if match is not None:
diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h
index effa9c9371..f3b33da692 100644
--- a/esphome/components/esp8266/hal.h
+++ b/esphome/components/esp8266/hal.h
@@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT
}
+// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
+// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
+// ESP8266 is ~4x as many flash accesses as the bulk path.
+__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
+ memcpy_P(dst, src, len); // NOLINT
+}
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py
index f7793b1493..66a33e1935 100644
--- a/esphome/components/esphome/ota/__init__.py
+++ b/esphome/components/esphome/ota/__init__.py
@@ -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"
),
diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py
index 7861c0affa..13f278d3bc 100644
--- a/esphome/components/espnow/__init__.py
+++ b/esphome/components/espnow/__init__.py
@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
-
+AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp
index 91d44394e8..403e6f4944 100644
--- a/esphome/components/espnow/espnow_component.cpp
+++ b/esphome/components/espnow/espnow_component.cpp
@@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
-#ifndef USE_WIFI
- // Initialize LwIP stack for wake_loop_threadsafe() socket support
- // When WiFi component is present, it handles esp_netif_init()
- ESP_ERROR_CHECK(esp_netif_init());
-#endif
-
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -174,8 +168,6 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
- esp_event_loop_create_default();
-
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py
index 3f88f8ef9a..784f5dee8c 100644
--- a/esphome/components/ethernet/__init__.py
+++ b/esphome/components/ethernet/__init__.py
@@ -2,6 +2,7 @@ from dataclasses import dataclass
import logging
from esphome import automation, pins
+from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.network import ip_address_literal
from esphome.config_helpers import filter_source_files_from_platform
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
+ CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -163,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"),
}
@@ -217,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:
@@ -348,6 +354,7 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
+ cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
}
@@ -494,6 +501,9 @@ async def to_code(config):
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
+ # enable_on_boot defaults to true in C++ - only set if false
+ if not config[CONF_ENABLE_ON_BOOT]:
+ cg.add(var.set_enable_on_boot(False))
CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE]
if CONF_MANUAL_IP in config:
@@ -715,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
+ )
diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h
new file mode 100644
index 0000000000..c16abc5bda
--- /dev/null
+++ b/esphome/components/ethernet/automation.h
@@ -0,0 +1,30 @@
+#pragma once
+
+#include "esphome/core/defines.h"
+#ifdef USE_ETHERNET
+#include "ethernet_component.h"
+
+namespace esphome::ethernet {
+
+template class EthernetConnectedCondition : public Condition {
+ public:
+ bool check(const Ts &...x) override { return global_eth_component->is_connected(); }
+};
+
+template class EthernetEnabledCondition : public Condition {
+ public:
+ bool check(const Ts &...x) override { return global_eth_component->is_enabled(); }
+};
+
+template class EthernetEnableAction : public Action {
+ public:
+ void play(const Ts &...x) override { global_eth_component->enable(); }
+};
+
+template class EthernetDisableAction : public Action {
+ public:
+ void play(const Ts &...x) override { global_eth_component->disable(); }
+};
+
+} // namespace esphome::ethernet
+#endif
diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h
index 17c84ee954..7d06377f90 100644
--- a/esphome/components/ethernet/ethernet_component.h
+++ b/esphome/components/ethernet/ethernet_component.h
@@ -124,6 +124,17 @@ class EthernetComponent final : public Component {
void on_powerdown() override { powerdown(); }
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
+ // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled).
+ // enable_on_boot defaults to true; when false, setup() runs all the driver/netif
+ // installation but skips esp_eth_start(), keeping the link cold until enable() is
+ // called. This is the primary lever for memory reclamation in multi-interface
+ // configurations where only one interface should carry traffic at a time.
+ void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
+ void enable();
+ void disable();
+ bool is_disabled() { return this->disabled_; }
+ bool is_enabled() { return !this->disabled_; }
+
void set_type(EthernetType type);
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
@@ -194,6 +205,16 @@ class EthernetComponent final : public Component {
void finish_connect_();
void dump_connect_params_();
+#ifdef USE_ESP32
+ // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver
+ // install, netif attach, and event handler registration (which together allocate
+ // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue)
+ // until ethernet actually needs to come up. Idempotent — guarded by the
+ // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or
+ // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi.
+ void ethernet_lazy_init_();
+#endif
+
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
void notify_ip_state_listeners_();
#endif
@@ -287,6 +308,17 @@ class EthernetComponent final : public Component {
bool started_{false};
bool connected_{false};
bool got_ipv4_address_{false};
+ // Codegen-time YAML option. When false, setup() defers esp_eth_start().
+ bool enable_on_boot_{true};
+ // Mirror of "is the link intentionally stopped" — set when setup() honors
+ // enable_on_boot=false, cleared by enable(), set again by disable().
+ bool disabled_{false};
+#ifdef USE_ESP32
+ // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable()
+ // to be called at runtime after enable_on_boot:false without re-allocating, and
+ // ensures setup() skips the heavy init when enable_on_boot_ is false.
+ bool ethernet_initialized_{false};
+#endif
#if LWIP_IPV6
uint8_t ipv6_count_{0};
bool ipv6_setup_done_{false};
diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp
index d4585bf100..544ec79c32 100644
--- a/esphome/components/ethernet/ethernet_component_esp32.cpp
+++ b/esphome/components/ethernet/ethernet_component_esp32.cpp
@@ -5,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
+#include "w5500_custom_spi.h"
#include
#include
@@ -137,6 +138,24 @@ void EthernetComponent::setup() {
delay(300); // NOLINT
}
+ if (this->enable_on_boot_) {
+ this->ethernet_lazy_init_();
+ if (!this->ethernet_initialized_) {
+ // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do.
+ return;
+ }
+ esp_err_t err = esp_eth_start(this->eth_handle_);
+ ESPHL_ERROR_CHECK(err, "ETH start error");
+ } else {
+ ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)");
+ this->disabled_ = true;
+ }
+}
+
+void EthernetComponent::ethernet_lazy_init_() {
+ if (this->ethernet_initialized_)
+ return;
+
esp_err_t err;
#ifdef USE_ETHERNET_SPI
@@ -163,11 +182,7 @@ void EthernetComponent::setup() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
-
- err = esp_netif_init();
- ESPHL_ERROR_CHECK(err, "ETH netif init error");
- err = esp_event_loop_create_default();
- ESPHL_ERROR_CHECK(err, "ETH event loop error");
+ // Network interface setup handled by network component
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -207,6 +222,10 @@ void EthernetComponent::setup() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
+ // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
+ // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
+ // runs the driver's init().
+ install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -370,9 +389,41 @@ void EthernetComponent::setup() {
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
#endif /* USE_NETWORK_IPV6 */
- /* start Ethernet driver state machine */
- err = esp_eth_start(this->eth_handle_);
- ESPHL_ERROR_CHECK(err, "ETH start error");
+ this->ethernet_initialized_ = true;
+}
+
+void EthernetComponent::enable() {
+ if (!this->disabled_)
+ return;
+
+ ESP_LOGD(TAG, "Enabling");
+ this->ethernet_lazy_init_();
+ if (!this->ethernet_initialized_) {
+ ESP_LOGE(TAG, "Cannot enable - init failed");
+ return;
+ }
+ esp_err_t err = esp_eth_start(this->eth_handle_);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err));
+ return;
+ }
+ this->disabled_ = false;
+ // The ETH_EVENT_START handler will set started_=true; the loop state machine
+ // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions.
+ this->enable_loop();
+}
+
+void EthernetComponent::disable() {
+ if (this->disabled_)
+ return;
+
+ ESP_LOGD(TAG, "Disabling");
+ esp_err_t err = esp_eth_stop(this->eth_handle_);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err));
+ }
+ this->disabled_ = true;
+ // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED.
}
void EthernetComponent::dump_config() {
@@ -486,6 +537,8 @@ void EthernetComponent::dump_config() {
network::IPAddresses EthernetComponent::get_ip_addresses() {
network::IPAddresses addresses;
+ if (!this->ethernet_initialized_)
+ return addresses; // all-zero IPs
esp_netif_ip_info_t ip;
esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip);
if (err != ESP_OK) {
@@ -708,6 +761,10 @@ void EthernetComponent::start_connect_() {
}
void EthernetComponent::dump_connect_params_() {
+ if (!this->ethernet_initialized_) {
+ ESP_LOGCONFIG(TAG, " uninitialized/disabled");
+ return;
+ }
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip);
const ip_addr_t *dns_ip1;
@@ -775,6 +832,16 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
#endif
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
+ if (!this->ethernet_initialized_) {
+ // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless
+ // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC.
+ if (this->fixed_mac_.has_value()) {
+ memcpy(mac, this->fixed_mac_->data(), 6);
+ } else {
+ esp_read_mac(mac, ESP_MAC_ETH);
+ }
+ return;
+ }
esp_err_t err;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac);
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
@@ -794,6 +861,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
}
eth_duplex_t EthernetComponent::get_duplex_mode() {
+ if (!this->ethernet_initialized_)
+ return ETH_DUPLEX_HALF;
esp_err_t err;
eth_duplex_t duplex_mode;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode);
@@ -802,6 +871,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() {
}
eth_speed_t EthernetComponent::get_link_speed() {
+ if (!this->ethernet_initialized_)
+ return ETH_SPEED_10M;
esp_err_t err;
eth_speed_t speed;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed);
diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp
index ef7bd46332..250297ddb5 100644
--- a/esphome/components/ethernet/ethernet_component_rp2040.cpp
+++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp
@@ -361,6 +361,23 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
+void EthernetComponent::enable() {
+ // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
+ // there is no clean enable/disable hook today. The YAML option is accepted on
+ // RP2040 for schema parity but has no effect.
+ if (!this->disabled_)
+ return;
+ ESP_LOGW(TAG, "enable_on_boot/disable not supported");
+ this->disabled_ = false;
+}
+
+void EthernetComponent::disable() {
+ if (this->disabled_)
+ return;
+ ESP_LOGW(TAG, "enable_on_boot/disable not supported");
+ this->disabled_ = true;
+}
+
} // namespace esphome::ethernet
#endif // USE_ETHERNET && USE_RP2040
diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp
new file mode 100644
index 0000000000..ed4f149738
--- /dev/null
+++ b/esphome/components/ethernet/w5500_custom_spi.cpp
@@ -0,0 +1,118 @@
+#include "w5500_custom_spi.h"
+
+#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
+
+#include
+#include
+#include
+#include
+#include
+
+namespace esphome::ethernet {
+
+namespace {
+
+// Per-device context returned by init() and handed back to read/write/deinit.
+struct W5500CustomSpiContext {
+ spi_device_handle_t handle;
+ SemaphoreHandle_t lock;
+};
+
+// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
+// transfers (the frame payloads) use the blocking, DMA-backed transmit.
+constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
+constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
+
+void *w5500_custom_spi_init(const void *spi_config) {
+ const auto *config = static_cast(spi_config);
+ auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
+ if (ctx == nullptr) {
+ return nullptr;
+ }
+ // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
+ // byte in the address phase; mirror what the stock driver configures.
+ spi_device_interface_config_t devcfg = *config->spi_devcfg;
+ devcfg.command_bits = 16;
+ devcfg.address_bits = 8;
+ if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
+ delete ctx;
+ return nullptr;
+ }
+ ctx->lock = xSemaphoreCreateMutex();
+ if (ctx->lock == nullptr) {
+ spi_bus_remove_device(ctx->handle);
+ delete ctx;
+ return nullptr;
+ }
+ return ctx;
+}
+
+esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
+ auto *ctx = static_cast(spi_ctx);
+ spi_bus_remove_device(ctx->handle);
+ vSemaphoreDelete(ctx->lock);
+ delete ctx;
+ return ESP_OK;
+}
+
+// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
+// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
+// accesses stay on the cheaper polling path. Used by both read and write.
+esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
+ if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
+ return ESP_ERR_TIMEOUT;
+ }
+ esp_err_t ret;
+ if (len > W5500_SPI_BULK_THRESHOLD) {
+ ret = spi_device_transmit(ctx->handle, trans);
+ } else {
+ ret = spi_device_polling_transmit(ctx->handle, trans);
+ }
+ xSemaphoreGive(ctx->lock);
+ return ret;
+}
+
+esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
+ auto *ctx = static_cast(spi_ctx);
+ spi_transaction_t trans = {};
+ trans.cmd = static_cast(cmd);
+ trans.addr = addr;
+ trans.length = 8 * len;
+ trans.tx_buffer = data;
+ return w5500_custom_spi_transfer(ctx, &trans, len);
+}
+
+esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
+ auto *ctx = static_cast(spi_ctx);
+ spi_transaction_t trans = {};
+ // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
+ // overwrites of adjacent registers (same guard the stock driver uses).
+ const bool use_rxdata = len <= 4;
+ trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
+ trans.cmd = static_cast(cmd);
+ trans.addr = addr;
+ trans.length = 8 * len;
+ trans.rx_buffer = data;
+ esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
+ if (use_rxdata && (ret == ESP_OK)) {
+ memcpy(data, trans.rx_data, len);
+ }
+ return ret;
+}
+
+} // namespace
+
+void install_w5500_async_spi(eth_w5500_config_t &config) {
+ // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
+ // spi_devcfg back out of it. The self-reference is valid because both the config and the
+ // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
+ config.custom_spi_driver.config = &config;
+ config.custom_spi_driver.init = w5500_custom_spi_init;
+ config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
+ config.custom_spi_driver.read = w5500_custom_spi_read;
+ config.custom_spi_driver.write = w5500_custom_spi_write;
+}
+
+} // namespace esphome::ethernet
+
+#endif // USE_ESP32 && USE_ETHERNET_W5500
diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h
new file mode 100644
index 0000000000..8756a149af
--- /dev/null
+++ b/esphome/components/ethernet/w5500_custom_spi.h
@@ -0,0 +1,35 @@
+#pragma once
+
+#include "esphome/core/defines.h"
+
+#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
+
+#include
+// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
+// is no longer reachable through esp_eth.h and needs the explicit header.
+#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
+#include
+#else
+#include
+#endif
+
+namespace esphome::ethernet {
+
+// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
+//
+// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
+// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
+// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
+// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
+// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
+// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
+// is cheaper than an interrupt round-trip.
+//
+// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
+// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
+// alive until esp_eth_mac_new_w5500() returns.
+void install_w5500_async_spi(eth_w5500_config_t &config);
+
+} // namespace esphome::ethernet
+
+#endif // USE_ESP32 && USE_ETHERNET_W5500
diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py
index 6eb577e5ad..c892ec1112 100644
--- a/esphome/components/external_components/__init__.py
+++ b/esphome/components/external_components/__init__.py
@@ -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")))
diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py
index c944e8a930..d99dffdc08 100644
--- a/esphome/components/fastled_base/__init__.py
+++ b/esphome/components/fastled_base/__init__.py
@@ -41,10 +41,16 @@ async def new_fastled_light(config):
if CONF_MAX_REFRESH_RATE in config:
cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE]))
- cg.add_library("fastled/FastLED", "3.9.16")
if CORE.is_esp32:
- from esphome.components.esp32 import include_builtin_idf_component
+ from esphome.components.esp32 import add_idf_component
- include_builtin_idf_component("esp_lcd")
+ add_idf_component(
+ name="fastled/FastLED",
+ repo="https://github.com/FastLED/FastLED.git",
+ ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
+ )
+ cg.add_library("SPI", None)
+ else:
+ cg.add_library("fastled/FastLED", "3.9.16")
await light.register_light(var, config)
return var
diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h
index 8e87f67e6d..f8535eb628 100644
--- a/esphome/components/fastled_base/fastled_light.h
+++ b/esphome/components/fastled_base/fastled_light.h
@@ -143,7 +143,6 @@ class FastLEDLightOutput : public light::AddressableLight {
}
}
-#ifdef FASTLED_HAS_CLOCKLESS
template class CHIPSET, uint8_t DATA_PIN, EOrder RGB_ORDER>
CLEDController &add_leds(int num_leds) {
static CHIPSET controller;
@@ -160,7 +159,6 @@ class FastLEDLightOutput : public light::AddressableLight {
static CHIPSET controller;
return add_leds(&controller, num_leds);
}
-#endif
template class CHIPSET, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) {
static CHIPSET controller;
diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py
index a10c45a9d7..7510f2f8b6 100644
--- a/esphome/components/font/__init__.py
+++ b/esphome/components/font/__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,
)
]
diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py
index 390b26ba1d..f14a920c24 100644
--- a/esphome/components/gpio/binary_sensor/__init__.py
+++ b/esphome/components/gpio/binary_sensor/__init__.py
@@ -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(
diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp
index 705c741dd0..a794e7721f 100644
--- a/esphome/components/gree/gree.cpp
+++ b/esphome/components/gree/gree.cpp
@@ -5,7 +5,23 @@ namespace esphome::gree {
static const char *const TAG = "gree.climate";
+climate::ClimateTraits GreeClimate::traits() {
+ auto t = climate_ir::ClimateIR::traits();
+ // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported.
+ if (!this->supports_heat_) {
+ auto modes = t.get_supported_modes();
+ modes.erase(climate::CLIMATE_MODE_HEAT_COOL);
+ t.set_supported_modes(modes);
+ }
+ return t;
+}
+
void GreeClimate::set_model(Model model) {
+ if (model == GREE_YAN) {
+ // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model.
+ this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
+ this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
+ }
if (model == GREE_YX1FF) {
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed
this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode
diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h
index 24453750ae..1eb812ae46 100644
--- a/esphome/components/gree/gree.h
+++ b/esphome/components/gree/gree.h
@@ -94,6 +94,7 @@ class GreeClimate : public climate_ir::ClimateIR {
protected:
// Transmit via IR the state of this climate controller.
void transmit_state() override;
+ climate::ClimateTraits traits() override;
uint8_t operation_mode_();
uint8_t fan_speed_();
diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp
index 41beb6e4e9..fc35271017 100644
--- a/esphome/components/growatt_solar/growatt_solar.cpp
+++ b/esphome/components/growatt_solar/growatt_solar.cpp
@@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector &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;
}
}
diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h
index 7eba795601..27ae32cc46 100644
--- a/esphome/components/growatt_solar/growatt_solar.h
+++ b/esphome/components/growatt_solar/growatt_solar.h
@@ -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;
diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py
index aa3a08c294..cd1b7d2bb0 100644
--- a/esphome/components/heatpumpir/climate.py
+++ b/esphome/components/heatpumpir/climate.py
@@ -126,6 +126,6 @@ async def to_code(config):
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
- cg.add_library("tonia/HeatpumpIR", "1.0.41")
+ cg.add_library("tonia/HeatpumpIR", "1.0.42")
if CORE.is_libretiny or CORE.is_esp32:
CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"])
diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp
index 8e9a2c5298..502f83cd5d 100644
--- a/esphome/components/heatpumpir/heatpumpir.cpp
+++ b/esphome/components/heatpumpir/heatpumpir.cpp
@@ -2,6 +2,7 @@
#if defined(USE_ARDUINO) || defined(USE_ESP32)
+#include
#include