Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy

This commit is contained in:
kbx81
2026-08-03 16:32:34 -05:00
295 changed files with 19994 additions and 7736 deletions
+4 -2
View File
@@ -3,8 +3,10 @@ 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 component test batches) shares
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", so all toolchains are present regardless of the chip).
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
skips per-variant narrowing whenever CI is set, so all toolchains are
present regardless of the chip a job builds.
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
Python venv already restored.
inputs:
+1 -1
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
@@ -13,6 +13,7 @@ module.exports = {
'merging-to-release',
'merging-to-beta',
'chained-pr',
'stacked-pr',
'core',
'small-pr',
'medium-pr',
+38 -1
View File
@@ -33,8 +33,41 @@ async function fetchPrFileContent(github, context, path) {
}
}
// Check whether a pull request is part of a GitHub stack.
//
// GitHub's stacked pull request feature adds a `stack` object to the pull
// request resource. It is present on every pull request in the stack -
// including the bottom one, whose base is already `dev` - and is absent
// entirely on standalone pull requests.
//
// The `pull_request_target` webhook payload is not guaranteed to carry this
// field, so fall back to asking the API when it is missing. Guessing wrong
// here is costly: a stacked pull request mistaken for a manually chained one
// gets a label that blocks merging.
async function isStackedPr(github, context) {
const pr = context.payload.pull_request;
if (pr.stack != null) {
return true;
}
try {
const { owner, repo } = context.repo;
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
return data.stack != null;
} catch (error) {
// Treat an API failure as "not stacked" so a chained pull request still
// gets its blocking label rather than silently slipping through.
console.log('Failed to check stack membership:', error.message);
return false;
}
}
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
@@ -42,7 +75,11 @@ async function detectMergeBranch(context) {
labels.add('merging-to-release');
} else if (baseRef === 'beta') {
labels.add('merging-to-beta');
} else if (await isStackedPr(github, context)) {
// GitHub manages the merge order for a stack, so these are not blocked.
labels.add('stacked-pr');
} else if (baseRef !== 'dev') {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
+2 -2
View File
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
// Early exit for release and beta branches only
if (baseRef === 'release' || baseRef === 'beta') {
const branchLabels = await detectMergeBranch(context);
const branchLabels = await detectMergeBranch(github, context);
const finalLabels = Array.from(branchLabels);
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectMergeBranch(github, context),
detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData),
@@ -1,6 +1,7 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
detectMergeBranch,
detectNewPlatforms,
detectNewComponents,
detectPRSize,
@@ -36,6 +37,107 @@ const API_DATA = {
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// ---------------------------------------------------------------------------
// detectMergeBranch
// ---------------------------------------------------------------------------
// Builds a fresh context for detectMergeBranch tests instead of mutating the
// shared CONTEXT fixture above (which other describe blocks rely on).
function makeMergeContext(baseRef, { stack } = {}) {
const pull_request = { number: 1, base: { ref: baseRef } };
if (stack !== undefined) {
pull_request.stack = stack;
}
return {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request }
};
}
// A GitHub API mock exposing only rest.pulls.get, with a call counter so
// tests can assert whether the API fallback was actually invoked.
function makeStackGithub({ stack = null, error = null } = {}) {
const state = { calls: 0 };
const github = {
rest: {
pulls: {
get: async () => {
state.calls++;
if (error) throw error;
return { data: { stack } };
}
}
}
};
return { github, state };
}
const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
describe('detectMergeBranch', () => {
it('base ref release adds merging-to-release only and never checks the stack', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('release', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
assert.equal(state.calls, 0);
});
it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('beta', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
assert.equal(state.calls, 0);
});
it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
const { github, state } = makeStackGithub();
const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 0);
});
it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 1);
});
it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
const { github, state } = makeStackGithub();
const context = makeMergeContext('dev', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 0);
});
it('not stacked, base ref not dev adds chained-pr', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
});
it('not stacked, base ref dev adds no labels', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('dev');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), []);
});
it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
assert.equal(state.calls, 1);
});
});
// ---------------------------------------------------------------------------
// detectNewPlatforms
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+5 -3
View File
@@ -71,11 +71,13 @@ jobs:
- name: Determine tag and whether to push
id: tag
env:
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
# 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 }}"
branch="$HEAD_REF"
tag="${branch//[^a-zA-Z0-9_.-]/-}"
case "$tag" in
[a-zA-Z0-9_]*) ;;
@@ -96,7 +98,7 @@ jobs:
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -154,7 +156,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+327 -264
View File
@@ -68,199 +68,6 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
uv pip install -e .
pylint:
name: Check pylint
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
ci-custom:
name: Run script/ci-custom
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- name: Run script/ci-custom
run: |
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
script/ci_check_duplicate_test_ids.py
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Check import time against budget and write waterfall HAR
run: |
. venv/bin/activate
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# 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
# overlay the PR's esphome so the downstream tests run
# against this PR's Python code. ``--system`` installs into
# the runner's Python instead of a venv.
run: |
uv pip install --system -e './device-builder[esphome,test]'
uv pip install --system -e ./esphome
- 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. ``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=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -350,6 +157,204 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
ci-custom:
name: Run script/ci-custom
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- name: Run script/ci-custom
run: |
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
script/ci_check_duplicate_test_ids.py
script/ci_check_test_fixture_list_form.py
pylint:
name: Check pylint
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
codecov-empty-upload:
name: Report no coverage to Codecov
runs-on: ubuntu-24.04
needs:
- determine-jobs
# ``pytest`` is the only job that uploads coverage, and it is skipped when
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
# ``script/determine-jobs.py``). With no upload Codecov never reports a
# result, so the required ``codecov/patch`` status stays pending forever and
# the pull request can never be merged. Tell Codecov up front that this
# commit has nothing to cover so it publishes a passing status instead.
#
# ``force`` skips Codecov's own check that every changed file is ignorable;
# ``determine-jobs`` has already decided none of these files can affect
# coverage, and Codecov would otherwise fail the status for paths it does
# not recognise as non-testable (``docker/**``, ``.yamllint``).
if: needs.determine-jobs.outputs.core-ci == 'false'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Report empty upload to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
run_command: empty-upload
force: true
fail_ci_if_error: true
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
@@ -364,6 +369,24 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -408,33 +431,46 @@ jobs:
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
- name: Print ccache statistics
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
cpp-unit-tests:
name: Run C++ unit tests
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-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 != '[]')
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
- name: Check import time against budget and write waterfall HAR
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
benchmarks:
name: Run CodSpeed benchmarks
@@ -465,7 +501,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1
with:
run: |
. venv/bin/activate
@@ -473,6 +509,33 @@ jobs:
pytest tests/benchmarks/python/ --codspeed --no-cov
mode: simulation
cpp-unit-tests:
name: Run C++ unit tests
runs-on: ubuntu-24.04
needs:
- common
- determine-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -1037,69 +1100,62 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out code from GitHub
- name: Check out esphome (this PR)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# 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
# overlay the PR's esphome so the downstream tests run
# against this PR's Python code. ``--system`` installs into
# the runner's Python instead of a venv.
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
uv pip install --system -e './device-builder[esphome,test]'
uv pip install --system -e ./esphome
- 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. ``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=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
memory-impact-target-branch:
name: Build target branch for memory impact
@@ -1400,21 +1456,28 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-24.04
# Listed in the same order the jobs are defined above. Two jobs are
# deliberately left out: "benchmarks" reports through CodSpeed rather than
# this check, and "pre-commit-seed-cache" only populates a cache on pushes
# to dev.
needs:
- common
- determine-jobs
- ci-custom
- pylint
- pre-commit-ci-lite
- pytest
- codecov-empty-upload
- integration-tests
- import-time
- cpp-unit-tests
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- clang-tidy-esp32-variants
- determine-jobs
- device-builder
- test-build-components-split
- test-esp32-platformio
- pre-commit-ci-lite
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
- memory-impact-comment
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:${{matrix.language}}"
+5 -5
View File
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -102,12 +102,12 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to docker hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -182,13 +182,13 @@ jobs:
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+35 -50
View File
@@ -6,61 +6,46 @@ on:
- cron: "30 0 * * *"
workflow_dispatch:
permissions:
issues: write # actions/stale labels, comments on, and closes stale issues
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
concurrency:
group: lock
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
# needs no permissions at all.
permissions: {}
jobs:
stale:
if: github.repository_owner == 'esphome'
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
operations-per-run: 400
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-close: 7
stale-label: stale
exempt-label: not-stale
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
# The 90 day stale policy for PRs
# - PRs
# - No PRs marked as "not-stale"
# - No Issues (see below)
days-before-pr-stale: 90
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "not-stale"
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Thank you for your contribution!
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
# The 90 day stale policy for Issues
# - Issues
# - No Issues marked as "not-stale"
# - No PRs (see above)
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "not-stale"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
+2 -2
View File
@@ -96,8 +96,8 @@ jobs:
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>
author: esphomebot <esphome@openhomefoundation.org>
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
branch: sync/device-classes
delete-branch: true
title: "Synchronise Device Classes from Home Assistant"
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.15
rev: v0.16.0
hooks:
# Run the linter.
- id: ruff
+60 -19
View File
@@ -191,11 +191,14 @@ This document provides essential context for AI models interacting with this pro
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -229,7 +232,12 @@ This document provides essential context for AI models interacting with this pro
- **Sensor:**
```python
from esphome.components import sensor
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
cv.polling_component_schema("60s")
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
@@ -238,7 +246,10 @@ This document provides essential context for AI models interacting with this pro
- **Binary Sensor:**
```python
from esphome.components import binary_sensor
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
```
@@ -246,7 +257,10 @@ This document provides essential context for AI models interacting with this pro
- **Switch:**
```python
from esphome.components import switch
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
CONFIG_SCHEMA = switch.switch_schema().extend({...})
async def to_code(config):
var = await switch.new_switch(config)
```
@@ -263,10 +277,13 @@ This document provides essential context for AI models interacting with this pro
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -316,11 +333,14 @@ This document provides essential context for AI models interacting with this pro
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
}
)
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
@@ -368,7 +388,10 @@ This document provides essential context for AI models interacting with this pro
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
* **Configuration Validation:**
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
@@ -381,6 +404,7 @@ This document provides essential context for AI models interacting with this pro
.extend(i2c.i2c_device_schema(0x48))
.extend(spi.spi_device_schema(cs_pin_required=True))
```
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
## 5. Key Files & Entrypoints
@@ -471,7 +495,7 @@ This document provides essential context for AI models interacting with this pro
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
4. **Lint:** Run `pre-commit` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
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.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
@@ -617,6 +641,7 @@ This document provides essential context for AI models interacting with this pro
_component_state = []
_use_feature = None
def enable_feature():
global _use_feature
_use_feature = True
@@ -636,20 +661,24 @@ This document provides essential context for AI models interacting with this pro
DOMAIN = "my_component"
@dataclass
class MyComponentData:
feature_enabled: bool = False
item_count: int = 0
items: list[str] = field(default_factory=list)
def _get_data() -> MyComponentData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = MyComponentData()
return CORE.data[DOMAIN]
def request_feature() -> None:
_get_data().feature_enabled = True
def add_item(item: str) -> None:
_get_data().items.append(item)
```
@@ -704,10 +733,22 @@ This document provides essential context for AI models interacting with this pro
```
* **Deprecation Pattern (Python):**
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
```python
CONFIG_SCHEMA = cv.All(
cv.rename_key(
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
),
cv.Schema({ ... }),
)
```
For other deprecations, warn manually during validation:
```python
# Remove before 2026.6.0
if CONF_OLD_KEY in config:
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
_LOGGER.warning(
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
)
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
## 9. English Language
+4
View File
@@ -69,6 +69,8 @@ esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
esphome/components/bk72xx/* @kuba2k2
esphome/components/bk72xx_ble/* @Bl00d-B0b
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
@@ -293,6 +295,7 @@ esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
@@ -625,6 +628,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zephyr_pwm/* @wiomoc
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.1
RUN \
platformio settings set enable_telemetry No \
+12
View File
@@ -210,15 +210,27 @@ def get_component_cmakelists() -> str:
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
+16 -6
View File
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
zephyr_add_overlay_builder,
zephyr_add_prj_conf,
zephyr_add_user,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id"
def _overlay_io_channels():
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
return f"""
/ {{
zephyr,user {{
io-channels = {entries};
}};
}};
"""
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -173,9 +185,8 @@ async def to_code(config):
if isinstance(pin_number, int):
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
pin_number = GPIO_TO_AIN[pin_number]
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
zephyr_add_overlay(
f"""
zephyr_add_overlay_builder(_overlay_io_channels)
zephyr_add_overlay(f"""
&adc {{
#address-cells = <1>;
#size-cells = <0>;
@@ -190,8 +201,7 @@ async def to_code(config):
zephyr,oversampling = <8>;
}};
}};
"""
)
""")
FILTER_SOURCE_FILES = filter_source_files_from_platform(
+8 -21
View File
@@ -802,6 +802,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
// Current feature flags and other supported parameters
msg.feature_flags = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
msg.supported_modes = &traits.get_supported_modes();
msg.visual_min_temperature = traits.get_visual_min_temperature();
msg.visual_max_temperature = traits.get_visual_max_temperature();
@@ -1336,7 +1337,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
}
}
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
bool APIConnection::send_voice_assistant_get_configuration_response_(
const VoiceAssistantConfigurationRequest & /*msg*/) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
@@ -1356,22 +1358,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
}
}
// Filter external wake words
for (auto &wake_word : msg.external_wake_words) {
if (wake_word.model_type != "micro") {
// microWakeWord only
continue;
}
resp.available_wake_words.emplace_back();
auto &resp_wake_word = resp.available_wake_words.back();
resp_wake_word.id = StringRef(wake_word.id);
resp_wake_word.wake_word = StringRef(wake_word.wake_word);
for (const auto &lang : wake_word.trained_languages) {
resp_wake_word.trained_languages.push_back(lang);
}
}
resp.active_wake_words = &config.active_wake_words;
resp.max_active_wake_words = config.max_active_wake_words;
return this->send_message(resp);
@@ -1486,6 +1472,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
msg.target_temperature_step = traits.get_target_temperature_step();
msg.supported_modes = &traits.get_supported_modes();
msg.supported_features = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
}
@@ -1574,8 +1561,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
static_cast<uint32_t>(proxies.size()));
return;
}
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits,
msg.data_size);
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1584,7 +1571,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
}
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
@@ -1593,7 +1580,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->set_modem_pins(msg.line_states);
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
}
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
+19 -2
View File
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent;
}
// Entry fully sent — free it and advance
Entry::destroy(front);
// Entry fully sent — unlink it before freeing so a freed pointer is never
// reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
Entry::destroy(front);
}
return 0; // All drained
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
};
} // namespace esphome::api
+94
View File
@@ -0,0 +1,94 @@
"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips.
The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
prebuilt libble_<chip>.a per SoC). This component only calls into it via the
public ble_api.h.
"""
import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
CODEOWNERS = ["@Bl00d-B0b"]
_LOGGER = logging.getLogger(__name__)
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BK72xxBLE),
# Default off: on the single-core BK72xx, bringing the BLE stack up during
# boot competes with the WiFi connection handshake. Consumers enable the
# stack lazily on first use (e.g. the tracker's first scan start).
cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
# Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value
# is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0
# on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this
# SAME key — and add_platformio_option appends list values, it never replaces.
# The platform therefore skips its disable when this component is configured,
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
# bundled 3.0.33 has an older BLE header/library layout — and with
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
# (the reason the libretiny platform sets =0 when BLE is unused), so a
# mismatched BDK can crash the device before WiFi comes up regardless of
# enable_on_boot. Pinning here makes a plain config build against the
# validated BDK without any manual platformio_options.
_LOGGER.warning(
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
"default: the default's older BLE layout can crash the device at boot when "
"BLE is compiled in"
)
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
@@ -0,0 +1,292 @@
// bk72xx_ble.cpp
//
// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx
// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that
// talks to the Beken BDK BLE stack:
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
// - the controller BLE address,
// - the raw controller scan primitives (bk_ble_scan_start/stop),
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
// dispatches on the main task and returns reports to the pool — the same
// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at
// steady state.
// Consumers contain no SDK calls of their own.
//
// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny
// beken-72xx builder itself (prebuilt libble_<chip>.a + ble_5_x sources, gated
// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only
// calls into it via the public ble_api.h — no framework patch is required.
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
#ifdef USE_BK72XX_BLE
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h"
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
// ---------------------------------------------------------------------------
// Beken BDK BLE 5.x SDK — public API.
// Exposed on the include path by the LibreTiny beken-72xx builder
// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
// ---------------------------------------------------------------------------
extern "C" {
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
// app_ble_get_idle_actv_idx_handle, struct scan_param,
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
#include "common_bt_defines.h" // struct bd_addr
// The controller's public BLE address, populated by the BDK during ble_entry().
// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the
// address is derived from the WiFi MAC instead (matching the BDK's own fallback).
extern struct bd_addr common_default_bdaddr;
#endif
// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so
// declare it here.
void ble_entry(void);
}
namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
// The BDK notice callback is a plain C function pointer with no user argument,
// so it reaches the (single) component instance through a file-static pointer.
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// ---------------------------------------------------------------------------
// BLE notice callback — runs in the BDK BLE task context.
// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice
// carrying a recv_adv_t. Copy it into the queue and return; all dispatch
// happens in loop() on the main task.
// ---------------------------------------------------------------------------
static void ble_notice_callback(ble_notice_t notice, void *param) {
if (s_ble == nullptr || param == nullptr)
return;
if (notice != BLE_5_REPORT_ADV)
return;
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
// a signed dBm value packed in a uint8_t).
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
info->data_len);
}
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop.
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac, 6);
report->rssi = rssi;
report->addr_type = addr_type;
report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len);
// Cannot fail: the pool is sized to the queue capacity.
this->report_queue_.push(report);
}
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void BK72xxBLE::setup() {
s_ble = this;
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
// the stack is up (it is re-read once ble_entry() has run).
this->resolve_mac_();
if (this->enable_on_boot_) {
this->enable();
}
}
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK
// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order).
float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void BK72xxBLE::enable() {
if (this->state_ != BLEComponentState::STATE_OFF)
return;
this->state_ = BLEComponentState::ENABLING;
// One-time BLE stack init: register the notice callback, then bring up the
// BDK BLE stack. The BDK has no teardown path — init happens at most once.
ble_set_notice_cb(ble_notice_callback);
ble_entry();
delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time
// Re-read the BLE MAC now that the controller is up (common_default_bdaddr is
// populated by ble_entry()); resolve_mac_() may have fallen back earlier.
this->resolve_mac_();
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// Liveness heuristic (BK7231N): a healthy ble_entry() populates
// common_default_bdaddr during init, so all-zero after the settle delay
// suggests the stack did not come up. The BDK entry point returns void — no
// return code exists — so warn rather than fail: scan starts against a dead
// stack already fail cleanly downstream (no idle activity handle).
bool bdaddr_live = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
bdaddr_live = true;
break;
}
}
if (!bdaddr_live)
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
#endif
this->state_ = BLEComponentState::ACTIVE;
ESP_LOGD(TAG, "BLE stack initialised");
}
void BK72xxBLE::loop() {
// Drain the lock-free ring filled by the BLE task; all per-report work runs
// here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop();
if (report == nullptr)
return;
do {
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr);
// Log dropped reports — only reachable when reports were processed; drops can
// only occur while the queue is full, and only this loop drains it.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
void BK72xxBLE::dump_config() {
// ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the
// MSB-first order Home Assistant shows.
ESP_LOGCONFIG(TAG,
"BK72xx BLE:\n"
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
" Active: %s",
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
this->ble_mac_[0], YESNO(this->is_active()));
}
// ---------------------------------------------------------------------------
// MAC resolution
// ---------------------------------------------------------------------------
void BK72xxBLE::resolve_mac_() {
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention)
// during ble_entry(). It may still be zero before the stack is up; if so, fall
// through to the WiFi-derived MAC below.
bool nonzero = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
nonzero = true;
break;
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
return;
}
#endif
// Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other
// BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the
// Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte
// incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next
// byte), OUI unchanged. This reproduces the address the controller advertises with
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently.
uint8_t wifi_mac[6];
get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - i];
}
// ---------------------------------------------------------------------------
// Controller scan primitives
// ---------------------------------------------------------------------------
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
if (!this->is_active())
this->enable();
if (this->scan_actv_idx_ != 0xFF) {
// Already scanning — stop first so this call cleanly restarts with the new
// parameters (the BDK cannot start a second scan on a busy activity).
this->scan_stop();
}
struct scan_param sp;
memset(&sp, 0, sizeof(sp));
sp.channel_map = 7; // advertising channels 37/38/39
sp.interval = interval;
sp.window = window;
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (this->scan_actv_idx_ == 0xFF) {
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
return false;
}
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
if (ret != ERR_SUCCESS) {
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
this->scan_actv_idx_ = 0xFF;
return false;
}
return true;
}
void BK72xxBLE::scan_stop() {
if (this->scan_actv_idx_ != 0xFF) {
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
this->scan_actv_idx_ = 0xFF;
}
}
} // namespace esphome::bk72xx_ble
#endif // BK72XX_BLE_NO_SDK
#endif // USE_BK72XX_BLE
@@ -0,0 +1,98 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/lock_free_queue.h"
#include <cstdint>
#include <vector>
namespace esphome::bk72xx_ble {
enum class BLEComponentState : uint8_t {
STATE_OFF = 0,
ENABLING,
ACTIVE,
};
/// One advertisement report from the controller.
struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
uint8_t data_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
// EventPool contract: nothing is heap-allocated inside a report.
void release() {}
};
/// Consumer interface for controller scan reports. on_scan_report() always runs
/// on the ESPHome main task: reports are queued from the BDK BLE task and
/// drained by the controller's loop(), so consumers never deal with cross-task
/// state (the esp32_ble event-queue pattern).
class BLEScanListener {
public:
virtual void on_scan_report(const BLEScanReport &report) = 0;
protected:
~BLEScanListener() = default; // deletion via this interface is not part of the contract
};
// Maximum reports buffered between the BLE task and loop().
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
class BK72xxBLE final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
/// Bring up the BDK BLE stack (one-time; the BDK has no teardown path).
void enable();
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address, least-significant octet first (BLE convention).
void get_mac_lsb_first(uint8_t out[6]) const;
/// Register a consumer for scan reports (delivered on the main task via loop()).
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
/// Enables the stack first if needed. Returns false on controller failure.
bool scan_start(uint16_t interval, uint16_t window);
/// Stop the controller scan (no-op when not scanning).
void scan_stop();
/// Internal: buffer one controller report (BDK notice callback, BLE task
/// context — bounded copy under the scheduler lock, nothing else).
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
protected:
void resolve_mac_();
std::vector<BLEScanListener *> scan_listeners_;
// Report ring: the BDK notice callback (BLE task) allocates a report from the
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
// allocate() returns nullptr before push() can fail. This prevents leaking a
// pool slot on a failed push and keeps release() off the producer path.
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
uint8_t scan_actv_idx_{0xFF};
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{false};
};
} // namespace esphome::bk72xx_ble
#endif // USE_BK72XX_BLE
@@ -0,0 +1,82 @@
"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable
LibreTiny Beken chips (beken-72xx family).
Builds on the bk72xx_ble controller component (stack bring-up, BLE address,
scan primitives) and implements the platform-neutral ble_device_base BLEHub
contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner,
bthome_mithermometer, xiaomi_*, …) bind to this tracker through
cv.use_id(BLEHub) with no BK-specific code.
Scan modes:
continuous: true — scan runs forever; never stops automatically.
Use this when the radio is dedicated to BLE.
continuous: false — a started scan runs for `duration` ms, then stops. The
FIRST start is external too: nothing in this component
starts a non-continuous scan on boot, so until the
automation actions land (follow-up PR) the radio stays
idle. start_scan() is called from code (e.g. an api
client-connected automation) so the single-core radio
can service WiFi in between scans.
"""
import esphome.codegen as cg
from esphome.components import bk72xx_ble, ble_device_base, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
DEPENDENCIES = ["bk72xx"]
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
CODEOWNERS = ["@Bl00d-B0b"]
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
)
# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms
# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in
# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.)
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
}
).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through
# ble_device_base (and any tracker-owned listeners have been counted) before
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_listener_count() -> None:
count = ble_device_base.get_listener_count()
if count > 0:
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
cg.add(var.set_parent(parent))
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
@@ -0,0 +1,264 @@
// bk72xx_ble_tracker.cpp
//
// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
// timers and the rate-limited start retry. All controller access (stack
// bring-up, scan primitives, the BLE-task → main-task report queue) goes
// through the bk72xx_ble component — no SDK calls and no cross-task state here.
#ifdef USE_LIBRETINY
#include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker {
static const char *const TAG = "bk72xx_ble_tracker";
// Minimum interval between scan (re)start attempts, so a failing controller start
// cannot be retried every main-loop iteration (single-core CPU starvation). The
// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
// that never comes up — the controller logs each failure at ERROR — settles into a
// slow, quiet poll instead of an error line every second for the rest of uptime;
// a single WARN is emitted when the retry interval first saturates.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void BK72xxBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// BLE task and delivers here on the main task.
this->parent_->register_scan_listener(this);
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
}
#ifdef USE_OTA_STATE_LISTENER
void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_requested_before_ota_ = this->scan_requested_;
this->stop_scan();
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch).
if (this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
this->scan_continuous_ = true;
}
// A one-shot request that was still pending (latched, retrying) when the
// OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
if (this->scan_requested_before_ota_) {
this->scan_requested_before_ota_ = false;
this->scan_requested_ = true;
}
}
}
#endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() {
const uint32_t now = millis();
if (this->scan_continuous_) {
if (!this->scan_running_) {
// A start that succeeded re-anchored the period timer from a later millis(),
// so the stale `now` below would underflow the comparison and fire
// on_scan_end() for a scan that just began. Resume next iteration.
if (this->try_start_with_backoff_(now))
return;
}
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
// that never came up (start kept failing) does not fire spurious on_scan_end events.
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->scan_period_start_ = now;
}
return;
}
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. api: on_client_connected:).
//
// A requested start that failed (same controller failures the continuous branch
// absorbs) is retried with the same backoff — otherwise a failed one-shot start
// would be silent: the scan never runs, stop_scan_() is never reached and
// on_scan_end() never fires, leaving period-keyed consumers waiting forever.
if (this->scan_requested_ && !this->scan_running_) {
// Same stale-`now` hazard as the continuous branch: start_scan_() stamps
// scan_start_time_ from a later millis(), so the duration check below would
// underflow and stop the scan in the iteration that started it.
if (this->try_start_with_backoff_(now))
return;
}
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
// Rate-limit (re)start attempts. The controller start can fail (no idle activity
// handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
// main-loop iteration would spin the single-core CPU and starve WiFi (device
// becomes unresponsive). The interval backs off with consecutive failures so a
// controller that never comes up polls slowly and quietly.
//
// force bypasses the gate for an explicit user start (start_scan()) — but
// only while the failure streak is clean. Once the controller is failing,
// even user-initiated attempts respect the backoff, so a start_scan() action
// on a short cadence cannot hammer a failing controller; the attempt stays
// inside the failure accounting below either way.
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
if ((!force || this->failed_start_count_ != 0) &&
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings))
return false;
this->last_scan_start_attempt_ = now;
this->start_scan_();
if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
++this->failed_start_count_;
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
}
}
return this->scan_running_;
}
void BK72xxBLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"BK72xx BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
}
// ---------------------------------------------------------------------------
// Scan report — delivered by the controller's loop() on the ESPHome main task
// (the controller queues reports from the BLE task), so publish_state() and
// listener dispatch run in main-loop context with no cross-task handling here.
// ---------------------------------------------------------------------------
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.mac = report.mac,
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
ble_device_base::ESPBTDevice device;
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
bool found = false;
for (auto *listener : this->listeners_)
if (listener->parse_device(device))
found = true;
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
// it and the scan is one-shot (continuous scans would spam).
if (!found && !this->scan_continuous_)
this->discovered_log_.log_device(TAG, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
// ---------------------------------------------------------------------------
// Public scan control
// ---------------------------------------------------------------------------
void BK72xxBLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
//
// Nothing to do while a scan is already running: latching here would leave
// scan_requested_ set after that scan ends and silently restart a one-shot
// scan nobody asked for.
if (this->scan_running_)
return;
// The request is latched: if this immediate attempt fails (controller busy,
// WiFi/BLE coexistence), loop() keeps retrying it with backoff even in
// non-continuous mode, so a one-shot start cannot fail silently.
//
// Routed through the backoff helper (forced: the user asked for an immediate
// attempt) so a failure here still counts toward the backoff escalation and
// its WARN. The force bypass only applies while the failure streak is clean —
// against a failing controller, repeated start_scan() calls are rate-limited
// like any other attempt.
this->scan_requested_ = true;
this->try_start_with_backoff_(millis(), /* force= */ true);
}
void BK72xxBLETracker::stop_scan() {
this->scan_continuous_ = false;
this->scan_requested_ = false; // also cancels a pending (not yet successful) start
this->stop_scan_();
}
// ---------------------------------------------------------------------------
// Internal scan start / stop
// ---------------------------------------------------------------------------
void BK72xxBLETracker::start_scan_() {
if (this->scan_running_)
return;
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
return;
const uint32_t now = millis();
this->scan_running_ = true;
this->scan_requested_ = false; // the latched one-shot request is satisfied
this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too
this->scan_start_time_ = now;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
this->scan_interval_ * 0.625f);
// Re-anchor the on_scan_end period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
// arrive). scan_started_once_ purely gates the period timer.
this->scan_period_start_ = now;
this->scan_started_once_ = true;
}
void BK72xxBLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->scan_running_ = false;
ESP_LOGD(TAG, "Scan stopped");
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire
}
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
@@ -0,0 +1,158 @@
// bk72xx_ble_tracker.h
//
// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family).
// Implements the platform-neutral ble_device_base::BLEHub contract on top of the
// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered
// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the
// hub's raw-advertisement callback.
//
// This component contains no Beken SDK calls and no cross-task state: the
// controller (stack bring-up, BLE address, scan primitives, and the BLE-task →
// main-task report queue) is owned by bk72xx_ble, which delivers every scan
// report on the ESPHome main task. The tracker owns scan policy — parameters,
// duration/period timers and the rate-limited start retry.
//
// YAML config (values shown are the defaults; interval/window are a 30 % duty
// cycle, the BK reference scan rate):
//
// bk72xx_ble_tracker:
// scan_parameters:
// interval: 100ms
// window: 30ms
// duration: 5min
// continuous: true
#pragma once
#ifdef USE_LIBRETINY
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
namespace esphome::bk72xx_ble_tracker {
// ---------------------------------------------------------------------------
// BK72xxBLETracker
// ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
// ---- ESPHome Component ----
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
// mirrors esp32_ble_tracker.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
// ---- YAML configuration setters ----
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
// The Beken BDK exposes no active-scan path (passive scanning only), so the
// controller never solicits scan responses and never merges them; consumers
// relying on scan-response fields (device names) get them only where the
// receiver merges per address (Home Assistant does). No GATT client either.
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
// BLE-task → main-task handoff already happened in the controller's queue.
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override;
protected:
void start_scan_();
void stop_scan_();
/// Attempt a rate-limited (re)start; returns true when the scan is running,
/// which means the caller must not compare its cached millis() against the
/// timestamps start_scan_() just refreshed. force bypasses the rate gate for
/// an explicit user start only while the failure streak is clean; a failing
/// controller rate-limits forced attempts too. Failure accounting always runs.
bool try_start_with_backoff_(uint32_t now, bool force = false);
bool scan_running_{false};
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
// Defaults: the BK reference — 30 % duty cycle
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure
#endif
uint32_t scan_start_time_{0};
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Parsed-advertisement consumers registered through ble_device_base.
// Codegen-sized: no heap allocation, no std::vector template instantiations.
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
// in ble_device_base, identical output on every tracker backend. Guarded like
// its only writer so a no-listener build does not carry an unused vector.
ble_device_base::DiscoveredDeviceLog discovered_log_{};
#endif
};
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
+3 -1
View File
@@ -51,7 +51,9 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
if (!this->services_.empty() && this->all_nodes_established_()) {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
}
@@ -34,6 +34,11 @@ class BLEClientNode {
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; }
@@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -20,7 +20,9 @@ ble_aes_ccm.h.
import re
import esphome.codegen as cg
from esphome.components.const import CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL
from esphome.core import CORE
from esphome.types import ConfigType
@@ -76,6 +78,92 @@ async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj
# ---- shared validation / codegen helpers (platform-neutral) ----
def to_ble_units(value: cv.TimePeriod) -> int:
"""Convert a scan time to the controller's 0.625 ms units.
Used by both validation and codegen so what is validated is exactly what is
programmed — the truncation here is what makes the duty-cycle check below
meaningful.
"""
return value.total_microseconds // 625
def validate_scan_parameters(config: ConfigType) -> ConfigType:
"""Reject impossible window/interval/duration combinations at config time.
The controller cannot scan for longer than the interval, and a too-short
duration would end the scan period almost immediately. Catching it here
gives a clear error instead of a runtime controller failure and a retry
loop.
"""
duration = config[CONF_DURATION]
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("interval", interval), ("window", window)):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
f"Scan duration ({duration}) must cover at least three scan intervals "
f"({interval}): the scanner listens on one of the three BLE advertising "
f"channels per interval, so a shorter duration can miss devices entirely."
)
return config
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
supports_active: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when
the tracker supports active scanning; it exposes the `active` option
(whose own default is on, esp32_ble_tracker behavior).
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
}
if supports_active:
schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean
return cv.All(cv.Schema(schema), validate_scan_parameters)
BT_UUID16_FORMAT = "XXXX"
BT_UUID32_FORMAT = "XXXXXXXX"
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
@@ -264,6 +264,21 @@ optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData
// ESPBTDevice
// ---------------------------------------------------------------------------
const char *ESPBTDevice::address_type_str() const {
switch (this->address_type_) {
case BLE_ADDR_TYPE_PUBLIC:
return "PUBLIC";
case BLE_ADDR_TYPE_RANDOM:
return "RANDOM";
case BLE_ADDR_TYPE_RPA_PUBLIC:
return "RPA_PUBLIC";
case BLE_ADDR_TYPE_RPA_RANDOM:
return "RPA_RANDOM";
default:
return "UNKNOWN";
}
}
void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
// Ingest is BLE controller order (LSB-first); store in printable (MSB-first)
@@ -282,29 +297,13 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
this->parse_adv_(data, data_len);
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
ESP_LOGVV(TAG, "Parse Result:");
const char *address_type;
switch (this->address_type_) {
case BLE_ADDR_TYPE_PUBLIC:
address_type = "PUBLIC";
break;
case BLE_ADDR_TYPE_RANDOM:
address_type = "RANDOM";
break;
case BLE_ADDR_TYPE_RPA_PUBLIC:
address_type = "RPA_PUBLIC";
break;
case BLE_ADDR_TYPE_RPA_RANDOM:
address_type = "RPA_RANDOM";
break;
default:
address_type = "UNKNOWN";
break;
}
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGVV(TAG, " Address: %s (%s)", this->address_str_to(addr_buf), address_type);
ESP_LOGVV(TAG, " RSSI: %d", this->rssi_);
ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str());
ESP_LOGVV(TAG,
"Parse Result:\n"
" Address: %s (%s)\n"
" RSSI: %d\n"
" Name: '%s'",
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str());
for (auto &it : this->tx_powers_) {
ESP_LOGVV(TAG, " TX Power: %d", it);
}
@@ -322,20 +321,26 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
for (auto &mfg_data : this->manufacturer_datas_) {
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data);
if (ibeacon.has_value()) {
ESP_LOGVV(TAG, " Manufacturer iBeacon:");
ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_str(uuid_buf));
ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major());
ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor());
ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power());
ESP_LOGVV(TAG,
" Manufacturer iBeacon:\n"
" UUID: %s\n"
" Major: %u\n"
" Minor: %u\n"
" TXPower: %d",
ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(),
ibeacon.value().get_signal_power());
} else {
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size()));
}
}
for (auto &svc_data : this->service_datas_) {
ESP_LOGVV(TAG, " Service data:");
ESP_LOGVV(TAG, " UUID: %s", svc_data.uuid.to_str(uuid_buf));
ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
ESP_LOGVV(TAG,
" Service data:\n"
" UUID: %s\n"
" Data: %s",
svc_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
}
ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len));
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
@@ -493,4 +498,33 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
}
}
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog
// ---------------------------------------------------------------------------
void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) {
#ifdef ESPHOME_LOG_HAS_DEBUG
// Everything here feeds ESP_LOGD: below DEBUG the whole body (including the
// dedup vector growth) would be pure overhead, so compile it out entirely.
const uint64_t address = device.address_uint64();
for (auto &disc : this->already_discovered_) {
if (disc == address)
return;
}
this->already_discovered_.push_back(address);
char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD(tag,
"Found device %s RSSI=%d\n"
" Address Type: %s",
device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str());
if (!device.get_name().empty()) {
ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str());
}
for (auto &tx_power : device.get_tx_powers()) {
ESP_LOGD(tag, " TX Power: %d", tx_power);
}
#endif // ESPHOME_LOG_HAS_DEBUG
}
} // namespace esphome::ble_device_base
@@ -144,6 +144,20 @@ class ESPBLEiBeacon {
} beacon_data_;
};
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks.
///
/// The result is the printable-order value esp32 has always sent
/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire.
/// This takes the raw controller order delivered by BLEHub's raw-advertisement
/// callback; ESPBTDevice::address_uint64() is the equivalent for an already
/// parsed device, whose address is stored MSB-first.
inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
addr |= static_cast<uint64_t>(mac[i]) << (i * 8);
return addr;
}
// ---------------------------------------------------------------------------
// ESPBTDevice — parsed BLE advertisement
// ---------------------------------------------------------------------------
@@ -181,6 +195,9 @@ class ESPBTDevice {
#else
uint8_t get_address_type() const { return this->address_type_; }
#endif
/// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or
/// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above.
const char *address_type_str() const;
int get_rssi() const { return rssi_; }
const std::string &get_name() const { return name_; }
@@ -224,6 +241,24 @@ class ESPBTDevice {
optional<uint8_t> ad_flag_{};
};
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger
// ---------------------------------------------------------------------------
/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address.
/// Shared by all tracker backends so the output format and dedup behaviour stay
/// identical by construction (single implementation instead of per-chip copies).
class DiscoveredDeviceLog {
public:
/// Log the device at DEBUG the first time its MAC is seen this scan period.
void log_device(const char *tag, const ESPBTDevice &device);
/// Reset the per-period dedup list (call when a scan period ends).
void clear() { this->already_discovered_.clear(); }
protected:
std::vector<uint64_t> already_discovered_;
};
// ---------------------------------------------------------------------------
// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers)
// ---------------------------------------------------------------------------
+28 -7
View File
@@ -17,15 +17,36 @@
#include "ble_device.h"
#include <cstdint>
#include <functional>
namespace esphome::ble_device_base {
/// Callback for raw advertisements (the bluetooth_proxy path).
/// mac[] is least-significant octet first (BLE controller convention);
/// the hub delivers on the ESPHome main loop.
using RawAdvertisementCallback =
std::function<void(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len)>;
/// One raw advertisement as delivered by the controller — a borrowed view,
/// valid only for the duration of the invoke() callback.
struct RawAdvertisement {
/// Least-significant octet first (BLE controller convention).
const uint8_t *mac;
const uint8_t *data;
uint16_t data_len;
int8_t rssi; // signed dBm
uint8_t addr_type;
};
/// Subscriber slot for the raw-advertisement stream (the bluetooth_proxy
/// path). The hub delivers on the ESPHome main loop. Same shape as
/// logger.h's LogCallback: an instance pointer plus a plain function
/// pointer — no virtuals, no std::function.
///
/// Usage:
/// hub->set_raw_advertisement_callback({this, [](void *self, const RawAdvertisement &adv) {
/// static_cast<MyComponent *>(self)->on_raw_advertisement(adv);
/// }});
struct RawAdvertisementCallback {
void *instance{nullptr};
void (*fn)(void *instance, const RawAdvertisement &adv){nullptr};
/// A default-constructed slot is "no subscriber"; hubs must guard on this.
bool is_set() const { return this->fn != nullptr; }
void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); }
};
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
struct HubCapabilities {
@@ -48,7 +69,7 @@ class BLEHub {
virtual void register_listener(ESPBTDeviceListener *listener) = 0;
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time.
virtual void set_raw_advertisement_callback(RawAdvertisementCallback cb) = 0;
virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0;
virtual HubCapabilities get_capabilities() const = 0;
+5 -15
View File
@@ -5,6 +5,8 @@
#include <ctime>
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/text_sensor/text_sensor.h"
@@ -18,22 +20,10 @@ class BLEScanner final : public text_sensor::TextSensor,
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON
const char *name = device.get_name().c_str();
// Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this
// sensor has always published.
char escaped_name[128];
size_t pos = 0;
for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) {
uint8_t c = static_cast<uint8_t>(*name);
if (c == '"' || c == '\\') {
escaped_name[pos++] = '\\';
escaped_name[pos++] = c;
} else if (c < 0x20) {
pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c);
} else {
escaped_name[pos++] = c;
}
}
escaped_name[pos] = '\0';
json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false);
char buf[256];
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
@@ -247,7 +247,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_remove_bond_device(address);
this->send_device_pairing(msg.address, ret == ESP_OK, ret);
this->send_device_unpairing(msg.address, ret == ESP_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
@@ -1,7 +1,7 @@
import logging
import esphome.codegen as cg
from esphome.components import web_server_base
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -101,6 +101,9 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], paren)
await cg.register_component(var, config)
cg.add_define("USE_CAPTIVE_PORTAL")
# The portal reads wifi scan results from the web server task; this makes the
# wifi component guard them with a lock on multi-threaded platforms.
wifi.request_wifi_scan_results_lock()
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
+137 -136
View File
@@ -7,145 +7,146 @@ namespace esphome::captive_portal {
#ifdef USE_CAPTIVE_PORTAL_GZIP
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e,
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a,
0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68,
0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5,
0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22,
0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52,
0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06,
0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a,
0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0,
0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84,
0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7,
0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05,
0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6,
0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0,
0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7,
0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b,
0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e,
0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34,
0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b,
0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1,
0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37,
0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac,
0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3,
0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68,
0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc,
0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c,
0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93,
0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c,
0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18,
0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06,
0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c,
0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef,
0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2,
0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9,
0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8,
0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc,
0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca,
0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f,
0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0,
0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f,
0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c,
0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d,
0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf,
0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d,
0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6,
0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5,
0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b,
0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3,
0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69,
0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95,
0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9,
0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e,
0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62,
0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7,
0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97,
0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee,
0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11,
0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b,
0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9,
0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93,
0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97,
0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19,
0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc,
0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2,
0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc,
0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e,
0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e,
0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9,
0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3,
0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5,
0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37,
0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f,
0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22,
0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68,
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f,
0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69,
0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e,
0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0,
0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0,
0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1,
0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45,
0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6,
0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda,
0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9,
0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8,
0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65,
0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f,
0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25,
0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9,
0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43,
0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83,
0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b,
0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4,
0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d,
0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc,
0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e,
0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2,
0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66,
0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c,
0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d,
0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a,
0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32,
0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d,
0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf,
0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0,
0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d,
0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74,
0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30,
0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29,
0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd,
0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70,
0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd,
0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17,
0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd,
0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a,
0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88,
0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b,
0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f,
0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a,
0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79,
0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87,
0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1,
0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87,
0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7,
0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40,
0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67,
0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda,
0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81,
0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c,
0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64,
0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1,
0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2,
0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7,
0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16,
0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a,
0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96,
0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72,
0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb,
0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54,
0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4,
0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6,
0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a,
0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8,
0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79,
0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29,
0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35,
0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9,
0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde,
0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b,
0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14,
0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5,
0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00};
#else // Brotli (default, smaller)
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,
0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1,
0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18,
0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82,
0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c,
0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72,
0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61,
0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69,
0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4,
0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe,
0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00,
0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d,
0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0,
0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71,
0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a,
0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01,
0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a,
0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7,
0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15,
0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55,
0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8,
0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1,
0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d,
0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f,
0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38,
0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71,
0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91,
0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18,
0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d,
0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50,
0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02,
0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e,
0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4,
0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4,
0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86,
0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd,
0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55,
0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f,
0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab,
0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3,
0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d,
0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8,
0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4,
0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51,
0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59,
0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee,
0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43,
0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65,
0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71,
0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca,
0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42,
0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a,
0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc,
0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8,
0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5,
0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45,
0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01};
0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89,
0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6,
0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28,
0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63,
0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2,
0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20,
0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21,
0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6,
0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa,
0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6,
0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6,
0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5,
0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe,
0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55,
0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6,
0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01,
0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28,
0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8,
0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1,
0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17,
0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81,
0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9,
0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1,
0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5,
0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12,
0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20,
0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5,
0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2,
0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7,
0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89,
0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b,
0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07,
0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8,
0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28,
0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d,
0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf,
0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb,
0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5,
0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c,
0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09,
0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab,
0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b,
0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46,
0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d,
0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d,
0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4,
0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29,
0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45,
0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5,
0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75,
0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95,
0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3,
0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d,
0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12,
0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c,
0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3,
0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96,
0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c,
0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28};
// Backwards compatibility alias
#define INDEX_GZ INDEX_BR
@@ -2,6 +2,8 @@
#ifdef USE_CAPTIVE_PORTAL
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/wifi_component.h"
#include "captive_index.h"
@@ -24,23 +26,30 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str());
#endif
for (auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most
// 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result.
char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1];
{
// Invariant: only bounded in-memory work under the lock; the network send
// happens later in request->send()
wifi::ScanResultsLock lock(wifi::global_wifi_component);
for (const auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// Assumes no " in ssid, possible unicode isses?
json_escape_into_buffer(escaped_ssid, scan.get_ssid());
#ifdef USE_ESP8266
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(scan.get_ssid().c_str());
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(escaped_ssid);
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
#else
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(),
scan.get_with_auth());
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth());
#endif
}
}
stream->print(ESPHOME_F("]}"));
request->send(stream);
+1 -1
View File
@@ -274,7 +274,7 @@ def climate_schema(
@setup_entity("climate")
async def setup_climate_core_(var, config):
visual = config[CONF_VISUAL]
visual = config.get(CONF_VISUAL, {})
if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None:
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
cg.add(var.set_visual_min_temperature_override(min_temp))
@@ -205,6 +205,9 @@ class ClimateTraits {
float get_visual_max_humidity() const { return this->visual_max_humidity_; }
void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; }
TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; }
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
protected:
void set_mode_support_(climate::ClimateMode mode, bool supported) {
if (supported) {
@@ -274,6 +277,7 @@ class ClimateTraits {
climate::ClimateFanModeMask supported_fan_modes_;
climate::ClimateSwingModeMask supported_swing_modes_;
climate::ClimatePresetMask supported_presets_;
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
/** Custom mode storage - pointers to vectors owned by the Climate base class.
*
+4
View File
@@ -24,6 +24,7 @@ CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
CONF_NOX_INDEX = "nox_index"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_STATE_CHANGE = "on_state_change"
@@ -31,14 +32,17 @@ CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SHA256 = "sha256"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_USE_PSRAM = "use_psram"
CONF_VOC_INDEX = "voc_index"
CONF_VOLUME_INCREMENT = "volume_increment"
CONF_VOLUME_INITIAL = "volume_initial"
CONF_VOLUME_MAX = "volume_max"
CONF_VOLUME_MIN = "volume_min"
CONF_WINDOW = "window"
ICON_CURRENT_DC = "mdi:current-dc"
ICON_SOLAR_PANEL = "mdi:solar-panel"
+130
View File
@@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C
return color_to_bwyr<NATIVE_COLOR>(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red);
}
/** Map RGB color to discrete BWYRGB hex 6 color key
*
* Divides the RGB cube into 8 corners by which components are "on" (over 128), same as
* color_to_bwyr, but also resolves the green and blue corners instead of folding them into
* white/black.
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green,
NATIVE_COLOR hw_blue) {
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on && g_on && !b_on) {
return hw_yellow;
}
if (r_on && !g_on && !b_on) {
return hw_red;
}
if (!r_on && g_on && !b_on) {
return hw_green;
}
if (!r_on && !g_on && b_on) {
return hw_blue;
}
// Handle "impure" colors (cyan, magenta) by folding into the closest primary.
if (!r_on && g_on && b_on) {
return hw_green; // cyan
}
if (r_on && !g_on) {
return hw_red; // magenta
}
if (r_on) {
// All high (but not gray) -> white
return hw_white;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> black
return hw_black;
}
/** Map RGB color to discrete BWYRGBO hex 7 color key
*
* Same corner logic as color_to_bwyrgb, except the red/yellow corner is split three ways
* instead of two, for panels with a dedicated orange ink.
*
* @tparam NATIVE_COLOR Type of native hardware color values
* @param color RGB color to convert from
* @param hw_black Native value for black
* @param hw_white Native value for white
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @param hw_orange Native value for orange
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green,
NATIVE_COLOR hw_blue, NATIVE_COLOR hw_orange) {
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
}
const bool r_on = (color.r > 128);
const bool g_on = (color.g > 128);
const bool b_on = (color.b > 128);
if (r_on && !b_on) {
// Between red and yellow: split the gradient in three instead of two, since this panel has a
// dedicated orange ink. Named orange (e.g. 0xFFA500) has g close to the midpoint, so the
// plain g_on (>128) threshold used by color_to_bwyrgb can't tell it apart from yellow.
if (color.g > 170) {
return hw_yellow;
}
if (color.g > 85) {
return hw_orange;
}
return hw_red;
}
if (!r_on && g_on && !b_on) {
return hw_green;
}
if (!r_on && !g_on && b_on) {
return hw_blue;
}
// Handle "impure" colors (cyan, magenta) by folding into the closest primary.
if (!r_on && g_on && b_on) {
return hw_green; // cyan
}
if (r_on && !g_on) {
return hw_red; // magenta
}
if (r_on) {
// All high (but not gray) -> white
return hw_white;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> black
return hw_black;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,82 @@
#include "epaper_spi_4bpp.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.4bpp";
void EPaper4bpp::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
auto pixel_color = this->color_to_native(color);
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
// Whole buffer just changed; mark the entire canvas dirty.
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaper4bpp::clear() {
// clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
auto pixel_bits = this->color_to_native(color);
uint32_t pixel_position = x + y * this->get_width_internal();
uint32_t byte_position = pixel_position / 2;
auto original = this->buffer_[byte_position];
if ((pixel_position & 1) != 0) {
this->buffer_[byte_position] = (original & 0xF0) | pixel_bits;
} else {
this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4);
}
}
bool HOT EPaper4bpp::transfer_data() {
const uint32_t start_time = App.get_loop_component_start_time();
const size_t buffer_length = this->buffer_length_;
if (this->current_data_index_ == 0) {
this->command(CMD_TRANSFER_DATA);
}
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
}
this->current_data_index_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,35 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
/**
* Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte).
*
* Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by
* this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via
* color_to_native() plus their IC-specific power/refresh/sleep command sequences.
*/
class EPaper4bpp : public EPaperBase {
public:
EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) {
this->buffer_length_ = width * height / 2; // 2 pixels per byte
}
void fill(Color color) override;
void clear() override;
protected:
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
/// Map an RGB color to this panel's native 4-bit color key (low nibble).
virtual uint8_t color_to_native(Color color) = 0;
static constexpr uint8_t CMD_TRANSFER_DATA = 0x10;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,47 @@
// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR)
#include "epaper_spi_inkplate6color.h"
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.inkplate6color";
// Native hardware color codes for this panel's 4-bit color values.
enum Inkplate6ColorHex : uint8_t {
BLACK = 0,
WHITE = 1,
GREEN = 2,
BLUE = 3,
RED = 4,
YELLOW = 5,
ORANGE = 6,
};
uint8_t EPaperInkplate6Color::color_to_native(Color color) {
return color_to_bwyrgbo<uint8_t>(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE, ORANGE);
}
void EPaperInkplate6Color::power_on() {
ESP_LOGV(TAG, "Power on");
this->command(0x04);
}
void EPaperInkplate6Color::power_off() {
ESP_LOGV(TAG, "Power off");
this->command(0x02);
}
void EPaperInkplate6Color::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh"); // full refresh only; partial is unused
this->cmd_data(0x12, {0x00});
}
void EPaperInkplate6Color::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x07, {0xA5});
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,22 @@
#pragma once
#include "epaper_spi_4bpp.h"
namespace esphome::epaper_spi {
// Soldered Inkplate 6COLOR: 600x448 7-color (black/white/green/blue/red/yellow/orange) e-paper,
// UC8159-family controller.
class EPaperInkplate6Color final : public EPaper4bpp {
public:
using EPaper4bpp::EPaper4bpp;
protected:
uint8_t color_to_native(Color color) override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
};
} // namespace esphome::epaper_spi
@@ -1,76 +1,23 @@
#include "epaper_spi_spectra_e6.h"
#include <algorithm>
#include "colorconv.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.6c";
static constexpr unsigned char GRAY_THRESHOLD = 50;
enum E6Color {
BLACK,
WHITE,
YELLOW,
RED,
SKIP_1,
BLUE,
GREEN,
CYAN,
SKIP_2,
// Native hardware color codes for this panel's 4-bit color values.
enum E6Color : uint8_t {
BLACK = 0,
WHITE = 1,
YELLOW = 2,
RED = 3,
BLUE = 5,
GREEN = 6,
};
static uint8_t color_to_hex(Color color) {
// --- Step 1: Check for Grayscale (Black or White) ---
// We define "grayscale" as a color where the min and max components
// are close to each other.
unsigned char max_rgb = std::max({color.r, color.g, color.b});
unsigned char min_rgb = std::min({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return WHITE;
}
return BLACK;
}
// --- Step 2: Check for Primary/Secondary Colors ---
// If it's not gray, it's a color. We check which components are
// "on" (over 128) vs "off". This divides the RGB cube into 8 corners.
bool r_on = (color.r > 128);
bool g_on = (color.g > 128);
bool b_on = (color.b > 128);
if (r_on && g_on && !b_on) {
return YELLOW;
}
if (r_on && !g_on && !b_on) {
return RED;
}
if (!r_on && g_on && !b_on) {
return GREEN;
}
if (!r_on && !g_on && b_on) {
return BLUE;
}
// Handle "impure" colors (Cyan, Magenta)
if (!r_on && g_on && b_on) {
// Cyan (G+B) -> Closest is Green or Blue. Pick Green.
return GREEN;
}
if (r_on && !g_on) {
// Magenta (R+B) -> Closest is Red or Blue. Pick Red.
return RED;
}
// Handle the remaining corners (White-ish, Black-ish)
if (r_on) {
// All high (but not gray) -> White
return WHITE;
}
// !r_on && !g_on && !b_on
// All low (but not gray) -> Black
return BLACK;
uint8_t EPaperSpectraE6::color_to_native(Color color) {
return color_to_bwyrgb<uint8_t>(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE);
}
void EPaperSpectraE6::power_on() {
@@ -92,71 +39,4 @@ void EPaperSpectraE6::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x07, {0xA5});
}
void EPaperSpectraE6::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
auto pixel_color = color_to_hex(color);
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
}
void EPaperSpectraE6::clear() {
// clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
auto pixel_bits = color_to_hex(color);
uint32_t pixel_position = x + y * this->get_width_internal();
uint32_t byte_position = pixel_position / 2;
auto original = this->buffer_[byte_position];
if ((pixel_position & 1) != 0) {
this->buffer_[byte_position] = (original & 0xF0) | pixel_bits;
} else {
this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4);
}
}
bool HOT EPaperSpectraE6::transfer_data() {
const uint32_t start_time = App.get_loop_component_start_time();
const size_t buffer_length = this->buffer_length_;
if (this->current_data_index_ == 0) {
this->command(0x10);
}
size_t buf_idx = 0;
uint8_t bytes_to_send[MAX_TRANSFER_SIZE];
while (this->current_data_index_ != buffer_length) {
bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++];
if (buf_idx == sizeof bytes_to_send) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis());
buf_idx = 0;
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
return false;
}
}
}
// Finished the entire dataset
if (buf_idx != 0) {
this->start_data_();
this->write_array(bytes_to_send, buf_idx);
this->disable();
}
this->current_data_index_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -1,28 +1,20 @@
#pragma once
#include "epaper_spi.h"
#include "epaper_spi_4bpp.h"
namespace esphome::epaper_spi {
class EPaperSpectraE6 final : public EPaperBase {
class EPaperSpectraE6 final : public EPaper4bpp {
public:
EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) {
this->buffer_length_ = width * height / 2; // 2 pixels per byte
}
void fill(Color color) override;
void clear() override;
using EPaper4bpp::EPaper4bpp;
protected:
uint8_t color_to_native(Color color) override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
};
} // namespace esphome::epaper_spi
@@ -15,7 +15,7 @@ class EpaperModel:
self,
name: str,
class_name: str,
initsequence=None,
initsequence=(),
**defaults,
):
name = name.upper()
@@ -0,0 +1,57 @@
# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR)
from esphome.components.mipi import delay
from . import EpaperModel
class Inkplate6ColorModel(EpaperModel):
def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs):
super().__init__(name, class_name, **kwargs)
# fmt: off
def get_init_sequence(self, config: dict):
width, height = self.get_dimensions(config)
return (
(0x00, 0xEF, 0x08,), # panel setting
(0x01, 0x37, 0x00, 0x05, 0x05,), # power setting
(0x03, 0x00,), # power off sequence setting
(0x06, 0xC7, 0xC7, 0x1D,), # booster soft start
(0x41, 0x00,), # temperature sensor enable
(0x50, 0x37,), # VCOM and data interval
(0x60, 0x20,), # TCON setting
(0x61, width // 256, width % 256, height // 256, height % 256,), # resolution set
(0xE3, 0xAA,), # power saving
delay(100),
(0x50, 0x37,), # VCOM and data interval, resent once the power-saving setting settles
)
# Native orientation is landscape (600x448).
inkplate6color = Inkplate6ColorModel(
"inkplate6color",
width=600,
height=448,
# Vendor library drives the panel at 2MHz; the controller doesn't reliably support faster rates.
data_rate="2MHz",
# Vendor library waits 200ms after releasing reset before talking to the panel.
reset_duration="200ms",
# A full 7-color refresh takes tens of seconds; disallow faster updates to avoid FSM update loops.
minimum_update_interval="30s",
# Panel's native buffer orientation is rotated 180 degrees relative to the logical
# rotation=0 orientation; confirmed on real hardware.
mirror_x=True,
mirror_y=True,
# Default GPIO pins for the on-board Inkplate 6COLOR wiring.
reset_pin=19,
dc_pin=33,
cs_pin=27,
busy_pin={
"number": 32,
"inverted": True, # hardware: LOW=busy, HIGH=idle
"mode": {
"input": True,
"pullup": True,
},
},
)
+22 -9
View File
@@ -814,14 +814,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, 10),
"latest": cv.Version(3, 3, 10),
"dev": cv.Version(3, 3, 10),
"recommended": cv.Version(3, 3, 11),
"latest": cv.Version(3, 3, 11),
"dev": cv.Version(3, 3, 11),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(
4, 0, 0, "alpha1"
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(3, 3, 11): cv.Version(55, 3, 311),
cv.Version(3, 3, 10): cv.Version(55, 3, 39),
cv.Version(3, 3, 9): cv.Version(55, 3, 39),
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
@@ -845,6 +846,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# 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, 11): cv.Version(5, 5, 5),
cv.Version(3, 3, 10): cv.Version(5, 5, 5),
cv.Version(3, 3, 9): cv.Version(5, 5, 4),
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
@@ -879,7 +881,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 5): cv.Version(55, 3, 39),
cv.Version(5, 5, 5): cv.Version(55, 3, 311),
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),
@@ -900,8 +902,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, 39),
"latest": cv.Version(55, 3, 39),
"recommended": cv.Version(55, 3, 311),
"latest": cv.Version(55, 3, 311),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}
@@ -1029,7 +1031,9 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
def _validate_toolchain(value) -> Toolchain:
return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value))
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
)
def _resolve_toolchain(value: ConfigType) -> ConfigType:
@@ -1237,6 +1241,13 @@ def final_validate(config):
from .gpio import final_validate_pins
# Remove before 2027.2.0
if CORE.using_toolchain_platformio:
_LOGGER.warning(
"The 'platformio' toolchain for ESP32 is deprecated and will be removed "
"in ESPHome 2027.2.0. Please use 'toolchain: esp-idf' instead."
)
errs = []
conf_fw = config[CONF_FRAMEWORK]
advanced = conf_fw[CONF_ADVANCED]
@@ -3108,9 +3119,10 @@ def _parse_register(config, regex, line):
STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*")
STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP32_C3_MTVAL_RE = re.compile(r".*MTVAL\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_BAD_ALLOC_RE = re.compile(
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
)
@@ -3128,9 +3140,10 @@ def process_stacktrace(config, line, backtrace_state):
# ESP32 PC/EXCVADDR
_parse_register(config, STACKTRACE_ESP32_PC_RE, line)
_parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line)
# ESP32-C3 PC/RA
# ESP32-C3 PC/RA/MTVAL
_parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line)
_parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line)
_parse_register(config, STACKTRACE_ESP32_C3_MTVAL_RE, line)
# bad alloc
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
+6
View File
@@ -63,4 +63,10 @@ VARIANT_FRIENDLY = {
VARIANT_ESP32S31: "ESP32-S31",
}
def variant_to_idf_target(variant: str) -> str:
"""Map an esp32 variant name (e.g. "ESP32S3") to its ESP-IDF target name."""
return variant.lower().replace("-", "")
esp32_ns = cg.esphome_ns.namespace("esp32")
+31 -3
View File
@@ -122,7 +122,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
// Magic is second to validate the data. Remaining fields can change between versions.
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
static constexpr uint32_t CRASH_DATA_VERSION = 2;
static constexpr uint32_t CRASH_DATA_VERSION = 3;
struct RawCrashData {
uint32_t version;
uint32_t magic;
@@ -132,7 +132,8 @@ struct RawCrashData {
uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG)
uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic)
uint32_t backtrace[MAX_BACKTRACE];
uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V)
uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V)
uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V)
uint8_t crashed_core;
#if SOC_CPU_CORES_NUM > 1
static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores");
@@ -240,6 +241,16 @@ static const char *get_exception_reason() {
nullptr,
"LoadProhibited",
"StoreProhibited",
nullptr,
nullptr,
"Cp0Dis",
"Cp1Dis",
"Cp2Dis",
"Cp3Dis",
"Cp4Dis",
"Cp5Dis",
"Cp6Dis",
"Cp7Dis",
};
uint32_t cause = s_raw_crash_data.cause;
if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr)
@@ -332,12 +343,24 @@ void crash_handler_log() {
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
const char *reason = get_exception_reason();
if (reason != nullptr) {
ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason);
ESP_LOGE(TAG, " Reason: %s - %s (cause %" PRIu32 ")", get_exception_type(), reason, s_raw_crash_data.cause);
} else {
ESP_LOGE(TAG, " Reason: %s", get_exception_type());
}
ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core);
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc);
// Faulting memory address — only meaningful for real CPU faults, not
// aborts/watchdogs or SoC-level pseudo exceptions. Uses the same register
// name as ESP-IDF's live register dump for the architecture (EXCVADDR on
// Xtensa, MTVAL on RISC-V) so the CLI decodes it when it happens to be a
// code address.
if (s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause) {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
ESP_LOGE(TAG, " EXCVADDR: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr);
#elif CONFIG_IDF_TARGET_ARCH_RISCV
ESP_LOGE(TAG, " MTVAL: 0x%08" PRIX32 " (faulting address)", s_raw_crash_data.fault_addr);
#endif
}
log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count);
#if SOC_CPU_CORES_NUM > 1
@@ -382,6 +405,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
s_raw_crash_data.exception = (uint8_t) info->exception;
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
s_raw_crash_data.crashed_core = (uint8_t) info->core;
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
s_raw_crash_data.cause = 0;
s_raw_crash_data.fault_addr = 0;
#if SOC_CPU_CORES_NUM > 1
s_raw_crash_data.other_backtrace_count = 0;
s_raw_crash_data.other_reg_frame_count = 0;
@@ -392,6 +418,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
}
@@ -414,6 +441,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
if (info->frame != nullptr) {
auto *rv_frame = (RvExcFrame *) info->frame;
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
s_raw_crash_data.backtrace_count =
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
}
+1
View File
@@ -58,6 +58,7 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT
+4 -1
View File
@@ -207,7 +207,7 @@ class BLEEvent {
StatusOnlyData scan_complete; // 1 byte
// Advertising complete events all have same structure
// Used by: esp32_ble_beacon, esp32_ble server components
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP
// ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP
StatusOnlyData adv_complete; // 1 byte
// RSSI complete event
// Used by: ble_client (ble_rssi_sensor component)
@@ -324,6 +324,9 @@ class BLEEvent {
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon
this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status;
break;
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response
this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status;
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon
this->event_.gap.adv_complete.status = p->adv_start_cmpl.status;
break;
@@ -125,6 +125,9 @@ void BLEClientBase::connect() {
}
ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_);
this->paired_ = false;
// A registration whose event never arrived must not block this connection's release.
this->services_released_ = false;
this->pending_notify_regs_ = 0;
// Enable loop for state processing
this->enable_loop();
// Immediately transition to CONNECTING to prevent duplicate connection attempts
@@ -200,10 +203,26 @@ void BLEClientBase::release_services() {
this->services_.clear();
#endif
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
#endif
}
esp_err_t BLEClientBase::register_for_notify(uint16_t char_handle) {
esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, char_handle);
if (err != ESP_OK)
return err;
if (this->pending_notify_regs_ == UINT8_MAX) {
// Saturating undercounts, so the release can run before the last registration completes.
// Wrapping to zero would undercount by the full range instead, which is worse.
this->log_warning_("Too many outstanding notify registrations to track");
return err;
}
this->pending_notify_regs_++;
return err;
}
void BLEClientBase::log_event_(const char *name) {
ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name);
}
@@ -498,12 +517,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->log_gattc_data_event_("REG_FOR_NOTIFY");
// The event carries no conn_id, so this is the only place the request can be retired.
if (this->pending_notify_regs_ > 0)
this->pending_notify_regs_--;
if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) {
// Client is responsible for flipping the descriptor value
// when using the cache
break;
}
if (this->services_released_) {
// The lookup below walks the freed GATT cache, and Bluedroid asserts on it rather than erroring.
this->log_warning_("REG_FOR_NOTIFY after services released, notifications not enabled");
break;
}
esp_gattc_descr_elem_t desc_result;
uint16_t count = 1;
esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle(
@@ -44,6 +44,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void unconditional_disconnect();
void release_services();
/// Register for notifications, holding the service release until the registration completes.
esp_err_t register_for_notify(uint16_t char_handle);
/// True while a register_for_notify() request has not completed.
bool notify_registration_pending() const { return this->pending_notify_regs_ > 0; }
bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; }
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
@@ -125,9 +131,15 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
espbt::ConnectionType connection_type_{espbt::ConnectionType::V1};
uint8_t connection_index_;
uint8_t service_count_{0}; // ESP32 has max handles < 255, typical devices have < 50 services
// Outstanding register_for_notify() requests
// A count, not per-request state, so a raw esp_ble_gattc_register_for_notify() on the same client can retire one
// services_released_ is the backstop if that ever lets the release run early
uint8_t pending_notify_regs_{0};
bool auto_connect_{false};
bool paired_{false};
// 6 bytes used, 2 bytes padding
// Set only when release_services() cleans the stack's GATT cache, which no API may then walk
bool services_released_{false};
// 8 bytes used, no padding
void log_event_(const char *name);
void log_gattc_lifecycle_event_(const char *name);
@@ -6,6 +6,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
request_bluetooth,
@@ -44,8 +45,6 @@ DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@bdraco"]
CONF_ESP32_BLE_ID = "esp32_ble_id"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_WINDOW = "window"
CONF_ON_SCAN_END = "on_scan_end"
CONF_SOFTWARE_COEXISTENCE = "software_coexistence"
@@ -126,25 +125,6 @@ ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_(
)
def validate_scan_parameters(config):
duration = config[CONF_DURATION]
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
if interval.total_milliseconds * 3 > duration.total_milliseconds:
raise cv.Invalid(
"Scan duration needs to be at least three times the scan interval to"
"cover all BLE channels."
)
return config
def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
if CONF_MAX_CONNECTIONS in config:
_LOGGER.warning(
@@ -154,6 +134,13 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
return config
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
# window/interval pairs that collapse to the same 0.625 ms unit count.
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", supports_active=True
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
# here for the components that import them from this module.
as_hex = ble_device_base.as_hex
@@ -169,24 +156,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MAX_CONNECTIONS): cv.All(
cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS)
),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All(
cv.Schema(
{
cv.Optional(
CONF_DURATION, default="5min"
): cv.positive_time_period_seconds,
cv.Optional(
CONF_INTERVAL, default="320ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_WINDOW, default="30ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
}
),
validate_scan_parameters,
),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
@@ -256,8 +226,8 @@ async def to_code(config):
params = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_duration(params[CONF_DURATION]))
cg.add(var.set_scan_interval(int(params[CONF_INTERVAL].total_milliseconds / 0.625)))
cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625)))
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
@@ -258,7 +258,7 @@ void ESP32BLETracker::start_scan_(bool first) {
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->already_discovered_.clear();
this->discovered_log_.clear();
#endif
this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE;
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
@@ -471,42 +471,9 @@ void ESP32BLETracker::dump_config() {
#ifdef USE_ESP32_BLE_DEVICE
void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) {
const uint64_t address = device.address_uint64();
for (auto &disc : this->already_discovered_) {
if (disc == address)
return;
}
this->already_discovered_.push_back(address);
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD(TAG, "Found device %s RSSI=%d", device.address_str_to(addr_buf), device.get_rssi());
const char *address_type_s;
switch (device.get_address_type()) {
case BLE_ADDR_TYPE_PUBLIC:
address_type_s = "PUBLIC";
break;
case BLE_ADDR_TYPE_RANDOM:
address_type_s = "RANDOM";
break;
case BLE_ADDR_TYPE_RPA_PUBLIC:
address_type_s = "RPA_PUBLIC";
break;
case BLE_ADDR_TYPE_RPA_RANDOM:
address_type_s = "RPA_RANDOM";
break;
default:
address_type_s = "UNKNOWN";
break;
}
ESP_LOGD(TAG, " Address Type: %s", address_type_s);
if (!device.get_name().empty()) {
ESP_LOGD(TAG, " Name: '%s'", device.get_name().c_str());
}
for (auto &tx_power : device.get_tx_powers()) {
ESP_LOGD(TAG, " TX Power: %d", tx_power);
}
// Shared implementation in ble_device_base — identical output and per-period
// MAC dedup on every tracker backend.
this->discovered_log_.log_device(TAG, device);
}
// resolve_irk() is provided by ble_device_base (portable software AES).
@@ -566,7 +533,7 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
ESP_LOGV(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : "");
#ifdef USE_ESP32_BLE_DEVICE
this->already_discovered_.clear();
this->discovered_log_.clear();
#endif
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
@@ -239,8 +239,8 @@ class ESP32BLETracker final : public Component,
// ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override;
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override {
this->raw_advertisement_callback_ = std::move(cb);
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true};
@@ -341,10 +341,10 @@ class ESP32BLETracker final : public Component,
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> neutral_listeners_;
#endif
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr};
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef USE_ESP32_BLE_DEVICE
/// Vector of addresses that have already been printed in print_bt_device_info
std::vector<uint64_t> already_discovered_;
/// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl)
ble_device_base::DiscoveredDeviceLog discovered_log_;
#endif
// Group 2: Structs (aligned to 4 bytes)
+3 -3
View File
@@ -256,10 +256,10 @@ async def to_code(config):
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
if idf_ver >= cv.Version(5, 5, 0):
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/esp_wifi_remote", ref="1.6.3")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
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")
+14 -25
View File
@@ -30,6 +30,7 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
@@ -294,6 +295,7 @@ async def to_code(config):
)
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
@@ -443,31 +445,18 @@ async def finalize_serial_config() -> None:
# Called by writer.py
def copy_files() -> None:
dir = Path(__file__).parent
post_build_file = dir / "post_build.py.script"
copy_file_if_changed(
post_build_file,
CORE.relative_build_path("post_build.py"),
)
testing_mode_file = dir / "testing_mode.py.script"
copy_file_if_changed(
testing_mode_file,
CORE.relative_build_path("testing_mode.py"),
)
exclude_updater_file = dir / "exclude_updater.py.script"
copy_file_if_changed(
exclude_updater_file,
CORE.relative_build_path("exclude_updater.py"),
)
exclude_waveform_file = dir / "exclude_waveform.py.script"
copy_file_if_changed(
exclude_waveform_file,
CORE.relative_build_path("exclude_waveform.py"),
)
remove_float_scanf_file = dir / "remove_float_scanf.py.script"
copy_file_if_changed(
remove_float_scanf_file,
CORE.relative_build_path("remove_float_scanf.py"),
)
for script in (
"post_build",
"testing_mode",
"exclude_updater",
"exclude_waveform",
"remove_float_scanf",
):
copy_file_if_changed(
dir / f"{script}.py.script",
CORE.relative_build_path(f"{script}.py"),
)
copy_ccache_script()
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
+42 -31
View File
@@ -437,37 +437,48 @@ GENERIC_SCHEMA = cv.All(
cv.only_on([Platform.ESP32]),
)
SPI_SCHEMA = cv.All(
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All(
cv.only_on_esp32,
cv.frequency,
cv.int_range(int(8e6), int(80e6)),
),
cv.Optional(CONF_INTERFACE): cv.All(
cv.only_on_esp32,
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
),
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
cv.only_on_esp32,
cv.positive_time_period_milliseconds,
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
),
}
def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)):
return cv.All(
BASE_SCHEMA.extend(
cv.Schema(
{
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number,
cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(
CONF_INTERRUPT_PIN
): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All(
cv.only_on_esp32,
cv.frequency,
cv.int_range(int(8e6), max_clock),
),
cv.Optional(CONF_INTERFACE): cv.All(
cv.only_on_esp32,
cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True),
),
# Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate()
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
cv.only_on_esp32,
cv.positive_time_period_milliseconds,
cv.Range(min=TimePeriodMilliseconds(milliseconds=1)),
),
}
),
),
),
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_interface,
)
cv.only_on([Platform.ESP32, Platform.RP2]),
_validate_spi_interface,
)
SPI_SCHEMA = _spi_schema()
# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out
# of spec for it and makes the driver's CS hold time helper compute no hold
SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
@@ -483,7 +494,7 @@ CONFIG_SCHEMA = cv.All(
"W5500": SPI_SCHEMA,
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
"DM9051": SPI_SCHEMA,
"ENC28J60": SPI_SCHEMA,
"ENC28J60": SPI_SCHEMA_ENC28J60,
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"LAN8670": RMII_SCHEMA,
@@ -232,8 +232,10 @@ void EthernetComponent::ethernet_lazy_init_() {
dm9051_config.poll_period_ms = this->polling_interval_;
#endif
#elif defined(USE_ETHERNET_ENC28J60)
// ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold
// time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID")
enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000);
enc28j60_config.int_gpio_num = this->interrupt_pin_;
// ENC28J60 does not support poll_period_ms
#endif
phy_config.phy_addr = this->phy_addr_spi_;
+2 -2
View File
@@ -23,8 +23,8 @@ CONF_PUMP_VOLTAGE = "pump_voltage"
CONF_LAST_VOLUME_REQUESTED = "last_volume_requested"
CONF_MAX_FLOW_RATE = "max_flow_rate"
UNIT_MILILITER = "ml"
UNIT_MILILITERS_PER_MINUTE = "ml/min"
UNIT_MILILITER = "mL"
UNIT_MILILITERS_PER_MINUTE = "mL/min"
CONFIG_SCHEMA = cv.Schema(
{
@@ -7,7 +7,6 @@ namespace esphome::growatt_solar {
static const char *const TAG = "growatt_solar";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04;
static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion
void GrowattSolar::loop() {
@@ -31,11 +30,12 @@ void GrowattSolar::update() {
}
this->waiting_to_update_ = false;
this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]);
this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]);
this->last_send_ = millis();
}
void GrowattSolar::on_modbus_data(const std::vector<uint8_t> &data) {
void GrowattSolar::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
// Other components might be sending commands to our device. But we don't get called with enough
// context to know what is what. So if we didn't do a send, we ignore the data.
if (!this->last_send_)
@@ -4,7 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/modbus/modbus.h"
#include <vector>
#include <span>
namespace esphome::growatt_solar {
@@ -69,7 +69,7 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD
public:
void loop() override;
void update() override;
void on_modbus_data(const std::vector<uint8_t> &data) override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void dump_config() override;
void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; }
+15
View File
@@ -68,6 +68,21 @@ MODELS = {
CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4",
},
},
"GUITION-JC8012P4A1": {
CONF_SWAP_XY: True,
CONF_MIRROR_X: True,
CONF_MIRROR_Y: False,
CONF_X_MIN: 20,
CONF_Y_MIN: 20,
CONF_X_MAX: 880,
CONF_Y_MAX: 1648,
CONF_RESET_PIN: 22,
CONF_INTERRUPT_PIN: 21,
CONF_FIRMWARE: {
CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin",
CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4",
},
},
"CUSTOM": {},
}
@@ -7,10 +7,10 @@ namespace esphome::havells_solar {
static const char *const TAG = "havells_solar";
static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03;
static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers
void HavellsSolar::on_modbus_data(const std::vector<uint8_t> &data) {
void HavellsSolar::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
if (data.size() < MODBUS_REGISTER_COUNT * 2) {
ESP_LOGW(TAG, "Invalid size for HavellsSolar!");
return;
@@ -121,7 +121,7 @@ void HavellsSolar::on_modbus_data(const std::vector<uint8_t> &data) {
this->dci_of_t_sensor_->publish_state(dci_of_t);
}
void HavellsSolar::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); }
void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); }
void HavellsSolar::dump_config() {
ESP_LOGCONFIG(TAG,
"HAVELLS Solar:\n"
@@ -4,7 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/modbus/modbus.h"
#include <vector>
#include <span>
namespace esphome::havells_solar {
@@ -77,7 +77,7 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD
void update() override;
void on_modbus_data(const std::vector<uint8_t> &data) override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void dump_config() override;
+14 -7
View File
@@ -8,6 +8,7 @@ from esphome.const import (
CONF_VISUAL,
)
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@rob-deutsch"]
@@ -97,6 +98,18 @@ VERTICAL_DIRECTIONS = {
"down": VerticalDirections.VERTICAL_DIRECTION_DOWN,
}
def _default_visual(config: ConfigType) -> ConfigType:
# Seed the visual min/max from the required min/max_temperature so the entity
# reports the configured range in Home Assistant instead of the ClimateIR
# 0-100 default. Done during validation so the effective range is visible in
# the dumped config and set before new_climate_ir() reads CONF_VISUAL.
visual = config.setdefault(CONF_VISUAL, {})
visual.setdefault(CONF_MAX_TEMPERATURE, config[CONF_MAX_TEMPERATURE])
visual.setdefault(CONF_MIN_TEMPERATURE, config[CONF_MIN_TEMPERATURE])
return config
CONFIG_SCHEMA = cv.All(
climate_ir.climate_ir_with_receiver_schema(HeatpumpIRClimate).extend(
{
@@ -108,18 +121,12 @@ CONFIG_SCHEMA = cv.All(
}
),
cv.Any(cv.only_with_arduino, cv.only_on_esp32),
_default_visual,
)
async def to_code(config):
var = await climate_ir.new_climate_ir(config)
if CONF_VISUAL not in config:
config[CONF_VISUAL] = {}
visual = config[CONF_VISUAL]
if CONF_MAX_TEMPERATURE not in visual:
visual[CONF_MAX_TEMPERATURE] = config[CONF_MAX_TEMPERATURE]
if CONF_MIN_TEMPERATURE not in visual:
visual[CONF_MIN_TEMPERATURE] = config[CONF_MIN_TEMPERATURE]
cg.add(var.set_protocol(config[CONF_PROTOCOL]))
cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT]))
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
+7
View File
@@ -10,6 +10,7 @@ from esphome.const import (
ThreadModel,
)
from esphome.core import CORE
from esphome.platformio.toolchain import copy_ccache_script
from .const import KEY_HOST
@@ -49,3 +50,9 @@ async def to_code(config):
cg.add_platformio_option("platform", "platformio/native")
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
# Called by writer.py
def copy_files() -> None:
copy_ccache_script()
+1
View File
@@ -29,6 +29,7 @@ DEPENDENCIES = ["esp32"]
MULTI_CONF = True
CONF_PDM = "pdm"
CONF_PDM_DSR = "pdm_dsr"
CONF_ADC_TYPE = "adc_type"
CONF_I2S_DOUT_PIN = "i2s_dout_pin"
@@ -17,6 +17,7 @@ from .. import (
CONF_LEFT,
CONF_MONO,
CONF_PDM,
CONF_PDM_DSR,
CONF_RIGHT,
I2SAudioIn,
i2s_audio_component_schema,
@@ -38,6 +39,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t")
I2S_PDM_DSR = {
8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S,
16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S,
}
def _validate_esp32_variant(config):
variant = esp32.get_esp32_variant()
@@ -111,6 +118,9 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_PDM, default=False): cv.boolean,
cv.Optional(CONF_PDM_DSR, default=8): cv.enum(
I2S_PDM_DSR, int=True
),
}
),
},
@@ -142,5 +152,7 @@ async def to_code(config):
cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN]))
cg.add(var.set_pdm(config[CONF_PDM]))
if esp32.get_esp32_variant() in PDM_VARIANTS:
cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR]))
cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET]))
@@ -128,7 +128,7 @@ bool I2SAudioMicrophone::start_driver_() {
.sample_rate_hz = this->sample_rate_,
.clk_src = clk_src,
.mclk_multiple = this->mclk_multiple_,
.dn_sample_mode = I2S_PDM_DSR_8S,
.dn_sample_mode = this->pdm_dsr_,
};
i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_);
@@ -245,7 +245,7 @@ void I2SAudioMicrophone::mic_task(void *params) {
while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) {
if (this_microphone->data_callbacks_.size() > 0) {
samples.resize(bytes_to_read);
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * pdMS_TO_TICKS(READ_DURATION_MS));
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS);
samples.resize(bytes_read);
if (this_microphone->correct_dc_offset_) {
this_microphone->fix_dc_offset_(samples);
@@ -318,12 +318,11 @@ void I2SAudioMicrophone::fix_dc_offset_(std::vector<uint8_t> &data) {
}
}
size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) {
size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) {
size_t bytes_read = 0;
// i2s_channel_read expects the timeout value in ms, not ticks
esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, pdTICKS_TO_MS(ticks_to_wait));
if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (ticks_to_wait != 0))) {
// Ignore ESP_ERR_TIMEOUT if ticks_to_wait = 0, as it will read the data on the next call
esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms);
if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) {
// Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call
if (!this->status_has_warning()) {
// Avoid spamming the logs with the error message if its repeated
ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err));
@@ -331,7 +330,7 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w
this->status_set_warning();
return 0;
}
if ((bytes_read == 0) && (ticks_to_wait > 0)) {
if ((bytes_read == 0) && (timeout_ms > 0)) {
this->status_set_warning();
return 0;
}
@@ -29,6 +29,10 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon
void set_pdm(bool pdm) { this->pdm_ = pdm; }
#if SOC_I2S_SUPPORTS_PDM_RX
void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; }
#endif
protected:
/// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings.
/// @return True if succesful, false otherwise
@@ -42,7 +46,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon
/// @param data
void fix_dc_offset_(std::vector<uint8_t> &data);
size_t read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait);
size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms);
/// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings.
void configure_stream_settings_();
@@ -57,6 +61,9 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon
gpio_num_t din_pin_{I2S_GPIO_UNUSED};
i2s_chan_handle_t rx_handle_;
bool pdm_{false};
#if SOC_I2S_SUPPORTS_PDM_RX
i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S};
#endif
bool correct_dc_offset_;
bool locked_driver_{false};
@@ -16,6 +16,7 @@ void ImprovSerialComponent::setup() {
global_improv_serial_component = this;
#ifdef USE_ESP32
this->uart_num_ = logger::global_logger->get_uart_num();
this->uart_selection_ = logger::global_logger->get_uart();
#elif defined(USE_ARDUINO)
this->hw_serial_ = logger::global_logger->get_hw_serial();
#endif
@@ -30,21 +31,23 @@ void ImprovSerialComponent::setup() {
}
void ImprovSerialComponent::loop() {
if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
const uint32_t now = App.get_loop_component_start_time();
if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
ESP_LOGV(TAG, "Timeout");
}
auto byte = this->read_byte_();
while (byte.has_value()) {
while (true) {
auto byte = this->read_byte_();
if (!byte.has_value())
break;
if (this->parse_improv_serial_byte_(byte.value())) {
this->last_read_byte_ = millis();
this->last_read_byte_ = now;
} else {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
}
byte = this->read_byte_();
}
if (this->state_ == improv::STATE_PROVISIONING) {
@@ -63,53 +66,6 @@ void ImprovSerialComponent::loop() {
void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); }
optional<uint8_t> ImprovSerialComponent::read_byte_() {
optional<uint8_t> byte;
uint8_t data = 0;
#ifdef USE_ESP32
switch (logger::global_logger->get_uart()) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
#if defined(USE_ESP32_VARIANT_ESP32)
case logger::UART_SELECTION_UART2:
#endif
if (this->uart_num_ >= 0) {
size_t available;
uart_get_buffered_data_len(this->uart_num_, &available);
if (available) {
uart_read_bytes(this->uart_num_, &data, 1, 0);
byte = data;
}
}
break;
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
case logger::UART_SELECTION_USB_CDC:
if (esp_usb_console_available_for_read()) {
esp_usb_console_read_buf((char *) &data, 1);
byte = data;
}
break;
#endif // USE_LOGGER_USB_CDC
#ifdef USE_LOGGER_USB_SERIAL_JTAG
case logger::UART_SELECTION_USB_SERIAL_JTAG: {
if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) {
byte = data;
}
break;
}
#endif // USE_LOGGER_USB_SERIAL_JTAG
default:
break;
}
#elif defined(USE_ARDUINO)
if (this->hw_serial_->available()) {
this->hw_serial_->readBytes(&data, 1);
byte = data;
}
#endif
return byte;
}
void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) {
// First, set length field
this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1;
@@ -133,7 +89,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
this->tx_header_[TX_CHECKSUM_IDX] = checksum;
#ifdef USE_ESP32
switch (logger::global_logger->get_uart()) {
switch (this->uart_selection_) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
#if defined(USE_ESP32_VARIANT_ESP32)
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/improv_base/improv_base.h"
#include "esphome/components/logger/logger.h"
#include "esphome/components/wifi/wifi_component.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
@@ -65,7 +66,52 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
std::vector<uint8_t> build_version_info_();
optional<uint8_t> read_byte_();
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
optional<uint8_t> byte;
uint8_t data = 0;
#ifdef USE_ESP32
switch (this->uart_selection_) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
#if defined(USE_ESP32_VARIANT_ESP32)
case logger::UART_SELECTION_UART2:
#endif
if (this->uart_num_ >= 0) {
size_t available;
uart_get_buffered_data_len(this->uart_num_, &available);
if (available) {
uart_read_bytes(this->uart_num_, &data, 1, 0);
byte = data;
}
}
break;
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
case logger::UART_SELECTION_USB_CDC:
if (esp_usb_console_available_for_read()) {
esp_usb_console_read_buf((char *) &data, 1);
byte = data;
}
break;
#endif
#ifdef USE_LOGGER_USB_SERIAL_JTAG
case logger::UART_SELECTION_USB_SERIAL_JTAG: {
if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) {
byte = data;
}
break;
}
#endif
default:
break;
}
#elif defined(USE_ARDUINO)
if (this->hw_serial_->available()) {
this->hw_serial_->readBytes(&data, 1);
byte = data;
}
#endif
return byte;
}
void write_data_(const uint8_t *data = nullptr, size_t size = 0);
uint8_t tx_header_[TX_BUFFER_SIZE] = {
@@ -85,6 +131,7 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
#ifdef USE_ESP32
uart_port_t uart_num_;
logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0};
#elif defined(USE_ARDUINO)
Stream *hw_serial_{nullptr};
#endif
+3 -3
View File
@@ -7,13 +7,13 @@ namespace esphome::kuntze {
static const char *const TAG = "kuntze";
static const uint8_t CMD_READ_REG = 0x03;
static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832};
// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5)
static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8;
void Kuntze::on_modbus_data(const std::vector<uint8_t> &data) {
void Kuntze::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
auto data = modbus::helpers::server_pdu_payload(response_pdu);
auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); };
this->waiting_ = false;
@@ -76,7 +76,7 @@ void Kuntze::loop() {
if (this->waiting_ || (this->state_ == 0))
return;
this->last_send_ = now;
send(CMD_READ_REG, REGISTER[this->state_ - 1], 2);
this->read_holding_registers(REGISTER[this->state_ - 1], 2);
this->waiting_ = true;
}
+3 -1
View File
@@ -4,6 +4,8 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/modbus/modbus.h"
#include <span>
namespace esphome::kuntze {
class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice {
@@ -19,7 +21,7 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice
void loop() override;
void update() override;
void on_modbus_data(const std::vector<uint8_t> &data) override;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void dump_config() override;
+10 -2
View File
@@ -26,6 +26,7 @@ from esphome.const import (
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from . import gpio # noqa: F401
@@ -506,6 +507,7 @@ async def component_to_code(config):
# it for project source files only. GCC uses the last -O flag.
build_src_flags += " -Os"
cg.add_platformio_option("build_src_flags", build_src_flags)
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
# On other families, patch_linker.py routes .sram.text into the right
# RAM-executable output section and prints a post-link placement summary.
@@ -580,8 +582,13 @@ async def component_to_code(config):
cg.add_platformio_option("custom_fw_name", "esphome")
cg.add_platformio_option("custom_fw_version", __version__)
# Apply chip-specific SDK options to save RAM/Flash
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
# Apply chip-specific SDK options to save RAM/Flash.
# Skipped when bk72xx_ble is configured: add_platformio_option APPENDS list
# values (it never replaces), so emitting the disable here as well would put
# both CFG_SUPPORT_BLE=0 and =1 into the generated sys_config.h and rely on
# last-wins emission order. Skipping keeps it a single unambiguous define.
ble_requested = "bk72xx_ble" in CORE.config
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238) and not ble_requested:
cg.add_platformio_option(
"custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS
)
@@ -605,3 +612,4 @@ def copy_files() -> None:
patch_linker_file,
CORE.relative_build_path("patch_linker.py"),
)
copy_ccache_script()
+12
View File
@@ -219,6 +219,18 @@ LightColorValues LightCall::validate_() {
this->set_flag_(FLAG_HAS_STATE);
}
// A light without brightness control has no way to represent "on but dark", so zero
// brightness -- how effects encode their dark phase -- means the light is off. Clear the
// brightness as well, so a zero can't linger in remote_values and leave the light stuck
// off: a later turn-on can't heal it, because the capability check below drops any
// brightness this mode doesn't support. explicit_turn_off_request was captured above, so
// a running effect is not stopped by this.
if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) {
this->state_ = false;
this->set_flag_(FLAG_HAS_STATE);
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
}
// Make sure a simple (no specific brightness) turn-on makes the light visible
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
this->parent_->remote_values.get_brightness() == 0.0f) {
+48
View File
@@ -0,0 +1,48 @@
"""LN882H BLE — BLE controller support for the LN882H LibreTiny chips.
The platform analog of esp32_ble / rp2040_ble: owns the LN882H BLE stack
bring-up and the controller BLE address. Consumers (ln882h_ble_tracker) build
on this component and contain no SDK calls of their own.
The BLE stack is compiled and linked by the LibreTiny lightning-ln882h builder
when CFG_SUPPORT_BLE=1 is set via custom_options.proj_config#h, which this
component does (LibreTiny v1.13.0+).
"""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["ln882x"]
CODEOWNERS = ["@Bl00d-B0b"]
ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble")
LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LN882HBLE),
# Default off: on the single-core LN882H, bringing the BLE stack up during
# boot competes with the WiFi connection handshake. Consumers enable the
# stack lazily on first use (e.g. the tracker's first scan start).
cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
# Enable the BLE stack in the build. The LibreTiny lightning-ln882h builder
# gates the BLE stack sources and libraries on CFG_SUPPORT_BLE, read from
# the custom option key "proj_config#h" (the '#h' maps to proj_config.h)
# before the stock header's default of 0. A list is used so any other
# component adding to this key concatenates with it rather than replacing
# it (add_platformio_option only merges when both values are lists).
cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"])
cg.add_define("USE_LN882H_BLE")
@@ -0,0 +1,251 @@
// ln882h_ble.cpp
//
// BLE controller support for the LN882H (LibreTiny lightning-ln882h family) —
// the platform analog of esp32_ble / rp2040_ble. Owns the one-time stack
// bring-up (rw_init + the ln_* app init sequence) and the controller BLE
// address (persistent KV entry, WiFi-MAC-derived once). Consumers
// (ln882h_ble_tracker) build on this component and contain no SDK calls of
// their own.
//
// BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE
// stack itself is compiled and linked by the LibreTiny lightning-ln882h builder
// (CFG_SUPPORT_BLE=1 via custom_options.proj_config#h; prebuilt
// libln882h_ble_full_stack.a).
#include "ln882h_ble.h" // pulls esphome/core/defines.h for USE_LN882H_BLE
#ifdef USE_LN882H_BLE
#include <algorithm>
#include <cstddef>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h"
// ---------------------------------------------------------------------------
// LN882H BLE SDK — forward declarations
// ---------------------------------------------------------------------------
extern "C" {
struct ln_bd_addr_v_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name
uint8_t addr[6];
}; // ABI-identical to ln_bd_addr_t
void ln_kv_ble_app_init(void);
struct ln_bd_addr_v_t *ln_kv_ble_pub_addr_get(void);
int ln_kv_ble_addr_store(struct ln_bd_addr_v_t addr);
void soc_module_clk_gate_enable(uint32_t clk);
void rw_init(uint8_t mac[6]);
void ln_gap_app_init(void);
void ln_gatt_app_init(void);
void ln_ble_conn_mgr_init(void);
void ln_ble_evt_mgr_init(void);
void ln_ble_smp_init(void);
void ln_ble_scan_mgr_init(void);
void ln_rw_app_task_init(void);
void ln_gap_reset(void);
void ln_ble_scan_actv_creat(void);
void ln_ble_scan_start(void *scan_param);
void ln_ble_scan_stop(void);
} // extern "C"
// ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is
// passed to ln_kv_ble_addr_store() by value, so its size and alignment are part
// of the calling convention.
static_assert(sizeof(struct ln_bd_addr_v_t) == 6, "ln_bd_addr_v_t must match the SDK's ln_bd_addr_t layout");
static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byte-aligned like the SDK type");
// ---------------------------------------------------------------------------
// LN882H SDK constants
// CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block
// BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports
// GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type /
// gapm_dup_filter_pol / gapm_scan_prop
// ---------------------------------------------------------------------------
static constexpr uint32_t CLK_G_BLE = 1u << 0;
// WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK
// exposes no symbolic name for this register; the address and value replicate
// the SDK reference bring-up. 0x003F sets all six PTI priority bits so the
// arbiter can pre-empt WiFi for BLE traffic.
static constexpr uint32_t BLE_COEX_PTI_REG_ADDR = 0x400121F8;
static constexpr uint32_t BLE_COEX_PTI_ENABLE_ALL = 0x003F;
// ble_app_default_cfg.h BLE_DEFAULT_PUBLIC_ADDR, in the SDK's ln_bd_addr_t
// array order — least-significant octet first, the BLE/HCI convention (the
// SDK's own AT commands print addr[5]..addr[0]). Printable form:
// 00:FF:03:12:34:56.
static constexpr uint8_t BLE_DEFAULT_ADDR[6] = {0x56, 0x34, 0x12, 0x03, 0xFF, 0x00};
// The SDK's KV loader treats an all-zero address as unset and substitutes the
// default; resolve_mac_() applies the same rule to a stored entry.
static bool is_unset_addr(const uint8_t (&addr)[6]) {
return std::all_of(std::begin(addr), std::end(addr), [](uint8_t b) { return b == 0; });
}
// gapm_scan_type: GEN_DISC = 0, LIM_DISC = 1, OBSERVER = 2. Observer reports every
// advertisement without filtering — what a tracker wants.
static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2;
static constexpr uint8_t GAPM_DUP_FILT_DIS = 0;
// gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3.
static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0;
// Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout,
// with the pad byte explicit so the whole block zero-initialises.
struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name
uint8_t type;
uint8_t prop;
uint8_t dup_filt_pol;
uint8_t pad;
uint16_t scan_intv;
uint16_t scan_wd;
};
// Pin the compiler's layout decisions for the hand-mirrored SDK struct: it is
// passed to ln_ble_scan_start() as void*, so a padding drift would silently
// feed garbage scan parameters to the controller.
static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout");
static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t");
// ---------------------------------------------------------------------------
// __sprintf weak stub
//
// The LN882H BLE SDK objects reference __sprintf (a Beken/LN libc alias) that
// LibreTiny's newlib does not provide. Supply a weak fallback so linking
// succeeds; a real definition, if one is ever provided, takes precedence.
// ---------------------------------------------------------------------------
#include <cstdarg>
#include <cstdio>
extern "C" __attribute__((weak)) int
__sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
char *str, const char *format, ...) {
va_list args;
va_start(args, format);
int ret = vsprintf(str, format, args); // NOLINT
va_end(args);
return ret;
}
namespace esphome::ln882h_ble {
static const char *const TAG = "ln882h_ble";
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void LN882HBLE::setup() {
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
// the stack is up. The KV load also happens here (no stack dependency).
this->resolve_mac_();
if (this->enable_on_boot_) {
this->enable();
}
}
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the LN
// SDK's KV subsystem is first touched only once WiFi is up; earlier access
// destabilized the device in hardware testing.
float LN882HBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void LN882HBLE::enable() {
if (this->state_ != BLEComponentState::STATE_OFF)
return;
this->state_ = BLEComponentState::ENABLING;
*reinterpret_cast<volatile uint32_t *>(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL;
soc_module_clk_gate_enable(CLK_G_BLE);
rw_init(this->ble_mac_);
ln_gap_app_init();
ln_gatt_app_init();
ln_ble_conn_mgr_init();
ln_ble_evt_mgr_init();
ln_ble_smp_init();
ln_ble_scan_mgr_init();
ln_rw_app_task_init();
ln_gap_reset();
delay(100); // NOLINT — one-time BLE stack init; SDK requires this settle time
ln_ble_scan_actv_creat();
delay(10);
// Prime the scan activity with a short probe start/stop — the SDK's scan
// manager completes activity creation on the first start.
// static: its address is handed to ln_ble_scan_start(void *), which may
// retain it past this call.
static le_scan_parameters_t probe_p{};
probe_p.type = GAPM_SCAN_TYPE_OBSERVER;
probe_p.prop = GAPM_SCAN_PROP_PHY_1M_BIT;
probe_p.dup_filt_pol = GAPM_DUP_FILT_DIS;
probe_p.scan_intv = 160;
probe_p.scan_wd = 16;
ln_ble_scan_start(&probe_p);
delay(10);
ln_ble_scan_stop();
this->state_ = BLEComponentState::ACTIVE;
ESP_LOGD(TAG, "BLE stack initialised");
}
void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); }
void LN882HBLE::dump_config() {
ESP_LOGCONFIG(TAG,
"LN882H BLE:\n"
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
" Active: %s",
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
this->ble_mac_[0], YESNO(this->is_active()));
}
// ---------------------------------------------------------------------------
// MAC resolution
// ---------------------------------------------------------------------------
void LN882HBLE::resolve_mac_() {
ln_kv_ble_app_init();
// ln_kv_ble_app_init() loads the persistent address from the "2_ble_addr" KV
// entry, falling back to BLE_DEFAULT_ADDR when nothing is stored. A stored
// address is preferred so boot does not write flash. Order is LSB-first
// throughout (BLE/HCI convention); consumers reverse for printable form.
ln_bd_addr_v_t bt_addr{};
bool have_unique_addr = false;
if (const ln_bd_addr_v_t *stored = ln_kv_ble_pub_addr_get(); stored != nullptr) {
bt_addr = *stored;
// All-zero is "unset", not a unique address: ln_kv_ble_addr_load() itself
// substitutes the default for it, so programming it verbatim would give the
// controller a null address. Treat it like the default and derive instead.
have_unique_addr =
memcmp(bt_addr.addr, BLE_DEFAULT_ADDR, sizeof(bt_addr.addr)) != 0 && !is_unset_addr(bt_addr.addr);
} else {
// KV subsystem down (wrong partition layout, corrupted region): derive
// below instead of dereferencing null and boot-looping.
ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC");
}
if (!have_unique_addr) {
uint8_t wifi_mac[6] = {0};
get_mac_address_raw(wifi_mac); // MSB-first
// Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment
// the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the
// Beken/Tuya factory pairing the bk72xx sibling also uses.
for (int i = 0; i < 6; i++)
bt_addr.addr[i] = wifi_mac[5 - i];
bt_addr.addr[0] = static_cast<uint8_t>(bt_addr.addr[0] + 1);
if (int err = ln_kv_ble_addr_store(bt_addr); err != 0) {
ESP_LOGW(TAG, "Failed to persist derived BLE address (err %d); will re-derive next boot", err);
} else {
ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored");
}
}
memcpy(this->ble_mac_, bt_addr.addr, 6);
}
} // namespace esphome::ln882h_ble
#endif // USE_LN882H_BLE
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_LN882H_BLE
#include "esphome/core/component.h"
#include <cstdint>
namespace esphome::ln882h_ble {
enum class BLEComponentState : uint8_t {
STATE_OFF = 0,
ENABLING,
ACTIVE,
};
class LN882HBLE final : public Component {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
/// Bring up the LN882H BLE stack (one-time; the SDK has no teardown path).
/// Requires setup() to have run: rw_init() is handed the address resolved
/// there. Blocks ~120 ms across the SDK's settle points, so calling it from
/// loop() (enable_on_boot: false) trips the loop-blocking warning once.
void enable();
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address in the SDK's ln_bd_addr_t order: least-significant
/// octet first (the BLE/HCI convention). Reverse for printable form — the
/// byte order is in the name so platform analogs cannot be confused
/// (the bk72xx sibling exposes the same accessor).
void get_mac_lsb_first(uint8_t out[6]) const;
protected:
void resolve_mac_();
uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{false};
};
} // namespace esphome::ln882h_ble
#endif // USE_LN882H_BLE
@@ -21,6 +21,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) {
// Single write for everything
fwrite(buffer, 1, pos, stdout);
fflush(stdout);
}
void Logger::pre_setup() { global_logger = this; }
+14 -31
View File
@@ -1,4 +1,3 @@
import functools
import importlib
from pathlib import Path
import pkgutil
@@ -86,7 +85,7 @@ from .schemas import (
any_widget_schema,
container_schema,
container_schema_value,
obj_dict,
theme_schema,
)
from .styles import styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
@@ -215,6 +214,18 @@ def multi_conf_validate(configs: list[dict]):
raise cv.Invalid(
f"'{item}' must have an explicit group set when using multiple LVGL instances"
)
# The hidden styles a `theme:` block creates are tracked in a single map shared
# by all LVGL instances (keyed only by widget type, not by instance), so a
# second instance's `theme:` would silently lose to whichever instance is
# processed first instead of doing what its config implies.
themed_configs = sum(
1 for config in configs if config.get(df.CONF_THEME) is not None
)
if themed_configs > 1:
raise cv.Invalid(
"'theme' may only be set on one LVGL instance when using multiple LVGL "
"instances -- combine both themes into a single instance's 'theme:' block"
)
base_config = configs[0]
for config in configs[1:]:
for item in (
@@ -552,34 +563,6 @@ def add_hello_world(config):
return config
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
# schemas.py for why chained .extend() is avoided here.
return cv.Schema(
{
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
**{
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
},
}
)
def _theme_schema(value: dict) -> dict:
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
FINAL_VALIDATE_SCHEMA = final_validation
# The options accepted at the top level of an `lvgl:` block, on top of the base
@@ -647,7 +630,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_THEME): theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
+9
View File
@@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles"
KEY_REFRESHED_WIDGETS = "refreshed_widgets"
KEY_REMAPPED_USES = "remapped_uses"
KEY_STYLES_USED = "styles_used"
KEY_THEME_UPDATE_REQUESTS = "theme_update_requests"
KEY_THEME_WIDGET_MAP = "theme_widget_map"
KEY_UPDATED_WIDGETS = "updated_widgets"
KEY_WIDGET_MAP = "widget_map"
@@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]:
return _get_data(KEY_THEME_WIDGET_MAP, {})
def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]:
# Values are dicts used as ordered sets (insertion order is deterministic,
# unlike a plain `set` of strings/tuples, whose iteration order depends on
# per-process string hash randomization) so codegen output doesn't churn
# between builds of the same config.
return _get_data(KEY_THEME_UPDATE_REQUESTS, {})
def get_styles_used() -> set[str]:
return _get_data(KEY_STYLES_USED, set())
+93 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable
import functools
from typing import Any
from esphome import config_validation as cv
@@ -8,6 +9,7 @@ from esphome.components.time import RealTimeClock
from esphome.config_validation import prepend_path
from esphome.const import (
CONF_ARGS,
CONF_DEFAULT,
CONF_FORMAT,
CONF_GROUP,
CONF_ID,
@@ -69,7 +71,7 @@ from .types import (
lv_pseudo_button_t,
lv_style_t,
)
from .widgets import WidgetType
from .widgets import WidgetType, collect_parts
# this will be populated later, in __init__.py to avoid circular imports.
WIDGET_TYPES: dict = {}
@@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.Schema:
return schema
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, WidgetType], ...],
include_dark_mode: bool = True,
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() above
# for why chained .extend() is avoided here.
return cv.Schema(
{
**(
{cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean}
if include_dark_mode
else {}
),
**{
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
},
}
)
def _reject_theme_styles_key(validated: dict) -> dict:
"""
`styles:` (a list of already-declared named styles) is not allowed inside
`theme:` -- the hidden style objects theme: creates only carry direct
style properties, so a `styles:` reference there would be silently
dropped by `style_set`, which only walks `ALL_STYLES`.
"""
for w_name, style in validated.items():
if w_name not in WIDGET_TYPES:
continue
for part, states in collect_parts(style).items():
for state, props in states.items():
if df.CONF_STYLES not in props:
continue
path = [w_name]
if part != df.CONF_MAIN:
path.append(part)
if state != CONF_DEFAULT:
path.append(state)
path.append(df.CONF_STYLES)
raise cv.Invalid(
"'styles:' is not allowed in LVGL theme styles. "
"Set style properties directly instead.",
path,
)
return validated
def theme_schema(value: dict) -> dict:
return _reject_theme_styles_key(
_build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
)
def theme_update_schema(value: dict) -> dict:
"""
Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`.
As a validation side effect, records which (widget type, part, state)
combos are targeted so `theme_to_code` can make sure a hidden style
exists for each -- even ones never mentioned under `theme:` -- and gets
it attached to widgets at the same point real theme styles are.
"""
validated = _reject_theme_styles_key(
_build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value)
)
for w_name, style in validated.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds a main/default entry
# even when nothing was set for it (e.g. `{pressed: {...}}`
# alone) -- skip combos with no properties so a request for
# one state doesn't also create an unused, empty main/default
# style that gets attached to every widget of this type.
if not props:
continue
df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = (
None
)
return validated
ALIGN_TO_SCHEMA = {
cv.Optional(df.CONF_ALIGN_TO): cv.Schema(
{
+89 -14
View File
@@ -10,11 +10,18 @@ from .defines import (
LValidator,
add_lv_use,
get_styles_used,
get_theme_update_requests,
get_theme_widget_map,
literal,
)
from .lvcode import LambdaContext, lv
from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property
from .schemas import (
ALL_STYLES,
FULL_STYLE_SCHEMA,
WIDGET_TYPES,
remap_property,
theme_update_schema,
)
from .types import ObjUpdateAction, lv_style_t
from .widgets import collect_parts, wait_for_widgets
@@ -86,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args):
style = await cg.get_variable(config[CONF_ID])
async with LambdaContext(parameters=args, where=action_id) as context:
await style_set(style, config)
# Refresh and redraw every widget using this style -- otherwise the
# updated properties would sit unused until something else happens to
# invalidate the affected widgets.
lv.obj_report_style_change(style)
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
async def theme_to_code(config):
if theme := config.get(CONF_THEME):
add_lv_use(CONF_THEME)
for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES):
# Work around Python 3.10 bug with nested async comprehensions
# With Python 3.11 this could be simplified
# TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions
styles = {}
for part, states in collect_parts(style).items():
styles[part] = {
state: await create_style(
theme = config.get(CONF_THEME) or {}
requests = get_theme_update_requests()
# Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather
# than a set -- a set of strings/tuples iterates in an order that depends on
# per-process hash randomization, which would otherwise churn the order hidden
# style variables are declared in main.cpp between builds of the same config.
widget_names = [
w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests
]
if not widget_names:
return
add_lv_use(CONF_THEME)
theme_map = get_theme_widget_map()
for w_name in widget_names:
declared_parts = collect_parts(theme[w_name]) if w_name in theme else {}
parts = {part: dict(states) for part, states in declared_parts.items()}
for part, state in requests.get(w_name, {}):
parts.setdefault(part, {}).setdefault(state, {})
widget_styles = theme_map.setdefault(w_name, {})
for part, states in parts.items():
part_styles = widget_styles.setdefault(part, {})
declared_states = declared_parts.get(part, {})
for state, props in states.items():
if state not in part_styles:
part_styles[state] = await create_style(
"_lv_theme_style_" + w_name + "_" + part + "_" + state, props
)
for state, props in states.items()
}
get_theme_widget_map()[w_name] = styles
elif state in declared_states:
# A `theme.update` request for this combo (possibly from
# another LVGL instance) already created the style as an
# empty placeholder before this instance's real `theme:`
# declaration was reached -- apply the real values now
# instead of silently leaving it empty.
await style_set(part_styles[state], props)
@automation.register_action(
"lvgl.theme.update",
ObjUpdateAction,
theme_update_schema,
synchronous=True,
)
async def theme_update_to_code(config, action_id, template_arg, args):
await wait_for_widgets()
theme_map = get_theme_widget_map()
# Invariant this relies on: theme_update_schema() records every (widget
# type, part, state) combo this action targets as a request during config
# validation (which completes for the whole config tree before any
# to_code runs), and theme_to_code() -- which runs for every LVGL
# instance before any action's own to_code -- materialises a style for
# each recorded request. If that handshake is ever broken by a future
# change, fail with a diagnosable message rather than a bare KeyError.
to_update = []
for w_name, style in config.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds an (empty) main/default
# entry even when this action didn't target it -- skip it, both
# because there's nothing to update and because
# theme_update_schema no longer pre-creates a placeholder style
# for combos with no properties.
if not props:
continue
style_var = theme_map.get(w_name, {}).get(part, {}).get(state)
if style_var is None:
raise cv.Invalid(
f"No theme style exists for '{w_name}' {part}/{state}. "
"This is an internal error -- please report it."
)
to_update.append((style_var, props))
async with LambdaContext(parameters=args, where=action_id) as context:
for style_var, props in to_update:
await style_set(style_var, props)
# Refresh and redraw every widget using this style -- otherwise the
# updated properties would sit unused until something else happens
# to invalidate the affected widgets.
lv.obj_report_style_change(style_var)
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+1 -1
View File
@@ -209,7 +209,7 @@ async def to_code(config):
ethernet.request_ethernet_ip_state_listener()
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.11.0")
add_idf_component(name="espressif/mdns", ref="1.11.3")
cg.add_define("USE_MDNS")
@@ -0,0 +1,269 @@
# Init sequence sourced from Espressif's esp-bsp repository:
# https://github.com/espressif/esp-bsp/blob/master/bsp/esp_vocat/priv_include/disp_init_data.h
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
from esphome.components.mipi import MODE_RGB, DriverChip
from esphome.components.spi import TYPE_QUAD
from esphome.const import CONF_INVERTED, CONF_NUMBER
# Init sequence for the ST77916 QSPI display on the ESP-VoCat v1.2 board.
# Source: disp_init_data.h from espressif/esp-bsp (bsp/esp_vocat/priv_include).
# The standard INVON/INVOFF and SLPOUT+DISPON commands (and required delays) are omitted because
# the mipi_spi framework appends them automatically based on the invert_colors and no_slpout settings.
# (So this sequence only contains panel-specific setup commands.)
_ESP_VOCAT_INIT = (
# Page 1a — startup unlock
(0xF0, 0x28),
(0xF2, 0x28),
(0x73, 0xF0),
(0x7C, 0xD1),
(0x83, 0xE0),
(0x84, 0x61),
(0xF2, 0x82),
# Switch to page 0 → page 1
(0xF0, 0x00),
(0xF0, 0x01),
(0xF1, 0x01),
# Power settings (Bx)
(0xB0, 0x56),
(0xB1, 0x4D),
(0xB2, 0x24),
(0xB4, 0x87),
(0xB5, 0x44),
(0xB6, 0x8B),
(0xB7, 0x40),
(0xB8, 0x86),
(0xBA, 0x00),
(0xBB, 0x08),
(0xBC, 0x08),
(0xBD, 0x00),
# VCOM / gate settings (Cx)
(0xC0, 0x80),
(0xC1, 0x10),
(0xC2, 0x37),
(0xC3, 0x80),
(0xC4, 0x10),
(0xC5, 0x37),
(0xC6, 0xA9),
(0xC7, 0x41),
(0xC8, 0x01),
(0xC9, 0xA9),
(0xCA, 0x41),
(0xCB, 0x01),
# Source settings (Dx)
(0xD0, 0x91),
(0xD1, 0x68),
(0xD2, 0x68),
# Misc
(0xF5, 0x00, 0xA5),
(0xDD, 0x4F),
(0xDE, 0x4F),
(0xF1, 0x10),
(0xF0, 0x00),
# Switch to page 2 — gamma
(0xF0, 0x02),
(
0xE0,
0xF0,
0x0A,
0x10,
0x09,
0x09,
0x36,
0x35,
0x33,
0x4A,
0x29,
0x15,
0x15,
0x2E,
0x34,
),
(
0xE1,
0xF0,
0x0A,
0x0F,
0x08,
0x08,
0x05,
0x34,
0x33,
0x4A,
0x39,
0x15,
0x15,
0x2D,
0x33,
),
# Switch to page 10 — GIP / timing
(0xF0, 0x10),
(0xF3, 0x10),
# Page 10: Exxx
(0xE0, 0x07),
(0xE1, 0x00),
(0xE2, 0x00),
(0xE3, 0x00),
(0xE4, 0xE0),
(0xE5, 0x06),
(0xE6, 0x21),
(0xE7, 0x01),
(0xE8, 0x05),
(0xE9, 0x02),
(0xEA, 0xDA),
(0xEB, 0x00),
(0xEC, 0x00),
(0xED, 0x0F),
(0xEE, 0x00),
(0xEF, 0x00),
# Page 10: Fxxx
(0xF8, 0x00),
(0xF9, 0x00),
(0xFA, 0x00),
(0xFB, 0x00),
(0xFC, 0x00),
(0xFD, 0x00),
(0xFE, 0x00),
(0xFF, 0x00),
# GIP section A (0x60–0x6B)
(0x60, 0x40),
(0x61, 0x04),
(0x62, 0x00),
(0x63, 0x42),
(0x64, 0xD9),
(0x65, 0x00),
(0x66, 0x00),
(0x67, 0x00),
(0x68, 0x00),
(0x69, 0x00),
(0x6A, 0x00),
(0x6B, 0x00),
# GIP section B (0x70–0x7B)
(0x70, 0x40),
(0x71, 0x03),
(0x72, 0x00),
(0x73, 0x42),
(0x74, 0xD8),
(0x75, 0x00),
(0x76, 0x00),
(0x77, 0x00),
(0x78, 0x00),
(0x79, 0x00),
(0x7A, 0x00),
(0x7B, 0x00),
# GIP timing (0x80–0x9F)
(0x80, 0x48),
(0x81, 0x00),
(0x82, 0x06),
(0x83, 0x02),
(0x84, 0xD6),
(0x85, 0x04),
(0x86, 0x00),
(0x87, 0x00),
(0x88, 0x48),
(0x89, 0x00),
(0x8A, 0x08),
(0x8B, 0x02),
(0x8C, 0xD8),
(0x8D, 0x04),
(0x8E, 0x00),
(0x8F, 0x00),
(0x90, 0x48),
(0x91, 0x00),
(0x92, 0x0A),
(0x93, 0x02),
(0x94, 0xDA),
(0x95, 0x04),
(0x96, 0x00),
(0x97, 0x00),
(0x98, 0x48),
(0x99, 0x00),
(0x9A, 0x0C),
(0x9B, 0x02),
(0x9C, 0xDC),
(0x9D, 0x04),
(0x9E, 0x00),
(0x9F, 0x00),
# GIP timing (0xA0–0xBF)
(0xA0, 0x48),
(0xA1, 0x00),
(0xA2, 0x05),
(0xA3, 0x02),
(0xA4, 0xD5),
(0xA5, 0x04),
(0xA6, 0x00),
(0xA7, 0x00),
(0xA8, 0x48),
(0xA9, 0x00),
(0xAA, 0x07),
(0xAB, 0x02),
(0xAC, 0xD7),
(0xAD, 0x04),
(0xAE, 0x00),
(0xAF, 0x00),
(0xB0, 0x48),
(0xB1, 0x00),
(0xB2, 0x09),
(0xB3, 0x02),
(0xB4, 0xD9),
(0xB5, 0x04),
(0xB6, 0x00),
(0xB7, 0x00),
(0xB8, 0x48),
(0xB9, 0x00),
(0xBA, 0x0B),
(0xBB, 0x02),
(0xBC, 0xDB),
(0xBD, 0x04),
(0xBE, 0x00),
(0xBF, 0x00),
# Source timing (0xC0–0xC9)
(0xC0, 0x10),
(0xC1, 0x47),
(0xC2, 0x56),
(0xC3, 0x65),
(0xC4, 0x74),
(0xC5, 0x88),
(0xC6, 0x99),
(0xC7, 0x01),
(0xC8, 0xBB),
(0xC9, 0xAA),
# Source timing (0xD0–0xD9)
(0xD0, 0x10),
(0xD1, 0x47),
(0xD2, 0x56),
(0xD3, 0x65),
(0xD4, 0x74),
(0xD5, 0x88),
(0xD6, 0x99),
(0xD7, 0x01),
(0xD8, 0xBB),
(0xD9, 0xAA),
# Finalise page 10, return to page 0
(0xF3, 0x01),
(0xF0, 0x00),
# INVON (0x21) and SLPOUT (0x11) are appended by the framework.
)
DriverChip(
"ESP-VOCAT",
width=360,
height=360,
# SPI pins for the ESP-VoCat v1.2 board.
# PCLK (GPIO18) and data pins (GPIO46/13/11/12) are configured on the spi: bus.
cs_pin=14,
# RST is active-HIGH on this panel; invert the ESPHome pin so the framework's
# active-low reset pulse (HIGH→LOW→HIGH) maps to the correct physical sequence
# (LOW→HIGH→LOW) on the wire.
reset_pin={CONF_NUMBER: 47, CONF_INVERTED: True},
# Note: GPIO9 behaviour varies by board revision (may be POWER_CTRL, not LCD_EN).
# Do not set a default enable_pin — manage LCD power in on_boot if needed.
# GPIO44 is the backlight; manage it separately via an output: or light:.
bus_mode=TYPE_QUAD,
data_rate="80MHz",
invert_colors=True,
color_order=MODE_RGB,
requires={"psram"},
initsequence=_ESP_VOCAT_INIT,
)
@@ -203,6 +203,54 @@ AXS15231.extend(
requires={"psram"},
)
# Init sequence and pins taken from the vendor demo:
# "ESP32-S3-Touch-LCD-3.5B-Demo/Arduino/examples/09_lvgl_arduino_v8"
# The panel has no reset line (demo passes RST = -1); the sleep-out, display-on
# and first RAM write are issued by the framework, so they are omitted here.
# fmt: off
AXS15231.extend(
"WAVESHARE-ESP32-S3-TOUCH-LCD-3.5B",
width=320,
height=480,
# Vendor demo runs the AXS15231B at 32MHz; the ESP32 SPI clock cannot hit
# that exactly, data sheet says max 50MHz, so use 40MHz (proven on the same controller, JC3248W535)
data_rate="40MHz",
cs_pin=12,
requires={"psram"},
initsequence=(
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5),
(0xA0, 0xC0, 0x10, 0x00, 0x02, 0x00, 0x00, 0x04, 0x3F, 0x20, 0x05, 0x3F, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00),
(0xA2, 0x30, 0x3C, 0x24, 0x14, 0xD0, 0x20, 0xFF, 0xE0, 0x40, 0x19, 0x80, 0x80, 0x80, 0x20, 0xF9, 0x10, 0x02, 0xFF, 0xFF, 0xF0, 0x90, 0x01, 0x32, 0xA0, 0x91, 0xE0, 0x20, 0x7F, 0xFF, 0x00, 0x5A),
(0xD0, 0xE0, 0x40, 0x51, 0x24, 0x08, 0x05, 0x10, 0x01, 0x20, 0x15, 0xC2, 0x42, 0x22, 0x22, 0xAA, 0x03, 0x10, 0x12, 0x60, 0x14, 0x1E, 0x51, 0x15, 0x00, 0x8A, 0x20, 0x00, 0x03, 0x3A, 0x12),
(0xA3, 0xA0, 0x06, 0xAA, 0x00, 0x08, 0x02, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x55, 0x55),
(0xC1, 0x31, 0x04, 0x02, 0x02, 0x71, 0x05, 0x24, 0x55, 0x02, 0x00, 0x41, 0x00, 0x53, 0xFF, 0xFF, 0xFF, 0x4F, 0x52, 0x00, 0x4F, 0x52, 0x00, 0x45, 0x3B, 0x0B, 0x02, 0x0D, 0x00, 0xFF, 0x40),
(0xC3, 0x00, 0x00, 0x00, 0x50, 0x03, 0x00, 0x00, 0x00, 0x01, 0x80, 0x01),
(0xC4, 0x00, 0x24, 0x33, 0x80, 0x00, 0xEA, 0x64, 0x32, 0xC8, 0x64, 0xC8, 0x32, 0x90, 0x90, 0x11, 0x06, 0xDC, 0xFA, 0x00, 0x00, 0x80, 0xFE, 0x10, 0x10, 0x00, 0x0A, 0x0A, 0x44, 0x50),
(0xC5, 0x18, 0x00, 0x00, 0x03, 0xFE, 0x3A, 0x4A, 0x20, 0x30, 0x10, 0x88, 0xDE, 0x0D, 0x08, 0x0F, 0x0F, 0x01, 0x3A, 0x4A, 0x20, 0x10, 0x10, 0x00),
(0xC6, 0x05, 0x0A, 0x05, 0x0A, 0x00, 0xE0, 0x2E, 0x0B, 0x12, 0x22, 0x12, 0x22, 0x01, 0x03, 0x00, 0x3F, 0x6A, 0x18, 0xC8, 0x22),
(0xC7, 0x50, 0x32, 0x28, 0x00, 0xA2, 0x80, 0x8F, 0x00, 0x80, 0xFF, 0x07, 0x11, 0x9C, 0x67, 0xFF, 0x24, 0x0C, 0x0D, 0x0E, 0x0F),
(0xC9, 0x33, 0x44, 0x44, 0x01),
(0xCF, 0x2C, 0x1E, 0x88, 0x58, 0x13, 0x18, 0x56, 0x18, 0x1E, 0x68, 0x88, 0x00, 0x65, 0x09, 0x22, 0xC4, 0x0C, 0x77, 0x22, 0x44, 0xAA, 0x55, 0x08, 0x08, 0x12, 0xA0, 0x08),
(0xD5, 0x40, 0x8E, 0x8D, 0x01, 0x35, 0x04, 0x92, 0x74, 0x04, 0x92, 0x74, 0x04, 0x08, 0x6A, 0x04, 0x46, 0x03, 0x03, 0x03, 0x03, 0x82, 0x01, 0x03, 0x00, 0xE0, 0x51, 0xA1, 0x00, 0x00, 0x00),
(0xD6, 0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x93, 0x00, 0x01, 0x83, 0x07, 0x07, 0x00, 0x07, 0x07, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0x84, 0x00, 0x20, 0x01, 0x00),
(0xD7, 0x03, 0x01, 0x0B, 0x09, 0x0F, 0x0D, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19, 0x40, 0x8E, 0x04, 0x00, 0x20, 0xA0, 0x1F),
(0xD8, 0x02, 0x00, 0x0A, 0x08, 0x0E, 0x0C, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19),
(0xD9, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F),
(0xDD, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F),
(0xDF, 0x44, 0x73, 0x4B, 0x69, 0x00, 0x0A, 0x02, 0x90),
(0xE0, 0x3B, 0x28, 0x10, 0x16, 0x0C, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x13, 0x2C, 0x33, 0x28, 0x0D),
(0xE1, 0x37, 0x28, 0x10, 0x16, 0x0B, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x14, 0x2C, 0x33, 0x28, 0x0F),
(0xE2, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D),
(0xE3, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x32, 0x2F, 0x0F),
(0xE4, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D),
(0xE5, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0F),
(0xA4, 0x85, 0x85, 0x95, 0x82, 0xAF, 0xAA, 0xAA, 0x80, 0x10, 0x30, 0x40, 0x40, 0x20, 0xFF, 0x60, 0x30),
(0xA4, 0x85, 0x85, 0x95, 0x85),
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
),
)
# fmt: on
# Waveshare 1.83-v2
#
# Do not use on 1.83-v1: Vendor warning on different chip!
+18 -17
View File
@@ -3,33 +3,34 @@ import esphome.codegen as cg
modbus_ns = cg.esphome_ns.namespace("modbus")
modbus_helpers_ns = modbus_ns.namespace("helpers")
ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode")
ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode")
FunctionCode_ns = modbus_ns.namespace("FunctionCode")
FunctionCode = FunctionCode_ns.enum("FunctionCode")
MODBUS_FUNCTION_CODE = {
"read_coils": ModbusFunctionCode.READ_COILS,
"read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS,
"read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS,
"read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS,
"write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL,
"write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER,
"write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS,
"write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS,
"read_coils": FunctionCode.READ_COILS,
"read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS,
"read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS,
"read_input_registers": FunctionCode.READ_INPUT_REGISTERS,
"write_single_coil": FunctionCode.WRITE_SINGLE_COIL,
"write_single_register": FunctionCode.WRITE_SINGLE_REGISTER,
"write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS,
"write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS,
}
ModbusRegisterType_ns = modbus_ns.namespace("ModbusRegisterType")
ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType")
EntityType_ns = modbus_ns.namespace("EntityType")
EntityType = EntityType_ns.enum("EntityType")
MODBUS_WRITE_REGISTER_TYPE = {
"custom": ModbusRegisterType.CUSTOM,
"coil": ModbusRegisterType.COIL,
"holding": ModbusRegisterType.HOLDING,
"custom": EntityType.CUSTOM,
"coil": EntityType.COIL,
"holding": EntityType.HOLDING,
}
MODBUS_REGISTER_TYPE = {
**MODBUS_WRITE_REGISTER_TYPE,
"discrete_input": ModbusRegisterType.DISCRETE_INPUT,
"read": ModbusRegisterType.INPUT_REGISTER,
"discrete_input": EntityType.DISCRETE_INPUT,
"read": EntityType.INPUT_REGISTER,
"input": EntityType.INPUT_REGISTER,
}
SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType")
+522 -187
View File
@@ -45,26 +45,46 @@ void Modbus::loop() {
}
void ModbusClientHub::loop() {
// Call base class to receive bytes and parse frames
this->Modbus::loop();
// Drain anything owed since the last loop (e.g. an external clear) before the watchdog runs, so it
// never times out an entry whose pending count has not been drained. No-op when nothing is owed.
this->sweep_();
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response
if (this->waiting_for_response_.has_value()) {
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.data()[0];
if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ &&
(this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address,
this->last_receive_check_ - this->last_send_);
this->notify_no_response_(wfr);
this->waiting_for_response_.reset();
}
this->Modbus::loop(); // receive bytes and parse frames
// Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the
// entry up and holds off if the response has started arriving.
if (this->waiting_for_response_ &&
this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) {
this->expire_waiting_();
}
// If there's no response pending and there's commands in the buffer
this->sweep_(); // deliver owed callbacks with the hub quiescent
this->send_next_frame_();
}
void ModbusClientHub::expire_waiting_() {
ModbusDeviceCommand *cmd = this->find_waiting_();
if (cmd == nullptr) {
this->waiting_for_response_ = false;
return;
}
if (!this->rx_buffer_.empty() && this->rx_buffer_[0] == cmd->frame.address()) {
// The start of the response is in the buffer: let the frame finish arriving.
return;
}
// Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected).
if (cmd->state == FrameState::WAITING) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(),
this->last_receive_check_ - this->last_send_);
}
// Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry
// lands in TIMED_OUT and the following sweep reschedules a retry or erases it. Free the
// wire first so a resend from inside the callback sees it available.
this->waiting_for_response_ = false;
this->sweep_needed_ = true;
cmd->timed_out();
}
bool Modbus::timeout_() {
// If the response frame is finished (including interframe delay) - we timeout.
// The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts
@@ -110,12 +130,21 @@ bool Modbus::tx_blocked() {
bool ModbusClientHub::tx_blocked() {
// We block transmission in any of these case:
// 1. We're waiting for a response
// 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED)
// 2. Any of the base class tx_blocked conditions
return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked();
return this->waiting_for_response_ || this->Modbus::tx_blocked();
}
bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); }
bool ModbusClientHub::tx_buffer_empty() {
// "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in
// other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous
// poll does not count either, since it ranks below every one-shot, so a new send goes out first.
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY && !cmd.continuous)
return false;
}
return true;
}
void Modbus::receive_bytes_() {
this->last_receive_check_ = millis();
@@ -214,11 +243,10 @@ bool Modbus::parse_modbus_server_frame_() {
// Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply
// synchronously. We can safely point directly into rx_buffer_ and avoid a copy.
uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
const uint8_t *data = this->rx_buffer_.data() + data_offset;
uint16_t data_len = frame_length - 2 - data_offset;
// The PDU is the frame without the leading address and the trailing CRC.
std::span<const uint8_t> pdu(this->rx_buffer_.data() + 1, frame_length - 3);
this->process_modbus_server_frame(address, function_code, data, data_len);
this->process_modbus_server_frame(address, pdu);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
return true;
@@ -258,63 +286,61 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
return true;
}
void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) {
if (!this->waiting_for_response_.has_value()) {
// The parser (parse_modbus_server_frame_) guarantees the bounds relied on here: pdu is never empty,
// and an exception-flagged pdu is at least 2 bytes. Keep that in mind when changing server_pdu_length().
void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) {
const uint8_t function_code = pdu[0];
ModbusDeviceCommand *cmd = this->waiting_for_response_ ? this->find_waiting_() : nullptr;
if (cmd == nullptr) {
ESP_LOGW(TAG,
"Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send",
address, function_code, this->last_modbus_byte_ - this->last_send_);
return;
} else { // We are waiting for a response
// Check if the response matches the expected address and function code
}
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.data()[0];
uint8_t expected_function_code = wfr.frame.data.data()[1];
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
ESP_LOGW(TAG,
"Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32
"ms after last send",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
// Invalidate the device; the entry survives as an interrupted shell so the late response is ignored.
// A retry requested here stays queued behind the shell until the send-wait timeout clears it.
this->notify_no_response_(wfr);
wfr.interrupted = true;
return;
}
// Check if the response matches the expected address and function code
const uint8_t expected_address = cmd->frame.address();
const uint8_t expected_function_code = cmd->frame.pdu()[0];
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
ESP_LOGW(TAG,
"Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32
"ms after last send",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
// Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this
// transaction and blocks tx until the send-wait timeout, where it gets its on_no_response.
cmd->interrupt();
return;
}
if (wfr.interrupted) {
ESP_LOGW(TAG,
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
"ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
return;
} else { // We have a valid device waiting for this response
if (cmd->state == FrameState::INTERRUPTED || cmd->state == FrameState::INTERRUPTED_RETIRED) {
// An interrupted shell keeps blocking until the send-wait timeout; a late response for it is
// ignored and does NOT free the wire. The distrust survives a clear (INTERRUPTED_RETIRED), so a
// cleared-interrupted frame still ends in on_no_response rather than delivering a late response.
ESP_LOGW(TAG,
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
"ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
return;
}
ModbusClientDevice *device = wfr.device;
this->waiting_for_response_.reset();
// Is it an error response?
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = len > 0 ? data[0] : 0;
ESP_LOGW(TAG,
"Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send",
function_code, exception, address, this->last_modbus_byte_ - this->last_send_);
if (device)
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else if (device) { // Not an error response
// on_modbus_data is existing public API taking const std::vector<uint8_t>&
device->on_modbus_data(std::vector<uint8_t>(data, data + len));
} else { // Not an error response, but no device to respond to
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
}
}
// Deliver at parse time so the response span can point into the rx buffer (zero copy). error()/
// response() set the state and consume the request BEFORE the callback, so a clear from inside it
// ("stop polling now") wins. A device-less shell runs no callback and the sweep erases it.
this->waiting_for_response_ = false;
this->sweep_needed_ = true;
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present
ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send",
function_code, exception, address, this->last_modbus_byte_ - this->last_send_);
cmd->error(static_cast<ExceptionCode>(exception));
} else if (!cmd->response(pdu)) {
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address,
this->last_modbus_byte_ - this->last_send_);
}
}
void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) {
void ModbusServerHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t>) {
if (this->find_device_(address) != nullptr) {
ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
}
@@ -344,7 +370,7 @@ bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_co
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
number_of_registers);
this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS);
return false;
}
return true;
@@ -363,22 +389,22 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
const uint8_t *response_data = response_buffer;
uint16_t response_len = 0;
switch (static_cast<ModbusFunctionCode>(function_code)) {
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS: {
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
// PDU data: start address(2) + quantity(2).
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data, 2);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
return;
}
RegisterValues registers;
if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) {
if (static_cast<FunctionCode>(function_code) == FunctionCode::READ_HOLDING_REGISTERS) {
status = device->on_read_holding_registers(start_address, number_of_registers, registers);
} else {
status = device->on_read_input_registers(start_address, number_of_registers, registers);
@@ -393,7 +419,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
if (registers.size() != number_of_registers) {
ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size());
this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return;
}
@@ -405,8 +431,8 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
}
break;
}
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: {
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
// PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values.
// A single-register write always targets one register; for a multiple-register write the
// quantity is in the frame and its byte count must equal quantity * 2. The register values are
@@ -414,7 +440,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = 1;
uint16_t values_offset = 2; // single write: values follow the 2-byte start address
if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) {
number_of_registers = helpers::get_data<uint16_t>(data, 2);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data, 4);
values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1)
@@ -422,7 +448,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
number_of_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers,
number_of_bytes);
this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
@@ -441,7 +467,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
}
default:
ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code);
this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION);
return;
}
if (status.has_value()) {
@@ -451,21 +477,20 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
}
}
// Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check
// after it and refuse (return false) if a byte arrived in that window rather than transmit over it.
bool Modbus::send_frame_(const ModbusFrame &frame) {
if (this->tx_blocked()) {
ESP_LOGE(TAG, "Attempted to send while transmission blocked");
return false;
}
if (frame.size() > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE);
return false;
}
const int32_t tx_delay_remaining = this->tx_delay_remaining();
if (tx_delay_remaining > 0) {
delay(tx_delay_remaining);
}
// The delay above can span several ms; a byte arriving in that window blocks transmission after the
// caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry.
if (this->tx_blocked()) {
return false;
}
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->digital_write(true);
this->write_array(frame.data.data(), frame.size());
@@ -489,28 +514,21 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
}
void ModbusClientHub::send_next_frame_() {
if (this->tx_buffer_.empty()) {
if (this->tx_blocked())
return;
ModbusDeviceCommand *cmd = this->select_next_ready_();
if (cmd == nullptr)
return;
if (!this->send_frame_(cmd->frame)) {
ESP_LOGV(TAG, "Send deferred for %" PRIu8 ": a frame arrived during the send delay, will retry",
cmd->frame.address());
return;
}
if (this->tx_blocked()) {
return;
}
ModbusDeviceCommand &command = this->tx_buffer_.front();
if (this->send_frame_(command.frame)) {
this->waiting_for_response_ = std::move(command);
} else {
if (command.device)
command.device->on_modbus_not_sent();
}
this->tx_buffer_.pop_front();
if (!this->tx_buffer_.empty()) {
ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size());
}
cmd->sent();
this->waiting_for_response_ = true;
}
void ModbusClientHub::dump_config() {
@@ -553,7 +571,7 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con
this->send_raw_(raw_frame, payload_len + 2);
}
void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) {
void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) {
uint8_t raw_frame[3];
raw_frame[0] = address;
raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK;
@@ -561,93 +579,261 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo
this->send_raw_(raw_frame, 3);
}
ModbusDeviceCommand *ModbusClientHub::find_waiting_() {
for (auto &cmd : this->tx_buffer_) {
if (cmd.waiting_state())
return &cmd;
}
return nullptr;
}
ModbusDeviceCommand *ModbusClientHub::select_next_ready_() {
// Class first (WRITE, then one-shot READ, then CONTINUOUS), oldest within a class. seq is a
// free-running counter, so compare each entry's AGE against it (correct across the full range).
const uint16_t now = this->next_seq_;
const auto age = [now](const ModbusDeviceCommand &cmd) -> uint16_t { return now - cmd.seq; };
const auto older = [&age](const ModbusDeviceCommand &a, const ModbusDeviceCommand &b) { return age(a) > age(b); };
ModbusDeviceCommand *best = nullptr;
for (auto &cmd : this->tx_buffer_) {
if (cmd.state != FrameState::READY)
continue;
if (best == nullptr || cmd.priority() > best->priority() ||
(cmd.priority() == best->priority() && older(cmd, *best))) {
best = &cmd;
}
}
return best;
}
bool ModbusDeviceCommand::sent() {
this->state = FrameState::WAITING;
// on_sent() is not a terminal, so nothing is consumed.
if (this->device == nullptr)
return false;
this->device->on_sent(this->frame.pdu());
return true;
}
bool ModbusDeviceCommand::notify_retired() {
if (!this->decrement_pending())
return false; // nothing owed - stop the sweep draining this entry
if (this->device != nullptr)
this->device->on_not_sent(this->frame.pdu());
return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero
}
bool ModbusDeviceCommand::response(std::span<const uint8_t> response_pdu) {
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE;
// A continuous poll is never consumed by its own response; a one-shot consumes one request here.
if (!this->continuous)
this->decrement_pending();
if (this->device == nullptr)
return false;
this->device->on_response(this->frame.pdu(), response_pdu);
return true;
}
bool ModbusDeviceCommand::error(ExceptionCode exception_code) {
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_EXCEPTION;
// An exception ends a continuous poll too, so decrement unconditionally.
this->decrement_pending();
if (this->device == nullptr)
return false;
this->device->on_error(this->frame.pdu(), exception_code);
return true;
}
bool ModbusDeviceCommand::interrupt() {
// An unexpected frame distrusts the transaction. A cleared-but-still-waiting shell distrusts too, so
// the interrupt survives the clear in either order (WAITING_RETIRED -> INTERRUPTED_RETIRED).
if (this->state == FrameState::WAITING) {
this->state = FrameState::INTERRUPTED;
return true;
}
if (this->state == FrameState::WAITING_RETIRED) {
this->state = FrameState::INTERRUPTED_RETIRED;
return true;
}
return false;
}
bool ModbusDeviceCommand::timed_out() {
this->state = FrameState::TIMED_OUT; // advance BEFORE the callback so a clear from inside it wins
this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1)
if (this->device == nullptr)
return false; // resolved, no one to tell
if (this->device->on_no_response(this->frame.pdu()))
this->increment_pending(); // granted retry = re-request (capped)
return true;
}
void ModbusClientHub::sweep_() {
if (!this->sweep_needed_)
return;
this->sweep_needed_ = false;
// Serve only the entries present now: a callback may append (a re-send), but those sit beyond
// work_set and are left for the next sweep, which bounds the work and is the termination argument.
// Entries leave the container only in the erase pass below, so indices/references stay valid.
const size_t work_set = this->tx_buffer_.size();
// Restart the walk after every callback: a handler may have moved any entry to any state.
bool callback_ran = true;
while (callback_ran) {
callback_ran = false;
for (size_t i = 0; i != work_set && !callback_ran; i++) {
ModbusDeviceCommand &cmd = this->tx_buffer_[i];
switch (cmd.state) {
case FrameState::RECEIVED_RESPONSE:
case FrameState::RECEIVED_EXCEPTION:
case FrameState::TIMED_OUT:
// Off the wire, callback already delivered: reschedule what is still pending, else erase.
if (cmd.pending)
cmd.requeue(this->next_seq_++);
break;
case FrameState::RETIRED:
// Owes one on_not_sent() per accepted request; notify_retired() consumes one and reports
// whether a debt remained, so the restart loop drains the entry to zero - even a device-less
// shell with pending > 1 (no callback fires, but it still drains rather than stranding).
callback_ran = cmd.notify_retired();
break;
case FrameState::WAITING_RETIRED:
case FrameState::INTERRUPTED_RETIRED:
// Cleared shell: drain only the un-run duplicates; the request in flight keeps pending 1
// and gets its usual callback when it resolves.
if (cmd.pending > 1)
callback_ran = cmd.notify_retired();
break;
default: // READY / WAITING / INTERRUPTED: idle or waiting for a response, nothing owed until the timeout
break;
}
}
}
// Erase pass: the only place entries leave the container. Storage order carries no meaning, so a
// finished entry is swap-and-popped; walking backwards means a moved-down entry is already seen.
for (size_t i = this->tx_buffer_.size(); i-- > 0;) {
const ModbusDeviceCommand &cmd = this->tx_buffer_[i];
// pending == 0 is erasable, but shells still waiting for a response are exempt until it resolves.
if (cmd.pending != 0 || cmd.waiting_state())
continue;
if (i + 1 != this->tx_buffer_.size())
this->tx_buffer_[i] = std::move(this->tx_buffer_.back());
this->tx_buffer_.pop_back();
}
}
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) {
if (wfr.device == nullptr)
return;
const bool retry = wfr.device->on_modbus_no_response();
// The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach
// over the retry request rather than re-queueing a frame that can no longer be routed.
if (retry && wfr.device != nullptr)
this->requeue_waiting_frame_(wfr);
// The old transaction is over either way; never deliver anything else to the device through it.
wfr.device = nullptr;
}
bool ModbusClientHub::send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device,
CommandOptions options) {
// Requests refused here never enter the machine and get no callback - the false return is it.
if (pdu.empty()) {
ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address);
return false;
}
// Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit.
if (pdu.size() > MAX_PDU_SIZE) {
ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size());
return false;
}
void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) {
const ModbusFrame &frame = wfr.frame;
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE;
bool continuous = false;
if (options.continuous) {
if (mutates) {
ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
} else {
continuous = true;
}
}
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
// entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a
// poll -> downgrade the poll to one-shot; both one-shots -> pending++ below the cap, else refused.
for (auto &item : this->tx_buffer_) {
if (item.state == FrameState::RETIRED || item.state == FrameState::WAITING_RETIRED ||
item.state == FrameState::INTERRUPTED_RETIRED)
continue; // cleared, on their way out: a new identical send queues fresh, never absorbs
if (item.device != device || !item.same_frame(address, pdu))
continue;
if (device == nullptr) {
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]);
if (requeueable) {
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
} else {
ESP_LOGW(TAG,
"Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped - register a "
"device for delivery accounting",
address, pdu[0]);
}
return false; // dropped: no entry, no callbacks - the refusal is the return value
}
if (continuous) {
item.make_continuous(true);
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
} else if (item.continuous) {
// A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this
// request, then stops (mirrors continuous incoming converting a one-shot the other way).
item.make_continuous(false);
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", downgraded from continuous to one-shot", address);
} else if (!item.increment_pending()) {
// At the servable cap, so refused. (An absorbed duplicate leaves seq alone - the entry keeps
// its place in line, held by its oldest outstanding request.)
ESP_LOGD(TAG, "Frame already active for %" PRIu8 " with %" PRIu8 " requests pending, refused", address,
item.pending);
return false;
} else {
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address,
item.pending);
}
return true;
}
// Backstop counts every entry; dead ones are gone by the sweep's end, so at worst they cost one
// refusal at the very cap for one loop.
if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]);
if (wfr.device != nullptr)
wfr.device->on_modbus_not_sent();
return;
}
// Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell.
this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3);
}
void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
if (pdu_len == 0) {
if (device)
device->on_modbus_not_sent();
return;
}
if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
this->tx_buffer_.emplace_back(device, address, pdu, pdu_len);
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
if (device)
device->on_modbus_not_sent();
ESP_LOGE(TAG, "Write buffer full, refused: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
return true;
}
void ModbusClientHub::clear_tx_queue_for_address(uint8_t address) {
// A clear is a pure state flip; the sweep delivers every owed on_not_sent() from a quiescent hub.
for (auto &cmd : this->tx_buffer_) {
if (cmd.frame.address() != address)
continue;
cmd.retire();
this->sweep_needed_ = true;
}
}
void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) {
// Remove any pending commands for this address from the tx buffer
auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(
std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }),
tx_buffer.end());
if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().frame.data.data()[0] == address) {
ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address);
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
}
}
void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) {
// Remove any pending commands for this address from the tx buffer
auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }),
tx_buffer.end());
if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().device == device) {
ESP_LOGV(TAG, "Clearing waiting for response");
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
// Silent teardown (supersede semantics): the caller's own frames vanish without callbacks; see
// the lifecycle note on ModbusClientDevice.
for (auto &cmd : this->tx_buffer_) {
if (cmd.device != device)
continue;
cmd.silent_retire();
this->sweep_needed_ = true;
}
}
void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device) {
if (payload.size() < 2) {
if (device)
device->on_modbus_not_sent();
ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused");
return;
}
this->queue_raw_(payload[0], payload.data() + 1, static_cast<uint16_t>(payload.size() - 1), device);
this->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
}
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
@@ -660,23 +846,26 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
return;
}
// In the rare case that the server is blocked (frame delay has not elapsed), we delay the send.
// This should only happen at low baud rates with long frame delays.
// If blocked now (frame delay not elapsed at low baud, or a frame arriving), defer rather than
// busy-waiting the loop; send_frame_ itself re-checks after its delay, so the deferred callback
// just reports whatever it returns.
if (this->tx_blocked()) {
// Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame
// without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures
// only one deferred send is pending, so a single buffer is sufficient.
// without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices.
std::memcpy(this->deferred_payload_.data(), payload, len);
this->deferred_payload_len_ = len;
this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
this->send_frame_(frame);
if (!this->send_frame_(frame))
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
});
} else {
ModbusFrame frame(payload[0], payload + 1, len - 1);
this->send_frame_(frame);
return;
}
ModbusFrame frame(payload[0], payload + 1, len - 1);
if (!this->send_frame_(frame))
ESP_LOGE(TAG, "Server reply dropped: a frame arrived during the send delay");
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
@@ -699,4 +888,150 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t
}
}
void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) {
if (request_pdu.empty())
return;
auto function_code = static_cast<FunctionCode>(request_pdu[0]);
// All standard requests handled below are function code + start address + count/value (5 bytes);
// anything shorter cannot be parsed and is handed to the catch-all.
if (request_pdu.size() < READ_PDU_SIZE) {
this->on_custom_response(request_pdu, response_pdu, status);
return;
}
const uint16_t start_address = helpers::get_data<uint16_t>(request_pdu.data(), 1);
// count for reads/multi-writes, value for single writes
const uint16_t count_or_value = helpers::get_data<uint16_t>(request_pdu.data(), 3);
// Gatekeeper for the typed dispatch below: anything that is not a standard-conformant transaction is
// handed to on_custom_response() with the raw PDUs, so the decode cases can trust every length, byte
// count, and quantity field without re-clamping.
// - The REQUEST must be standard: nothing upstream validates a caller-built request PDU, so its
// internal byte count, quantity, and address range are checked here (is_client_pdu_standard()).
// - On success, the RESPONSE must be standard (self-consistent; the frame parser already guarantees
// most of this, but the check keeps the safety proof local), and a read response's length must also
// match the REQUESTED count - the per-PDU checks cannot see that relationship, and a short but
// self-consistent response must be diverted, never silently clamped and delivered as complete.
// - On failure (status engaged) the response is empty by design (see on_error()), so only the request
// is validated.
bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size());
if (!custom && !status.has_value()) {
custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size());
if (!custom && helpers::is_function_code_read(static_cast<uint8_t>(function_code))) {
const bool bits =
function_code == FunctionCode::READ_COILS || function_code == FunctionCode::READ_DISCRETE_INPUTS;
const size_t expected_data_size =
bits ? packed_bit_bytes(count_or_value) : static_cast<size_t>(count_or_value) * 2;
if (response_pdu.size() != expected_data_size + 2) {
ESP_LOGD(TAG, "Response length %zu does not match request (expected %zu) for function code 0x%X",
response_pdu.size(), expected_data_size + 2, static_cast<uint8_t>(function_code));
custom = true;
}
}
}
if (custom) {
this->on_custom_response(request_pdu, response_pdu, status);
return;
}
switch (function_code) {
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
// Decode the big-endian register words into host byte order. The gate guarantees a success response
// carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the
// capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On
// failure the registers span is empty.
RegisterValues registers;
if (!status.has_value()) {
for (size_t i = 0; i != count_or_value; i++) {
registers.push_back(helpers::get_data<uint16_t>(response_pdu.data(), 2 + 2 * i));
}
}
std::span<const uint16_t> register_span(registers.data(), registers.size());
if (function_code == FunctionCode::READ_HOLDING_REGISTERS) {
this->on_read_holding_registers(start_address, register_span, status);
} else {
this->on_read_input_registers(start_address, register_span, status);
}
break;
}
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS: {
// Deliver the bits packed as on the wire; the gate guarantees a success response carries exactly
// (count_or_value + 7) / 8 data bytes. On failure the view is empty AND the count is zero -
// PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them.
std::span<const uint8_t> packed_bytes;
uint16_t count = 0;
if (!status.has_value()) {
packed_bytes = response_pdu.subspan(2);
count = count_or_value;
}
PackedBits bits(packed_bytes, count);
if (function_code == FunctionCode::READ_COILS) {
this->on_read_coils(start_address, bits, status);
} else {
this->on_read_discrete_inputs(start_address, bits, status);
}
break;
}
// Single-write acks echo the value: on success that echo is device-confirmed state - the one
// write whose acknowledgement carries a real read-back - so it is preferred over the request
// copy. On an exception the response has no value and the request copy is the only one.
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_SINGLE_COIL: {
const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE)
? helpers::get_data<uint16_t>(response_pdu.data(), 3)
: count_or_value;
if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) {
this->on_write_single_register(start_address, value, status);
} else {
this->on_write_single_coil(start_address, value == 0xFF00, status);
}
break;
}
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
// Request layout: [0] function code, [1..2] start address, [3..4] register count, [5] byte count,
// [6..] register data. The gate guarantees the request carries exactly count_or_value registers
// (<= MAX_NUM_OF_REGISTERS_TO_WRITE, within RegisterValues capacity). Decoded from the request and
// delivered regardless of status - see the write-acknowledgement note in modbus.h.
RegisterValues registers;
for (size_t i = 0; i != count_or_value; i++) {
registers.push_back(helpers::get_data<uint16_t>(request_pdu.data(), 6 + 2 * i));
}
std::span<const uint16_t> register_span(registers.data(), registers.size());
this->on_write_multiple_registers(start_address, register_span, status);
break;
}
case FunctionCode::WRITE_MULTIPLE_COILS: {
// Request layout: [0] function code, [1..2] start address, [3..4] coil count, [5] byte count,
// [6..] packed bits. The gate guarantees the request carries exactly (count_or_value + 7) / 8 packed
// bytes. Decoded from the request and delivered regardless of status - see the write-acknowledgement
// note in modbus.h.
std::span<const uint8_t> packed_bytes = request_pdu.subspan(6);
PackedBits bits(packed_bytes, count_or_value);
this->on_write_multiple_coils(start_address, bits, status);
break;
}
default:
this->on_custom_response(request_pdu, response_pdu, status);
break;
}
}
// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response
void ModbusClientDevice::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) {
// The dispatcher never calls this with an empty request, but this is a public virtual - stay safe.
const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0];
// Warn once per device, then drop to VERBOSE: a mildly non-conformant peer answers every poll,
// and an unhandled-response warning per transaction would flood the log permanently.
if (!this->custom_response_warned_) {
this->custom_response_warned_ = true;
ESP_LOGW(TAG, "Non-standard request or response for function code 0x%X. No on_custom_response handler declared",
function_code);
} else {
ESP_LOGV(TAG, "Non-standard request or response for function code 0x%X (unhandled)", function_code);
}
}
} // namespace esphome::modbus
+393 -49
View File
@@ -9,13 +9,20 @@
#include <array>
#include <cstring>
#include <memory>
#include <span>
#include <vector>
#include <deque>
#include <optional>
namespace esphome::modbus {
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15;
// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a
// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a
// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing.
// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus
// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one
// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266.
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes
@@ -39,6 +46,13 @@ struct ModbusFrame {
}
uint16_t size() const { return static_cast<uint16_t>(this->data.size()); }
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
uint8_t address() const { return this->data.data()[0]; }
/// The PDU: function code + data, without address or CRC. Only valid while the frame is alive.
/// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the
/// subtraction would wrap on anything shorter.
std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); }
};
class Modbus : public uart::UARTDevice, public Component {
@@ -59,9 +73,12 @@ class Modbus : public uart::UARTDevice, public Component {
virtual int32_t tx_delay_remaining();
virtual void parse_modbus_frames() = 0;
bool parse_modbus_server_frame_();
virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) = 0;
// pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code.
virtual void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) = 0;
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
// Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms,
// so this re-checks after the delay and returns false without transmitting if a byte arrived in that
// window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted.
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
@@ -82,13 +99,158 @@ class Modbus : public uart::UARTDevice, public Component {
class ModbusClientDevice;
class ModbusServerDevice;
// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived
// at selection time, never caller-chosen or stored.
enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE };
// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed
// callbacks from a quiescent hub, and an entry is erased once pending == 0 && !waiting_state().
enum class FrameState : uint8_t {
READY = 0,
WAITING,
RECEIVED_RESPONSE,
RECEIVED_EXCEPTION,
TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase
INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout
WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal
INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response
RETIRED, // cleared, off the wire
};
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
struct CommandOptions {
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
bool continuous{false};
};
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
bool interrupted{false};
FrameState state{FrameState::READY};
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
bool continuous{false};
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
uint8_t pending{1};
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
// fairness within a class. Meant to wrap.
uint16_t seq{0};
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len)
: device(device), frame(address, src, len) {}
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
bool continuous = false, uint16_t seq = 0)
: device(device),
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
continuous(continuous),
seq(seq) {}
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
CommandPriority priority() const {
return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
}
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
static CommandPriority classify(uint8_t function_code) {
if (helpers::is_function_code_exception(function_code))
return CommandPriority::READ;
const auto code = static_cast<FunctionCode>(function_code);
if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER ||
code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) {
return CommandPriority::WRITE;
}
return CommandPriority::READ;
}
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
uint8_t max_pending() const {
const uint8_t fc = this->frame.pdu()[0];
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc);
return (requeueable && !this->continuous) ? 2 : 1;
}
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
void silent_retire() {
if (!this->waiting_state())
this->state = FrameState::RETIRED;
this->pending = 0;
this->device = nullptr;
}
// Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++).
void requeue(uint16_t seq) {
this->state = FrameState::READY;
this->seq = seq;
}
// Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to
// a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter -
// to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed.
// On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the
// single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run
// once to serve that request - so restore one first. While the flag is still set max_pending() is 1,
// so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op
// on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without
// retroactively inflating that no-op.
void make_continuous(bool continuous) {
if (continuous) {
this->continuous = true;
this->pending = 1;
} else {
this->increment_pending();
this->continuous = false;
}
}
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
// request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is
// still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED ->
// INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other
// state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry:
// the clear is address-scoped (any device may call it) while the retry is the owning device's call
// via on_no_response - the bus obeys the owner.
void retire() {
if (this->state == FrameState::WAITING) {
this->state = FrameState::WAITING_RETIRED;
} else if (this->state == FrameState::INTERRUPTED) {
this->state = FrameState::INTERRUPTED_RETIRED;
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
this->state = FrameState::RETIRED;
}
this->continuous = false;
}
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
bool waiting_state() const {
return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED ||
this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED;
}
bool decrement_pending() {
if (this->pending > 0) {
this->pending--;
return true;
}
return false;
}
// Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry).
bool increment_pending() {
if (this->pending < this->max_pending()) {
this->pending++;
return true;
}
return false;
}
// Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and
// returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here.
bool sent();
bool response(std::span<const uint8_t> response_pdu);
bool error(ExceptionCode exception_code);
bool interrupt();
bool timed_out();
bool notify_retired();
/// True if this command carries the same wire frame (address + PDU) as the given one.
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
const auto own_pdu = this->frame.pdu();
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
memcmp(own_pdu.data(), pdu.data(), pdu.size()) == 0;
}
};
class ModbusClientHub : public Modbus {
@@ -104,36 +266,49 @@ class ModbusClientHub : public Modbus {
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
this->send_pdu(address,
helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities,
payload, payload_len),
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
payload_len),
device);
};
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
this->queue_raw_(address, pdu.data(), pdu.size(), device);
}
// Queue a request; true once it is a live entry (resolving in one terminal), false if it never
// entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback.
bool send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
CommandOptions options = {});
ESPDEPRECATED("Use send_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true);
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently.
void clear_tx_queue_for_address(uint8_t address);
void clear_tx_queue_for_device(ModbusClientDevice *device);
protected:
int32_t tx_delay_remaining() override;
void parse_modbus_frames() override;
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) override;
void send_next_frame_();
// Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true.
// wfr is the caller's checked reference to waiting_for_response_.
void notify_no_response_(ModbusDeviceCommand &wfr);
void requeue_waiting_frame_(ModbusDeviceCommand &wfr);
void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr);
// Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState.
void sweep_();
// The selection function: best READY entry (WRITE class first, then one-shot reads, then the
// least-recently-served continuous; FIFO by seq within each group), or nullptr.
ModbusDeviceCommand *select_next_ready_();
// Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED).
ModbusDeviceCommand *find_waiting_();
// End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState.
void expire_waiting_();
uint16_t send_wait_time_{2000};
uint16_t turnaround_delay_ms_{0};
std::optional<ModbusDeviceCommand> waiting_for_response_;
// std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many
// requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling
// may change at run time.
// Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select
// while it is set, so at most one frame is awaiting a response.
bool waiting_for_response_{false};
// Set whenever a transition leaves owed callbacks behind; quiet loop() passes skip the sweep.
bool sweep_needed_{false};
// Monotonic stamp source for ModbusDeviceCommand::seq.
uint16_t next_seq_{0};
// Plain append-order container; ordering lives in select_next_ready_(), lifecycle in FrameState.
std::deque<ModbusDeviceCommand> tx_buffer_;
};
@@ -146,8 +321,7 @@ class ModbusServerHub : public Modbus {
protected:
void parse_modbus_frames() override;
bool parse_modbus_client_frame_();
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) override;
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data);
ModbusServerDevice *find_device_(uint8_t address);
// Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space.
@@ -155,17 +329,38 @@ class ModbusServerHub : public Modbus {
bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers);
void send_raw_(const uint8_t *payload, uint16_t len);
void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation.
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
uint16_t deferred_payload_len_{0};
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data),
/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by
/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return)
/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request.
/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all
/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from
/// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal":
/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are
/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate
/// merges into it).
///
/// Invariants:
/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing
/// entry through its callback-free transition methods.
/// - Public entry points can never trigger a callback synchronously.
/// - Callbacks are delivered only from within loop().
/// - At most one callback is ever issued between calls to sweep_():
/// sweep_ -> parse (response OR error) OR timeout (no_response) -> sweep_ -> send (sent) -> sweep_ (next loop)
class ModbusClientDevice {
public:
ModbusClientDevice() = default;
@@ -180,25 +375,150 @@ class ModbusClientDevice {
ModbusClientDevice &operator=(ModbusClientDevice &&) = delete;
void set_parent(ModbusClientHub *parent) { this->parent_ = parent; }
void set_address(uint8_t address) { this->address_ = address; }
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
/// Low-level response hook: called with the request PDU this device sent and the response PDU received
/// The spans are only valid for the duration of the call - copy the bytes if they must outlive it.
/// The default implementation decodes standard responses and dispatches to on_read_* / on_write_* callbacks below.
/// Override it to handle raw PDUs directly.
virtual void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
}
/// Low-level error hook: called with the request PDU and the modbus exception code from the error response.
/// The default implementation dispatches to the same typed callbacks with the exception code as status.
/// Devices implementing the High-level typed callbacks see success and failure through one interface.
virtual void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) {
this->dispatch_response_(request_pdu, {}, exception_code);
}
/// Called when an accepted request was dropped before transmission by clear_tx_queue_for_address().
/// (on_modbus_* below are deprecated pre-rename spellings; the defaults forward during deprecation.)
virtual void on_not_sent(std::span<const uint8_t> request_pdu) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->on_modbus_not_sent();
#pragma GCC diagnostic pop
}
/// Called when this device's frame is actually written to the wire
virtual void on_sent(std::span<const uint8_t> request_pdu) {}
/// Called when no matching, uninterrupted response arrived; return true to have the hub re-queue the frame for a
/// retry. The hub does not bound retries: the device is responsible for limiting them.
virtual bool on_no_response(std::span<const uint8_t> request_pdu) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return this->on_modbus_no_response();
#pragma GCC diagnostic pop
}
// Remove before 2027.2.0
ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0")
virtual void on_modbus_not_sent() {}
/// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry.
/// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and
/// return false when exhausted), or an unresponsive peer will starve other traffic on the bus.
// Remove before 2027.2.0
ESPDEPRECATED("Override on_no_response() instead. Removed in 2027.2.0", "2026.8.0")
virtual bool on_modbus_no_response() { return false; }
/// High-level typed response callbacks, fired by the default on_response()/on_error() with arguments
/// parsed from the request and response PDUs.
/// Status is std::nullopt on success; holds the exception code on failure.
/// Register values are in host byte order; spans are only valid for the duration of the call.
virtual void on_read_registers(EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) {}
virtual void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) {
this->on_read_registers(EntityType::HOLDING, start_address, registers, status);
}
virtual void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) {
this->on_read_registers(EntityType::INPUT_REGISTER, start_address, registers, status);
}
/// Coil/discrete-input reads are delivered as a PackedBits view (bit 0 = the bit at start_address,
/// bits.size() = the count requested). The view points into the hub's receive buffer and is only
/// valid during the call.
virtual void on_read_bits(EntityType entity_type, uint16_t start_address, PackedBits bits, ResponseStatus status) {}
virtual void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) {
this->on_read_bits(EntityType::COIL, start_address, bits, status);
}
virtual void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) {
this->on_read_bits(EntityType::DISCRETE_INPUT, start_address, bits, status);
}
/// Write acknowledgements. These deliberately mirror the read callbacks' shapes, so a write ack can be fed
/// through the same handler as a read (registers.size() / bits.size() gives the count)
///
/// IMPORTANT - for the multi-writes these are the values that were REQUESTED, not device-confirmed
/// state: a multi-write ack only echoes the start address and count, so the values are decoded from
/// the request PDU, and they are delivered even when status holds an exception code. Always check
/// status, and treat publishing them as an optimistic update rather than a read-back. The single
/// writes are the exception: their successful ack echoes the value, so on success the delivered
/// value is the device's echo (on an exception it falls back to the request copy).
virtual void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) {}
virtual void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) {}
virtual void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) {}
virtual void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) {}
/// Catch-all for custom function codes and anything that is not a standard-conformant transaction
/// (see dispatch_response_()); on failure the response is empty and the exception code is in status.
/// The default implementation only logs a warning that the response is going unhandled - override it
/// to handle custom traffic (which also silences the warning).
virtual void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status);
ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0")
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
const uint8_t *payload = nullptr) {
this->parent_->send_pdu(this->address_,
helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities,
payload, payload_len),
this);
this->parent_->send_pdu(
this->address_,
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
this);
}
void send_pdu(std::span<const uint8_t> pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
/// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow),
/// false = refused at the door (no callback).
bool send_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
return this->parent_->send_pdu(this->address_, pdu, this, options);
}
ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
bool send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty())
return false; // too short to contain a PDU; refused at the door like any invalid send
return this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
// create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return.
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
CommandOptions options = {}) {
return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
number_of_entities),
options);
}
bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
return this->send_pdu(
helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options);
}
bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) {
return this->send_pdu(
helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options);
}
bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) {
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options);
}
bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) {
return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs),
options);
}
bool write_single_register(uint16_t start_address, uint16_t value) {
return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value));
}
bool write_single_coil(uint16_t address, bool value) {
return this->send_pdu(helpers::create_write_single_coil_pdu(address, value));
}
bool write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
return this->send_pdu(helpers::create_write_registers_pdu(start_address, values));
}
/// Note: std::vector<bool> cannot bind to std::span<const bool>; use a contiguous bool container or the packed
/// overload.
bool write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
return this->send_pdu(helpers::create_write_coils_pdu(start_address, values));
}
/// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so
/// read-modify-write needs no unpack/repack.
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits));
}
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
// If more than one device is connected block sending a new command before a response is received
@@ -207,18 +527,42 @@ class ModbusClientDevice {
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
protected:
/// Parses the request/response PDU pair and dispatches to the matching high-level typed callback
void dispatch_response_(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status);
ModbusClientHub *parent_{nullptr};
uint8_t address_{0};
bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE
};
// This is for compatibility with external components using the former class name
// Remove before 2026.12.0
using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0",
"2026.6.0") = ModbusClientDevice;
// Compatibility shim for external components written against the pre-2026.8 API, which subclassed
// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree
// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old
// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy
// exists only on this deprecated path) and on_modbus_error() the function code and exception code.
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0)
class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0",
"2026.8.0") ModbusDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
// Custom (user-defined) function codes historically delivered the payload starting AT the function
// code byte (frame data_offset 1). server_pdu_payload() drops that byte, so pass the whole PDU for
// them - external components match the first byte against the code they sent (issue #17994).
auto payload = !response_pdu.empty() && helpers::is_function_code_custom(response_pdu[0])
? response_pdu
: helpers::server_pdu_payload(response_pdu);
this->on_modbus_data(std::vector<uint8_t>(payload.begin(), payload.end()));
}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override {
this->on_modbus_error(request_pdu.empty() ? 0 : request_pdu[0], static_cast<uint8_t>(exception_code));
}
};
// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it;
// (future) client response callbacks receive it. Named without a side prefix so both directions share it.
using ResponseStatus = std::optional<ModbusExceptionCode>;
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
@@ -237,7 +581,7 @@ class ModbusServerDevice {
uint8_t get_address() const { return this->address_; }
virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers,
RegisterValues &registers) {
return ModbusExceptionCode::ILLEGAL_FUNCTION;
return ExceptionCode::ILLEGAL_FUNCTION;
};
virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers,
RegisterValues &registers) {
@@ -248,7 +592,7 @@ class ModbusServerDevice {
return this->on_read_registers(start_address, number_of_registers, registers);
};
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues &registers) {
return ModbusExceptionCode::ILLEGAL_FUNCTION;
return ExceptionCode::ILLEGAL_FUNCTION;
};
protected:
+98 -11
View File
@@ -1,5 +1,9 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <span>
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -14,7 +18,7 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_1_END = 72; // 0x48
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
enum class ModbusFunctionCode : uint8_t {
enum class FunctionCode : uint8_t {
INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions).
CUSTOM = 0x00, // The CUSTOM alias should be removed in future.
READ_COILS = 0x01,
@@ -37,14 +41,20 @@ enum class ModbusFunctionCode : uint8_t {
READ_FIFO_QUEUE = 0x18, // not implemented
};
/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/
inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); }
// Remove before 2027.2.0
using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0",
"2026.8.0") = FunctionCode;
// 4.3 MODBUS Data model
enum class ModbusRegisterType : uint8_t {
/*Allow direct comparison operators between FunctionCode and uint8_t*/
inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
inline bool operator!=(uint8_t lhs, FunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); }
// 4.3 MODBUS Data model. "Entity" is the spec's umbrella for the four primary tables; only the
// 16-bit tables are registers (coils and discrete inputs are bits), so the enum is not named
// RegisterType.
enum class EntityType : uint8_t {
CUSTOM = 0x00,
COIL = 0x01,
DISCRETE_INPUT = 0x02,
@@ -52,15 +62,17 @@ enum class ModbusRegisterType : uint8_t {
// Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro.
INPUT_REGISTER = 0x04,
// Remove before 2027.2.0
READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") =
INPUT_REGISTER,
READ ESPDEPRECATED("Use EntityType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = INPUT_REGISTER,
};
// Remove before 2027.2.0
using ModbusRegisterType ESPDEPRECATED("Use modbus::EntityType instead. Removed in 2027.2.0", "2026.8.0") = EntityType;
// 7 MODBUS Exception Responses:
const uint8_t FUNCTION_CODE_MASK = 0x7F;
const uint8_t FUNCTION_CODE_EXCEPTION_MASK = 0x80;
enum class ModbusExceptionCode : uint8_t {
enum class ExceptionCode : uint8_t {
ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDRESS = 0x02,
ILLEGAL_DATA_VALUE = 0x03,
@@ -72,9 +84,19 @@ enum class ModbusExceptionCode : uint8_t {
GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = 0x0B,
};
// Remove before 2027.2.0
using ModbusExceptionCode ESPDEPRECATED("Use modbus::ExceptionCode instead. Removed in 2027.2.0",
"2026.8.0") = ExceptionCode;
// 6.11 15 (0x0F) Write Multiple Coils
static constexpr uint16_t MAX_NUM_OF_COILS_TO_WRITE = 1968; // 0x7B0
// 6.12 16 (0x10) Write Multiple registers:
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.17 23 (0x17) Read/Write Multiple Registers:
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121; // 0x79
// 6.1 01 (0x01) Read Coils
// 6.2 02 (0x02) Read Discrete Inputs
static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0
@@ -86,8 +108,73 @@ static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2)
static constexpr uint16_t MIN_FRAME_SIZE = 4;
static constexpr uint16_t MIN_PDU_SIZE = 1;
static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253
static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254
// A read request PDU is always function code(1) + start address(2) + quantity(2)
static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
static constexpr uint16_t MAX_FRAME_SIZE = 256;
// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client
// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never
// has to check the framed size - it cannot be exceeded.
static_assert(MAX_PDU_SIZE + 3 == MAX_FRAME_SIZE, "a framed client PDU must fill the RTU frame limit");
static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload must fill the RTU frame limit");
/// Bits pack 8 per data byte, rounded up to whole bytes.
constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; }
/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout
* coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the
* two cannot desynchronize. The view does not own the bytes - it is only valid while they are.
* Reads (operator[]) are unchecked by design - the caller owns the bit < size() precondition, as
* with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and
* bytes() clamps to the real span, because those paths touch buffers and the wire directly.
*/
class PackedBits {
public:
PackedBits(std::span<const uint8_t> data, uint16_t count) : data_(data), count_(count) {}
/// Value of the given bit; bit must be < size().
bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; }
/// Number of bits in the view.
uint16_t size() const { return this->count_; }
/// The underlying packed bytes: exactly ceil(size() / 8) bytes, even when the view was constructed
/// over a larger buffer - forwarding this span onto the wire can never leak trailing buffer content.
/// Clamped to the actual span so a view over a too-short buffer stays detectable instead of UB.
std::span<const uint8_t> bytes() const {
return this->data_.first(std::min<size_t>(packed_bit_bytes(this->count_), this->data_.size()));
}
private:
std::span<const uint8_t> data_; // must cover ceil(count_ / 8) bytes
uint16_t count_;
};
/** Mutable counterpart of PackedBits: set() writes bits in place (deliberately no proxy operator[]=).
* Converts implicitly to PackedBits for read access.
*/
class MutablePackedBits {
public:
MutablePackedBits(std::span<uint8_t> data, uint16_t count) : data_(data), count_(count) {}
bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; }
/// Set or clear the given bit. Out-of-range bits are dropped: on the server read path the span wraps a
/// stack response buffer, so a handler looping past size() must not be able to smash the frame.
void set(size_t bit, bool value) {
if (bit >= this->count_ || bit / 8 >= this->data_.size())
return;
if (value) {
this->data_[bit / 8] |= (1 << (bit % 8));
} else {
this->data_[bit / 8] &= ~(1 << (bit % 8));
}
}
uint16_t size() const { return this->count_; }
operator PackedBits() const { return PackedBits(this->data_, this->count_); }
private:
std::span<uint8_t> data_; // must cover ceil(count_ / 8) bytes
uint16_t count_;
};
/// End of Modbus definitions
} // namespace esphome::modbus

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