mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +00:00
Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ®isters) {
|
||||
return ModbusExceptionCode::ILLEGAL_FUNCTION;
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
RegisterValues ®isters) {
|
||||
@@ -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 ®isters) {
|
||||
return ModbusExceptionCode::ILLEGAL_FUNCTION;
|
||||
return ExceptionCode::ILLEGAL_FUNCTION;
|
||||
};
|
||||
|
||||
protected:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user