mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 14:46:20 +00:00
Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb7cb5e69 | ||
|
|
700c0b0460 | ||
|
|
c0aa121c3d | ||
|
|
345bd11a2c | ||
|
|
071ef5016d | ||
|
|
fdc2974c0d | ||
|
|
899ed02ef2 | ||
|
|
a930baab7c | ||
|
|
43526a815e | ||
|
|
8f321bf346 | ||
|
|
91cbcab25e | ||
|
|
67e0058e72 | ||
|
|
b395a87cad | ||
|
|
8a7d2d0ca0 | ||
|
|
ed066cf0fe | ||
|
|
741fbbff5a | ||
|
|
cf17749307 | ||
|
|
98f2a0b4a4 | ||
|
|
9dd7dcb06d | ||
|
|
559614077d | ||
|
|
b5e18eb101 | ||
|
|
e0644ab20a | ||
|
|
98e9dd7954 | ||
|
|
fbb1a76e2a | ||
|
|
7fbb647c65 | ||
|
|
a2c749c277 | ||
|
|
0833e91fb5 | ||
|
|
eaa79b3696 | ||
|
|
58385bebff | ||
|
|
eb7575f59e | ||
|
|
6648ccc278 | ||
|
|
9ac4039c01 | ||
|
|
5e2d428e69 | ||
|
|
d12300679e | ||
|
|
e224a781ff | ||
|
|
a210c27d14 | ||
|
|
f0800336b8 | ||
|
|
33fa9f5069 | ||
|
|
427a504308 | ||
|
|
dd2fbdd43a | ||
|
|
d7d1e5c2f6 | ||
|
|
b832e135cb | ||
|
|
66615a24a7 | ||
|
|
52ace9c448 | ||
|
|
e1cedaba87 | ||
|
|
f7bf4e9727 | ||
|
|
e46bdf9e18 | ||
|
|
a08070a836 | ||
|
|
18f93c0e41 | ||
|
|
deba7a1f0c | ||
|
|
4817904637 | ||
|
|
98c6fa294a | ||
|
|
312b0c53c2 | ||
|
|
5dfce9b90c | ||
|
|
69085a7a42 | ||
|
|
2edb089f71 | ||
|
|
078b1aa1d6 | ||
|
|
1f8fea5379 | ||
|
|
9c211abc54 | ||
|
|
4afb619672 | ||
|
|
4d3b4659d7 | ||
|
|
37be04f58e | ||
|
|
1e4bf48ea3 | ||
|
|
52c6a50b6d | ||
|
|
008c85c7d5 | ||
|
|
e84880e4ec | ||
|
|
a30f9f7c65 | ||
|
|
e931919fb4 | ||
|
|
bbc5669efa | ||
|
|
718b04c15a | ||
|
|
24a8634a4b | ||
|
|
fc6664d737 | ||
|
|
388e411469 | ||
|
|
a8ebdcb8f2 | ||
|
|
f5f1c48f51 | ||
|
|
7c55de311f | ||
|
|
307faa6c38 | ||
|
|
5f2adcf9b3 | ||
|
|
7738464f0b | ||
|
|
5a86e26f68 | ||
|
|
786b47d8c2 | ||
|
|
83092ea05c | ||
|
|
629afd38f6 | ||
|
|
3ffc3a9610 | ||
|
|
9de7bd7461 | ||
|
|
5df922e0df | ||
|
|
b571d2a5ab | ||
|
|
231a2897c0 | ||
|
|
2223b14794 | ||
|
|
3d340f4d90 | ||
|
|
2f99466f3a | ||
|
|
f05e15522c | ||
|
|
f37dad683b | ||
|
|
a0fb14bf54 | ||
|
|
d4443be0c1 | ||
|
|
0dfb573e18 | ||
|
|
572fc033cd | ||
|
|
4062f0a323 | ||
|
|
cd40fb1c68 | ||
|
|
7afe7750cd | ||
|
|
e08bdf8cca | ||
|
|
b3172ecae8 | ||
|
|
2b4d9c0af9 | ||
|
|
3a10d2c187 | ||
|
|
05e2c6b133 | ||
|
|
2797349c75 | ||
|
|
95a01ac2ab | ||
|
|
ea01c909b7 | ||
|
|
d3655597ea | ||
|
|
cd8d76fa34 | ||
|
|
500d4aa9e9 | ||
|
|
271c1b409a | ||
|
|
9a0e8606cd | ||
|
|
d5681ac6a0 | ||
|
|
057143838d | ||
|
|
4268d91da3 | ||
|
|
6f39030b41 | ||
|
|
3e1a9e8a3a | ||
|
|
bb1318dce1 | ||
|
|
26fcc068b6 | ||
|
|
2a96fa44ff | ||
|
|
5fa93513f2 | ||
|
|
4a1f54ce4b | ||
|
|
76655cf951 | ||
|
|
dfb988c563 | ||
|
|
0443b4849b | ||
|
|
977376d55c | ||
|
|
1f93345af8 | ||
|
|
96dd2382c1 | ||
|
|
3064fb0d48 | ||
|
|
4c9dab9c98 | ||
|
|
0b0e706349 |
@@ -6,6 +6,7 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
@@ -20,6 +21,10 @@
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
|
||||
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
|
||||
|
||||
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
- [ ] ESP32
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -22,11 +22,13 @@ module.exports = {
|
||||
'has-tests',
|
||||
'needs-tests',
|
||||
'needs-docs',
|
||||
'needs-developer-docs',
|
||||
'needs-codeowners',
|
||||
'too-big',
|
||||
'labeller-recheck',
|
||||
'bugfix',
|
||||
'new-feature',
|
||||
'new-feature-developer',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
@@ -40,5 +42,17 @@ module.exports = {
|
||||
// Keep matching the old esphome-docs name during the transition period
|
||||
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
|
||||
/esphome\/esphome-docs#\d+/
|
||||
],
|
||||
|
||||
DEVELOPER_DOCS_PR_PATTERNS: [
|
||||
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
|
||||
/esphome\/developers\.esphome\.io#\d+/
|
||||
],
|
||||
|
||||
// Files whose developer-facing changes are documented via Python docstrings
|
||||
// only - developers.esphome.io has no reference page for them yet, so PRs
|
||||
// touching nothing but these files (and tests/) skip needs-developer-docs.
|
||||
DEV_DOCS_EXEMPT_FILES: [
|
||||
'esphome/config_validation.py'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DOCS_PR_PATTERNS } = require('./constants');
|
||||
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
|
||||
const {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
@@ -245,6 +245,7 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
const checkboxPatterns = [
|
||||
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
@@ -355,12 +356,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
const labels = new Set();
|
||||
|
||||
// Check for missing tests
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
|
||||
labels.add('needs-tests');
|
||||
}
|
||||
|
||||
// Check for missing docs.
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
|
||||
// deliberately excluded here: its docs live on developers.esphome.io and are
|
||||
// checked separately below. `new-component` / `new-platform`
|
||||
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
|
||||
// i.e. the new component/platform is actually loadable from YAML.
|
||||
const docsEligible =
|
||||
@@ -376,6 +379,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing developer docs. `new-feature-developer` requires a
|
||||
// developers.esphome.io PR link, unless every changed file outside tests/ is
|
||||
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
|
||||
if (allLabels.has('new-feature-developer')) {
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
const nonTestFiles = prFiles
|
||||
.map(file => file.filename)
|
||||
.filter(file => !file.startsWith('tests/'));
|
||||
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
|
||||
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
|
||||
|
||||
if (!onlyExemptFiles && !hasDevDocsLink) {
|
||||
labels.add('needs-developer-docs');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing CODEOWNERS
|
||||
if (allLabels.has('new-component')) {
|
||||
const codeownersModified = prFiles.some(file =>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
|
||||
const {
|
||||
detectNewPlatforms,
|
||||
detectNewComponents,
|
||||
detectPRSize,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectRequirements,
|
||||
} = require('../detectors');
|
||||
const { MANAGED_LABELS } = require('../constants');
|
||||
|
||||
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
|
||||
// to check for CONFIG_SCHEMA in newly added files.
|
||||
@@ -146,6 +153,125 @@ describe('detectNewComponents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRTemplateCheckboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
|
||||
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
|
||||
function makeBodyContext(body) {
|
||||
return { payload: { pull_request: { body } } };
|
||||
}
|
||||
|
||||
describe('detectPRTemplateCheckboxes', () => {
|
||||
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature-developer'));
|
||||
assert.ok(!labels.has('new-feature'));
|
||||
});
|
||||
|
||||
it('unticked developer-facing feature checkbox adds no label', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('ticked new feature checkbox does not add new-feature-developer', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature'));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectRequirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectRequirements', () => {
|
||||
// PR body without any docs-PR link.
|
||||
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
|
||||
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
|
||||
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
|
||||
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
|
||||
|
||||
// File sets: a normal source change vs. one confined to the exempt core validators.
|
||||
const SOURCE_FILES = [
|
||||
{ filename: 'esphome/components/foo/foo.py' },
|
||||
{ filename: 'tests/components/foo/common.yaml' },
|
||||
];
|
||||
const VALIDATOR_FILES = [
|
||||
{ filename: 'esphome/config_validation.py' },
|
||||
{ filename: 'tests/unit_tests/test_config_validation.py' },
|
||||
];
|
||||
|
||||
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-tests'));
|
||||
assert.ok(!labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer with has-tests does not add needs-tests', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-tests'));
|
||||
});
|
||||
|
||||
it('new-feature without a docs link still adds needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('validator changes mixed with other source files are not exempt', async () => {
|
||||
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGED_LABELS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MANAGED_LABELS', () => {
|
||||
it('includes new-feature-developer so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('includes needs-developer-docs so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
|
||||
@@ -21,15 +21,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -61,9 +61,9 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
@@ -96,7 +96,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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -145,16 +145,16 @@ jobs:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
- nrf52
|
||||
- host
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: .github/scripts/auto-label-pr
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Check out code from base repository
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Always check out from the base repository (esphome/esphome), never from forks
|
||||
# Use the PR's target branch to ensure we run trusted code from the main repo
|
||||
|
||||
+29
-28
@@ -28,13 +28,13 @@ jobs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Generate cache-key
|
||||
id: cache-key
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.python-linters == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -117,6 +117,7 @@ jobs:
|
||||
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
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
@@ -127,7 +128,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.import-time == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -155,17 +156,17 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out esphome (this PR)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -174,7 +175,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -231,7 +232,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -291,7 +292,7 @@ jobs:
|
||||
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Fetch enough history to find the merge base
|
||||
fetch-depth: 2
|
||||
@@ -363,10 +364,10 @@ jobs:
|
||||
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore Python virtual environment
|
||||
@@ -378,7 +379,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -418,7 +419,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -447,7 +448,7 @@ jobs:
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -516,7 +517,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -614,7 +615,7 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -694,7 +695,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -779,7 +780,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -866,7 +867,7 @@ jobs:
|
||||
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1011,7 +1012,7 @@ jobs:
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -1047,7 +1048,7 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1076,7 +1077,7 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1115,7 +1116,7 @@ jobs:
|
||||
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
|
||||
@@ -1297,7 +1298,7 @@ jobs:
|
||||
flash_usage: ${{ steps.extract.outputs.flash_usage }}
|
||||
steps:
|
||||
- name: Check out PR branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1366,7 +1367,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
# yamllint disable rule:line-length
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -92,9 +92,9 @@ jobs:
|
||||
os: "ubuntu-24.04-arm"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
- ghcr
|
||||
- dockerhub
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
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@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -28,16 +28,16 @@ jobs:
|
||||
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Checkout Home Assistant
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: home-assistant/core
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -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, []):
|
||||
@@ -617,6 +637,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 +657,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)
|
||||
```
|
||||
@@ -707,7 +732,9 @@ This document provides essential context for AI models interacting with this pro
|
||||
```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,11 +69,13 @@ 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/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
|
||||
+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.4
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+29
-7
@@ -16,14 +16,10 @@ import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
@@ -704,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
|
||||
def _wrap_to_code(name, comp, yaml_util):
|
||||
import esphome.codegen as cg
|
||||
|
||||
coro = coroutine(comp.to_code)
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
@@ -739,6 +737,7 @@ def write_cpp(config: ConfigType) -> int:
|
||||
|
||||
def generate_cpp_contents(config: ConfigType) -> None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import iter_component_configs
|
||||
|
||||
_LOGGER.info("Generating C++ source...")
|
||||
|
||||
@@ -776,6 +775,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -1457,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
|
||||
|
||||
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import strip_default_ids
|
||||
|
||||
if getattr(args, "no_defaults", False):
|
||||
user_config = getattr(config, "user_config", None)
|
||||
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
@@ -2483,7 +2498,12 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
# argcomplete only does anything when the shell-completion machinery
|
||||
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
|
||||
if "_ARGCOMPLETE" in os.environ:
|
||||
import argcomplete
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
|
||||
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
@@ -2582,6 +2602,8 @@ def run_esphome(argv):
|
||||
)
|
||||
|
||||
if config is None:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
|
||||
@@ -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()
|
||||
|
||||
+100
-1
@@ -12,7 +12,7 @@ from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -51,6 +51,7 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -127,6 +128,12 @@ class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
@@ -148,6 +155,94 @@ def add_bundle_file(path: Path) -> None:
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -174,6 +269,7 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -438,6 +534,7 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -522,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
@@ -794,6 +794,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();
|
||||
@@ -1468,6 +1469,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,134 @@
|
||||
"""
|
||||
ble_device_base — the platform-neutral BLE layer.
|
||||
|
||||
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
|
||||
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
|
||||
(BLEHub, in ble_hub.h) on every platform.
|
||||
|
||||
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
|
||||
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
|
||||
subclass, so there is no platform table here and no dependency in either
|
||||
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and
|
||||
calls register_ble_device() in to_code; a tracker component subclasses BLEHub
|
||||
(C++ and codegen class). Adding a new BLE chip requires only a new tracker
|
||||
component.
|
||||
|
||||
AES-CCM decryption for encrypted advertisements is provided portably in
|
||||
ble_aes_ccm.h.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
CONF_BLE_HUB_ID = "ble_hub_id"
|
||||
|
||||
# CORE.data key: number of parsed-advertisement listeners registered in this
|
||||
# build. Trackers whose codegen sizes storage at compile time (esp32's
|
||||
# StaticVector count define) read it in their final coroutine.
|
||||
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
|
||||
|
||||
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
|
||||
|
||||
# The neutral tracker contract. Every tracker's codegen class declares this as a
|
||||
# parent, which is what lets cv.use_id(BLEHub) resolve any of them.
|
||||
BLEHub = ble_device_base_ns.class_("BLEHub")
|
||||
|
||||
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
|
||||
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
|
||||
|
||||
|
||||
def inject_ble_hub(config: ConfigType) -> ConfigType:
|
||||
"""Validator: auto-resolve the configured BLE tracker into the config.
|
||||
|
||||
Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID +
|
||||
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on
|
||||
any platform; multiple trackers can be disambiguated with an explicit
|
||||
ble_hub_id.
|
||||
"""
|
||||
return cv.Schema(
|
||||
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA
|
||||
)(config)
|
||||
|
||||
|
||||
def request_irk_support() -> None:
|
||||
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
|
||||
irk: option so builds without IRK do not carry the resolution code."""
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
|
||||
|
||||
def get_listener_count() -> int:
|
||||
"""Number of parsed listeners registered so far (for tracker codegen)."""
|
||||
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0)
|
||||
|
||||
|
||||
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
"""Register `var` as a parsed-advertisement listener on the configured hub."""
|
||||
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
|
||||
cg.add(hub.register_listener(var))
|
||||
CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1
|
||||
return var
|
||||
|
||||
|
||||
# ---- shared validation / codegen helpers (platform-neutral) ----
|
||||
BT_UUID16_FORMAT = "XXXX"
|
||||
BT_UUID32_FORMAT = "XXXXXXXX"
|
||||
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
|
||||
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
|
||||
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
|
||||
_BT_UUID128_RE = re.compile(
|
||||
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
|
||||
)
|
||||
|
||||
|
||||
# Validator table keyed by input length: (compiled pattern, label used in errors).
|
||||
_BT_UUID_FORMATS = {
|
||||
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
|
||||
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
|
||||
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
|
||||
}
|
||||
|
||||
|
||||
def bt_uuid(value: str) -> str:
|
||||
in_value = cv.string_strict(value)
|
||||
value = in_value.upper()
|
||||
|
||||
fmt = _BT_UUID_FORMATS.get(len(value))
|
||||
if fmt is None:
|
||||
raise cv.Invalid(
|
||||
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
|
||||
)
|
||||
pattern, label = fmt
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def as_hex(value: str) -> cg.RawExpression:
|
||||
return cg.RawExpression(f"0x{value}ULL")
|
||||
|
||||
|
||||
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
|
||||
value = value.replace("-", "")
|
||||
cpp_array = [
|
||||
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
||||
]
|
||||
if reverse:
|
||||
cpp_array.reverse()
|
||||
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
|
||||
|
||||
|
||||
def as_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=False)
|
||||
|
||||
|
||||
def as_reversed_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=True)
|
||||
@@ -0,0 +1,202 @@
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
namespace {
|
||||
|
||||
// AES-128 forward cipher only — CCM uses the block cipher in the encrypt
|
||||
// direction for both the CTR keystream and the CBC-MAC.
|
||||
const uint8_t SBOX[256] = {
|
||||
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, //
|
||||
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, //
|
||||
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, //
|
||||
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, //
|
||||
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, //
|
||||
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, //
|
||||
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, //
|
||||
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, //
|
||||
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, //
|
||||
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, //
|
||||
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, //
|
||||
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, //
|
||||
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, //
|
||||
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, //
|
||||
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, //
|
||||
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, //
|
||||
};
|
||||
|
||||
const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36};
|
||||
|
||||
inline uint8_t xtime(uint8_t x) { return static_cast<uint8_t>((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); }
|
||||
|
||||
// AES-128 forward cipher with on-the-fly key schedule.
|
||||
class Aes128 {
|
||||
public:
|
||||
explicit Aes128(const uint8_t key[16]) {
|
||||
memcpy(this->rk_, key, 16);
|
||||
for (size_t i = 16; i < 176; i += 4) {
|
||||
uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]};
|
||||
if (i % 16 == 0) {
|
||||
const uint8_t tmp = t[0];
|
||||
t[0] = static_cast<uint8_t>(SBOX[t[1]] ^ RCON[i / 16]);
|
||||
t[1] = SBOX[t[2]];
|
||||
t[2] = SBOX[t[3]];
|
||||
t[3] = SBOX[tmp];
|
||||
}
|
||||
for (size_t j = 0; j < 4; j++)
|
||||
this->rk_[i + j] = static_cast<uint8_t>(this->rk_[i - 16 + j] ^ t[j]);
|
||||
}
|
||||
}
|
||||
|
||||
void encrypt(const uint8_t in[16], uint8_t out[16]) const {
|
||||
uint8_t s[16];
|
||||
memcpy(s, in, 16);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[i];
|
||||
|
||||
for (size_t round = 1; round < 10; round++) {
|
||||
for (uint8_t &b : s)
|
||||
b = SBOX[b];
|
||||
shift_rows(s);
|
||||
for (size_t c = 0; c < 4; c++) {
|
||||
uint8_t *col = s + c * 4;
|
||||
const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3];
|
||||
const uint8_t h = static_cast<uint8_t>(a0 ^ a1 ^ a2 ^ a3);
|
||||
col[0] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a0 ^ a1)));
|
||||
col[1] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a1 ^ a2)));
|
||||
col[2] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a2 ^ a3)));
|
||||
col[3] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a3 ^ a0)));
|
||||
}
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[round * 16 + i];
|
||||
}
|
||||
|
||||
for (uint8_t &b : s)
|
||||
b = SBOX[b];
|
||||
shift_rows(s);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[160 + i];
|
||||
memcpy(out, s, 16);
|
||||
}
|
||||
|
||||
protected:
|
||||
static void shift_rows(uint8_t s[16]) {
|
||||
uint8_t t = s[1];
|
||||
s[1] = s[5];
|
||||
s[5] = s[9];
|
||||
s[9] = s[13];
|
||||
s[13] = t;
|
||||
t = s[2];
|
||||
s[2] = s[10];
|
||||
s[10] = t;
|
||||
t = s[6];
|
||||
s[6] = s[14];
|
||||
s[14] = t;
|
||||
t = s[3];
|
||||
s[3] = s[15];
|
||||
s[15] = s[11];
|
||||
s[11] = s[7];
|
||||
s[7] = t;
|
||||
}
|
||||
|
||||
uint8_t rk_[176];
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
|
||||
Aes128 aes(key);
|
||||
aes.encrypt(in, out);
|
||||
}
|
||||
|
||||
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
|
||||
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
|
||||
const uint8_t *tag, size_t tag_len) {
|
||||
// CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte
|
||||
// nonce L = 2; BTHome uses M = 4.
|
||||
if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16)
|
||||
return false;
|
||||
const size_t l = 15 - nonce_len;
|
||||
const size_t m = tag_len;
|
||||
|
||||
const Aes128 aes(key);
|
||||
|
||||
// Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian).
|
||||
uint8_t a[16];
|
||||
auto build_ctr = [&](uint32_t counter) {
|
||||
a[0] = static_cast<uint8_t>(l - 1);
|
||||
memcpy(a + 1, nonce, nonce_len);
|
||||
memset(a + 1 + nonce_len, 0, l);
|
||||
for (size_t i = 0; i < l; i++)
|
||||
a[15 - i] = static_cast<uint8_t>((counter >> (8 * i)) & 0xff);
|
||||
};
|
||||
|
||||
// S_0 = E(A_0); its first m bytes mask the transmitted tag.
|
||||
uint8_t s0[16];
|
||||
build_ctr(0);
|
||||
aes.encrypt(a, s0);
|
||||
|
||||
// CTR-decrypt ciphertext into plaintext using S_1, S_2, ...
|
||||
uint8_t ks[16];
|
||||
for (size_t off = 0; off < ct_len; off += 16) {
|
||||
build_ctr(static_cast<uint32_t>(off / 16) + 1);
|
||||
aes.encrypt(a, ks);
|
||||
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
plaintext[off + i] = static_cast<uint8_t>(ciphertext[off + i] ^ ks[i]);
|
||||
}
|
||||
|
||||
// CBC-MAC over B_0 | (formatted AAD) | plaintext.
|
||||
uint8_t x[16];
|
||||
uint8_t b0[16];
|
||||
const uint8_t flags = static_cast<uint8_t>((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1));
|
||||
b0[0] = flags;
|
||||
memcpy(b0 + 1, nonce, nonce_len);
|
||||
memset(b0 + 1 + nonce_len, 0, l);
|
||||
for (size_t i = 0; i < l; i++)
|
||||
b0[15 - i] = static_cast<uint8_t>((ct_len >> (8 * i)) & 0xff);
|
||||
aes.encrypt(b0, x); // X_1 = E(B_0)
|
||||
|
||||
if (aad_len > 0) {
|
||||
// Only the < 2^16-2^8 encoding is needed for BLE-sized AAD.
|
||||
uint8_t blk[16] = {0};
|
||||
blk[0] = static_cast<uint8_t>((aad_len >> 8) & 0xff);
|
||||
blk[1] = static_cast<uint8_t>(aad_len & 0xff);
|
||||
size_t ai = 0;
|
||||
size_t pos = 2;
|
||||
while (pos < 16 && ai < aad_len)
|
||||
blk[pos++] = aad[ai++];
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
while (ai < aad_len) {
|
||||
memset(blk, 0, 16);
|
||||
const size_t n = std::min(static_cast<size_t>(16), aad_len - ai);
|
||||
memcpy(blk, aad + ai, n);
|
||||
ai += n;
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t off = 0; off < ct_len; off += 16) {
|
||||
uint8_t blk[16] = {0};
|
||||
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
|
||||
memcpy(blk, plaintext + off, n);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
}
|
||||
|
||||
// Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag.
|
||||
uint8_t diff = 0;
|
||||
for (size_t i = 0; i < m; i++)
|
||||
diff |= static_cast<uint8_t>((x[i] ^ s0[i]) ^ tag[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
// Self-contained AES-128-CCM authenticated decryption (RFC 3610).
|
||||
//
|
||||
// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use
|
||||
// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE
|
||||
// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls
|
||||
// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not
|
||||
// put it on the application include path), so a sensor cannot rely on
|
||||
// <mbedtls/ccm.h> being available. This software implementation makes
|
||||
// encrypted-advertisement decryption work on every BLE platform without a
|
||||
// per-chip crypto dependency. Decryption volume is tiny (one short block per
|
||||
// matching advertisement), so software AES is not a meaningful cost.
|
||||
//
|
||||
// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted
|
||||
// bytes to `plaintext` and returns true. Returns false when authentication fails
|
||||
// (the caller must then discard `plaintext`). The CCM parameters follow the
|
||||
// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be
|
||||
// null when `aad_len` is 0.
|
||||
/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by
|
||||
/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching
|
||||
/// works identically on every platform with no chip crypto dependency.
|
||||
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
|
||||
|
||||
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
|
||||
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
|
||||
const uint8_t *tag, size_t tag_len);
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,530 @@
|
||||
// ble_device.cpp
|
||||
//
|
||||
// Platform-neutral implementation of the shared BLE advertisement types.
|
||||
// Parses raw BLE advertisement data into ESPBTDevice.
|
||||
|
||||
#include "ble_device.h"
|
||||
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
static const char *const TAG = "ble_device_base";
|
||||
|
||||
// Longest advertisement payload worth hex-dumping at VERY_VERBOSE
|
||||
// (legacy advertising: 31-byte adv + 31-byte scan response).
|
||||
static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTUUID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID16;
|
||||
ret.uuid_.uuid16 = uuid;
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID32;
|
||||
ret.uuid_.uuid32 = uuid;
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID128;
|
||||
memcpy(ret.uuid_.uuid128, data, 16);
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID128;
|
||||
for (int i = 0; i < 16; i++)
|
||||
ret.uuid_.uuid128[i] = data[15 - i];
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
|
||||
// Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw.
|
||||
ESPBTUUID ret;
|
||||
if (length == 4) {
|
||||
// 16-bit UUID as 4-character hex string
|
||||
auto parsed = parse_hex<uint16_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.type_ = Type::UUID16;
|
||||
ret.uuid_.uuid16 = parsed.value();
|
||||
}
|
||||
} else if (length == 8) {
|
||||
// 32-bit UUID as 8-character hex string
|
||||
auto parsed = parse_hex<uint32_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.type_ = Type::UUID32;
|
||||
ret.uuid_.uuid32 = parsed.value();
|
||||
}
|
||||
} else if (length == 16) {
|
||||
// 16 raw bytes (little-endian 128-bit UUID)
|
||||
ret.type_ = Type::UUID128;
|
||||
memcpy(ret.uuid_.uuid128, reinterpret_cast<const uint8_t *>(data), 16);
|
||||
} else if (length == 36) {
|
||||
// Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
ret.type_ = Type::UUID128;
|
||||
int n = 0;
|
||||
for (size_t i = 0; i < length; i += 2) {
|
||||
if (data[i] == '-')
|
||||
i++;
|
||||
uint8_t msb = data[i];
|
||||
uint8_t lsb = data[i + 1];
|
||||
if (msb > '9')
|
||||
msb -= 7;
|
||||
if (lsb > '9')
|
||||
lsb -= 7;
|
||||
ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
|
||||
if (uuid.len == ESP_UUID_LEN_16)
|
||||
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
|
||||
if (uuid.len == ESP_UUID_LEN_32)
|
||||
return ESPBTUUID::from_uint32(uuid.uuid.uuid32);
|
||||
return ESPBTUUID::from_raw(uuid.uuid.uuid128);
|
||||
}
|
||||
|
||||
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
|
||||
esp_bt_uuid_t ret;
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
ret.len = ESP_UUID_LEN_16;
|
||||
ret.uuid.uuid16 = this->uuid_.uuid16;
|
||||
break;
|
||||
case Type::UUID32:
|
||||
ret.len = ESP_UUID_LEN_32;
|
||||
ret.uuid.uuid32 = this->uuid_.uuid32;
|
||||
break;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
ret.len = ESP_UUID_LEN_128;
|
||||
memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
this->scan_result_ = &scan_result;
|
||||
// BLEScanResult's bda is most-significant octet first; the neutral ingest
|
||||
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
|
||||
// address_str() then produce exactly the historical esp32 values.
|
||||
uint8_t mac_lsb_first[6];
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
mac_lsb_first[i] = scan_result.bda[5 - i];
|
||||
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
|
||||
scan_result.adv_data_len + scan_result.scan_rsp_len);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
if (this->type_ == Type::UUID128)
|
||||
return *this;
|
||||
uint8_t data[16];
|
||||
this->to_128bit_(data);
|
||||
return ESPBTUUID::from_raw(data);
|
||||
}
|
||||
|
||||
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
|
||||
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
|
||||
case Type::UUID32:
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1;
|
||||
bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
|
||||
if (a && b)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case Type::UUID128:
|
||||
for (uint8_t i = 0; i < 15; i++) {
|
||||
if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *ESPBTUUID::to_str(char *buf) const {
|
||||
// Identical output format to esp32_ble::ESPBTUUID::to_str.
|
||||
char *pos = buf;
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
case Type::UUID32:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
for (int shift = 28; shift >= 0; shift -= 4)
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
for (int8_t i = 15; i >= 0; i--) {
|
||||
uint8_t byte = this->uuid_.uuid128[i];
|
||||
*pos++ = format_hex_pretty_char(byte >> 4);
|
||||
*pos++ = format_hex_pretty_char(byte & 0x0F);
|
||||
if (i == 12 || i == 10 || i == 8 || i == 6)
|
||||
*pos++ = '-';
|
||||
}
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
|
||||
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
|
||||
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
|
||||
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
if (this->type_ == Type::UUID128) {
|
||||
memcpy(out, this->uuid_.uuid128, 16);
|
||||
return;
|
||||
}
|
||||
memcpy(out, BASE, 16);
|
||||
const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16;
|
||||
const size_t len = (this->type_ == Type::UUID32) ? 4 : 2;
|
||||
for (size_t i = 0; i < len; i++)
|
||||
out[12 + i] = (value >> (i * 8)) & 0xFF;
|
||||
}
|
||||
|
||||
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
if (this->type_ == other.type_) {
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
return this->uuid_.uuid16 == other.uuid_.uuid16;
|
||||
case Type::UUID32:
|
||||
return this->uuid_.uuid32 == other.uuid_.uuid32;
|
||||
case Type::UUID128:
|
||||
return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
|
||||
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
|
||||
uint8_t a[16];
|
||||
uint8_t b[16];
|
||||
this->to_128bit_(a);
|
||||
other.to_128bit_(b);
|
||||
return memcmp(a, b, 16) == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
|
||||
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
|
||||
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
|
||||
// Parity with esp32_ble_tracker: gate on the Apple company ID and length only.
|
||||
// (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior
|
||||
// change; it belongs to a follow-up, not this refactor.)
|
||||
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
|
||||
return {};
|
||||
if (data.data.size() != 23)
|
||||
return {};
|
||||
return ESPBLEiBeacon(data.data.data());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
// order so the raw address() accessor matches the historical esp32 layout.
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
this->address_[i] = mac[5 - i];
|
||||
this->address_type_ = addr_type;
|
||||
this->rssi_ = rssi;
|
||||
this->name_.clear();
|
||||
this->service_uuids_.clear();
|
||||
this->manufacturer_datas_.clear();
|
||||
this->service_datas_.clear();
|
||||
this->tx_powers_.clear();
|
||||
this->appearance_.reset();
|
||||
this->ad_flag_.reset();
|
||||
this->parse_adv_(data, data_len);
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
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);
|
||||
}
|
||||
if (this->appearance_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_);
|
||||
}
|
||||
if (this->ad_flag_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_);
|
||||
}
|
||||
char uuid_buf[UUID_STR_LEN];
|
||||
for (auto &uuid : this->service_uuids_) {
|
||||
ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf));
|
||||
}
|
||||
char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)];
|
||||
for (auto &mfg_data : this->manufacturer_datas_) {
|
||||
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data);
|
||||
if (ibeacon.has_value()) {
|
||||
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:\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
|
||||
}
|
||||
|
||||
std::string ESPBTDevice::address_str() const {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->address_str_to(buf));
|
||||
}
|
||||
|
||||
const char *ESPBTDevice::address_str_to(char *buf) const {
|
||||
// address_ is stored in printable (MSB-first) order.
|
||||
format_mac_addr_upper(this->address_, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
uint64_t ESPBTDevice::address_uint64() const {
|
||||
// address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics).
|
||||
uint64_t addr = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
addr |= static_cast<uint64_t>(this->address_[i]) << ((5 - i) * 8);
|
||||
return addr;
|
||||
}
|
||||
|
||||
bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
|
||||
#ifdef USE_BLE_DEVICE_IRK
|
||||
// Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits].
|
||||
// The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes).
|
||||
// Uses the portable software AES-128 shared with the CCM decryptor, so IRK
|
||||
// matching behaves identically on every platform (volume is one block per
|
||||
// advertisement from a matching RPA device — software AES is not a cost).
|
||||
uint8_t ecb_plaintext[16] = {0};
|
||||
uint8_t ecb_ciphertext[16];
|
||||
const uint64_t addr64 = this->address_uint64();
|
||||
ecb_plaintext[13] = (addr64 >> 40) & 0xff;
|
||||
ecb_plaintext[14] = (addr64 >> 32) & 0xff;
|
||||
ecb_plaintext[15] = (addr64 >> 24) & 0xff;
|
||||
aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext);
|
||||
return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) &&
|
||||
ecb_ciphertext[13] == ((addr64 >> 16) & 0xff);
|
||||
#else
|
||||
// No sensor configured an irk: in this build; the AES core is compiled out.
|
||||
(void) irk;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
|
||||
// BLE AD structure TLV: [length][type][value...]
|
||||
// length includes the type byte.
|
||||
uint16_t offset = 0;
|
||||
while (offset < len) {
|
||||
uint8_t ad_len = payload[offset++];
|
||||
if (ad_len == 0)
|
||||
continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too)
|
||||
if (offset + ad_len > len)
|
||||
break;
|
||||
uint8_t ad_type = payload[offset];
|
||||
const uint8_t *ad_data = &payload[offset + 1];
|
||||
uint8_t ad_data_len = ad_len - 1;
|
||||
offset += ad_len;
|
||||
|
||||
switch (ad_type) {
|
||||
case 0x01: // Flags
|
||||
if (ad_data_len >= 1)
|
||||
this->ad_flag_ = ad_data[0];
|
||||
break;
|
||||
|
||||
case 0x08: // Shortened Local Name
|
||||
case 0x09: // Complete Local Name
|
||||
// Keep the longest name seen — a merged adv + scan-response frame may carry both the
|
||||
// shortened and the complete name, and the shortened form must never replace the
|
||||
// complete one (same rule as esp32_ble_tracker's parse_adv_).
|
||||
if (ad_data_len > this->name_.length())
|
||||
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len);
|
||||
break;
|
||||
|
||||
case 0x0A: // TX Power Level
|
||||
if (ad_data_len >= 1)
|
||||
this->tx_powers_.push_back(static_cast<int8_t>(ad_data[0]));
|
||||
break;
|
||||
|
||||
case 0x19: // Appearance
|
||||
if (ad_data_len >= 2)
|
||||
this->appearance_ = static_cast<uint16_t>(ad_data[0]) | (static_cast<uint16_t>(ad_data[1]) << 8);
|
||||
break;
|
||||
|
||||
case 0x02: // Incomplete List of 16-bit Service UUIDs
|
||||
case 0x03: // Complete List of 16-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) {
|
||||
uint16_t uuid = (static_cast<uint16_t>(ad_data[i + 1]) << 8) | ad_data[i];
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x04: // Incomplete List of 32-bit Service UUIDs
|
||||
case 0x05: // Complete List of 32-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) {
|
||||
uint32_t uuid = (static_cast<uint32_t>(ad_data[i + 3]) << 24) |
|
||||
(static_cast<uint32_t>(ad_data[i + 2]) << 16) | (static_cast<uint32_t>(ad_data[i + 1]) << 8) |
|
||||
ad_data[i];
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x06: // Incomplete List of 128-bit Service UUIDs
|
||||
case 0x07: // Complete List of 128-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16)
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i]));
|
||||
break;
|
||||
|
||||
case 0xFF: // Manufacturer Specific Data
|
||||
if (ad_data_len >= 2) {
|
||||
uint16_t company_id = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint16(company_id);
|
||||
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
|
||||
this->manufacturer_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x16: // Service Data — 16-bit UUID
|
||||
if (ad_data_len >= 2) {
|
||||
uint16_t uuid = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint16(uuid);
|
||||
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x20: // Service Data — 32-bit UUID
|
||||
if (ad_data_len >= 4) {
|
||||
uint32_t uuid = (static_cast<uint32_t>(ad_data[3]) << 24) | (static_cast<uint32_t>(ad_data[2]) << 16) |
|
||||
(static_cast<uint32_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint32(uuid);
|
||||
sd.data.assign(ad_data + 4, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x21: // Service Data — 128-bit UUID
|
||||
if (ad_data_len >= 16) {
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_raw(ad_data);
|
||||
sd.data.assign(ad_data + 16, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -0,0 +1,260 @@
|
||||
// ble_device.h
|
||||
//
|
||||
// Platform-neutral BLE advertisement types — the generic base every BLE consumer
|
||||
// (sensor components, bluetooth_proxy, automation triggers) builds against:
|
||||
// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener
|
||||
//
|
||||
// These types are owned here on EVERY platform, with no chip-SDK types in their
|
||||
// public surface. Platform trackers produce them:
|
||||
// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and
|
||||
// re-exports these names (esp32 only) for backward compatibility;
|
||||
// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__cpp_lib_span)
|
||||
#include <span>
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// Historical esp32_ble API surface (below, under the same define) uses the
|
||||
// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32.
|
||||
#include "esphome/components/esp32_ble/ble_scan_result.h"
|
||||
#include <esp_bt_defs.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
using adv_data_t = std::vector<uint8_t>;
|
||||
|
||||
// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t).
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3;
|
||||
|
||||
/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0"
|
||||
static constexpr size_t UUID_STR_LEN = 37;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type.
|
||||
// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t
|
||||
// conversions live in esp32_ble (esp32-only adapters), not here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTUUID {
|
||||
public:
|
||||
ESPBTUUID() = default;
|
||||
|
||||
static ESPBTUUID from_uint16(uint16_t uuid);
|
||||
static ESPBTUUID from_uint32(uint32_t uuid);
|
||||
/// Construct from raw 16-byte little-endian UUID.
|
||||
static ESPBTUUID from_raw(const uint8_t *data);
|
||||
/// Construct from raw 16-byte big-endian UUID (reversed on store).
|
||||
static ESPBTUUID from_raw_reversed(const uint8_t *data);
|
||||
/// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes,
|
||||
/// or the 36-char dashed UUID form. Same semantics as esp32_ble historically.
|
||||
static ESPBTUUID from_raw(const char *data, size_t length);
|
||||
static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); }
|
||||
static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); }
|
||||
static ESPBTUUID from_raw(std::initializer_list<uint8_t> data) {
|
||||
return from_raw(reinterpret_cast<const char *>(data.begin()), data.size());
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// Source compatibility with the historical esp32_ble API (esp32 builds only).
|
||||
static ESPBTUUID from_uuid(esp_bt_uuid_t uuid);
|
||||
esp_bt_uuid_t get_uuid() const;
|
||||
#endif
|
||||
|
||||
/// Expand to the 128-bit Bluetooth Base UUID form.
|
||||
ESPBTUUID as_128bit() const;
|
||||
|
||||
/// True if the UUID value contains the adjacent byte pair (data1, data2).
|
||||
bool contains(uint8_t data1, uint8_t data2) const;
|
||||
|
||||
bool operator==(const ESPBTUUID &other) const;
|
||||
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
|
||||
|
||||
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf
|
||||
/// (>= UUID_STR_LEN bytes) and return buf.
|
||||
const char *to_str(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
|
||||
#endif
|
||||
enum class Type : uint8_t { UUID16, UUID32, UUID128 };
|
||||
Type type() const { return this->type_; }
|
||||
uint16_t uuid16() const { return this->uuid_.uuid16; }
|
||||
uint32_t uuid32() const { return this->uuid_.uuid32; }
|
||||
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
|
||||
|
||||
protected:
|
||||
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
|
||||
void to_128bit_(uint8_t out[16]) const;
|
||||
|
||||
Type type_{Type::UUID16};
|
||||
union {
|
||||
uint16_t uuid16;
|
||||
uint32_t uuid32;
|
||||
uint8_t uuid128[16];
|
||||
} uuid_{};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ServiceData {
|
||||
ESPBTUUID uuid;
|
||||
adv_data_t data;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBLEiBeacon {
|
||||
public:
|
||||
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
|
||||
explicit ESPBLEiBeacon(const uint8_t *data);
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
|
||||
|
||||
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
|
||||
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
|
||||
int8_t get_signal_power() const { return this->beacon_data_.signal_power; }
|
||||
ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
|
||||
|
||||
protected:
|
||||
struct PACKED BeaconData {
|
||||
uint8_t sub_type;
|
||||
uint8_t length;
|
||||
uint8_t proximity_uuid[16];
|
||||
uint16_t major;
|
||||
uint16_t minor;
|
||||
int8_t signal_power;
|
||||
} beacon_data_;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice — parsed BLE advertisement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTDevice {
|
||||
public:
|
||||
/// Populate from a raw scan result delivered by a BLE tracker backend.
|
||||
/// mac is least-significant octet first (BLE controller convention).
|
||||
void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
|
||||
|
||||
// Alias the core constant so the two cannot drift apart.
|
||||
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
|
||||
|
||||
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
|
||||
std::string address_str() const;
|
||||
/// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf.
|
||||
const char *address_str_to(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
|
||||
return this->address_str_to(buf.data());
|
||||
}
|
||||
#endif
|
||||
/// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
|
||||
uint64_t address_uint64() const;
|
||||
/// Raw MAC bytes in printable (MSB-first) order — matches the historical
|
||||
/// esp32 layout (ESP-IDF bda order).
|
||||
const uint8_t *address() const { return address_; }
|
||||
#ifdef USE_ESP32
|
||||
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t.
|
||||
esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
|
||||
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result.
|
||||
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
|
||||
// Exposed through a function for use in lambdas
|
||||
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
|
||||
#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_; }
|
||||
|
||||
const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
|
||||
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
|
||||
const std::vector<ServiceData> &get_service_datas() const { return service_datas_; }
|
||||
const std::vector<int8_t> &get_tx_powers() const { return tx_powers_; }
|
||||
const optional<uint16_t> &get_appearance() const { return appearance_; }
|
||||
const optional<uint8_t> &get_ad_flag() const { return ad_flag_; }
|
||||
|
||||
/// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah"
|
||||
/// function, AES-128). Uses the portable software AES shared with the CCM
|
||||
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
|
||||
bool resolve_irk(const uint8_t *irk) const;
|
||||
|
||||
optional<ESPBLEiBeacon> get_ibeacon() const {
|
||||
for (const auto &it : this->manufacturer_datas_) {
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
protected:
|
||||
void parse_adv_(const uint8_t *payload, uint16_t len);
|
||||
|
||||
uint8_t address_[6]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
std::string name_{};
|
||||
std::vector<ESPBTUUID> service_uuids_{};
|
||||
std::vector<ServiceData> manufacturer_datas_{};
|
||||
std::vector<ServiceData> service_datas_{};
|
||||
#ifdef USE_ESP32
|
||||
const esp32_ble::BLEScanResult *scan_result_{nullptr};
|
||||
#endif
|
||||
std::vector<int8_t> tx_powers_{};
|
||||
optional<uint16_t> appearance_{};
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTDeviceListener {
|
||||
public:
|
||||
virtual ~ESPBTDeviceListener() = default;
|
||||
/// Called at the end of each scan duration period.
|
||||
virtual void on_scan_end() {}
|
||||
virtual bool parse_device(const ESPBTDevice &device) = 0;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,63 @@
|
||||
// ble_hub.h
|
||||
//
|
||||
// BLEHub — the platform-neutral BLE tracker contract.
|
||||
//
|
||||
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker,
|
||||
// ln882h_ble_tracker, future chips) implements this interface; every BLE
|
||||
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via
|
||||
// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares.
|
||||
// Adding a new BLE chip therefore requires only a new tracker component that
|
||||
// implements BLEHub: no consumer, registry, or base changes.
|
||||
//
|
||||
// Chip differences are expressed as data (HubCapabilities), never as
|
||||
// platform conditionals in consumers.
|
||||
|
||||
#pragma once
|
||||
|
||||
#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)>;
|
||||
|
||||
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
|
||||
struct HubCapabilities {
|
||||
/// Controller can send scan requests (active scanning).
|
||||
bool active_scan;
|
||||
/// Controller (or tracker) delivers advertisement + scan response as one merged
|
||||
/// frame. When false, consumers relying on scan-response fields (e.g. names)
|
||||
/// may only see them where the receiver merges per address (Home Assistant does).
|
||||
bool merges_scan_response;
|
||||
/// GATT client connections are available (today: esp32 only, but a chip SDK
|
||||
/// gaining GATT support only has to flip this bit).
|
||||
bool gatt;
|
||||
};
|
||||
|
||||
class BLEHub {
|
||||
public:
|
||||
virtual ~BLEHub() = default;
|
||||
|
||||
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers).
|
||||
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 HubCapabilities get_capabilities() const = 0;
|
||||
|
||||
/// Adapter MAC in printable (MSB-first) order, out[0] = MSB.
|
||||
virtual void get_adapter_mac(uint8_t out[6]) = 0;
|
||||
|
||||
virtual bool scan_running() = 0;
|
||||
/// True when the current/configured scan mode is active (scan requests sent).
|
||||
virtual bool scan_active() = 0;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -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"
|
||||
|
||||
@@ -19,21 +21,8 @@ class BLEScanner final : public text_sensor::TextSensor,
|
||||
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();
|
||||
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()));
|
||||
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -24,23 +24,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.
|
||||
*
|
||||
|
||||
@@ -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, 9),
|
||||
"latest": cv.Version(3, 3, 9),
|
||||
"dev": cv.Version(3, 3, 9),
|
||||
"recommended": cv.Version(3, 3, 10),
|
||||
"latest": cv.Version(3, 3, 10),
|
||||
"dev": cv.Version(3, 3, 10),
|
||||
}
|
||||
ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
4, 0, 0, "alpha1"
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
||||
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"),
|
||||
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
|
||||
@@ -844,6 +845,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, 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),
|
||||
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
|
||||
@@ -865,9 +867,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
# The default/recommended esp-idf framework version
|
||||
# - https://github.com/espressif/esp-idf/releases
|
||||
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(5, 5, 4),
|
||||
"latest": cv.Version(5, 5, 4),
|
||||
"dev": cv.Version(5, 5, 4),
|
||||
"recommended": cv.Version(5, 5, 5),
|
||||
"latest": cv.Version(5, 5, 5),
|
||||
"dev": cv.Version(5, 5, 5),
|
||||
}
|
||||
|
||||
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
@@ -877,6 +879,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, 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),
|
||||
@@ -3105,9 +3108,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+)\)$"
|
||||
)
|
||||
@@ -3125,9 +3129,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);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ from collections.abc import Callable, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
|
||||
# bt_uuid validation lives in the platform-neutral ble_device_base; re-exported
|
||||
# here for backward compatibility.
|
||||
from esphome.components.ble_device_base import ( # noqa: F401 # pylint: disable=unused-import
|
||||
BT_UUID16_FORMAT as bt_uuid16_format,
|
||||
BT_UUID32_FORMAT as bt_uuid32_format,
|
||||
BT_UUID128_FORMAT as bt_uuid128_format,
|
||||
bt_uuid,
|
||||
)
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
@@ -28,6 +36,7 @@ from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ble_device_base"] # ble_uuid.h builds on the neutral ESPBTUUID
|
||||
DEPENDENCIES = ["esp32"]
|
||||
CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"]
|
||||
DOMAIN = "esp32_ble"
|
||||
@@ -372,43 +381,6 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType:
|
||||
CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes)
|
||||
|
||||
|
||||
bt_uuid16_format = "XXXX"
|
||||
bt_uuid32_format = "XXXXXXXX"
|
||||
bt_uuid128_format = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
|
||||
|
||||
def bt_uuid(value):
|
||||
in_value = cv.string_strict(value)
|
||||
value = in_value.upper()
|
||||
|
||||
if len(value) == len(bt_uuid16_format):
|
||||
pattern = re.compile("^[A-F0-9]{4,}$")
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
if len(value) == len(bt_uuid32_format):
|
||||
pattern = re.compile("^[A-F0-9]{8,}$")
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
if len(value) == len(bt_uuid128_format):
|
||||
pattern = re.compile(
|
||||
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
|
||||
)
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for 128 UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
raise cv.Invalid(
|
||||
f"Bluetooth UUID must be in 16 bit '{bt_uuid16_format}', 32 bit '{bt_uuid32_format}', or 128 bit '{bt_uuid128_format}' format"
|
||||
)
|
||||
|
||||
|
||||
def validate_variant(_):
|
||||
variant = get_esp32_variant()
|
||||
if variant in NO_BLUETOOTH_VARIANTS:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "ble_uuid.h"
|
||||
|
||||
#include <array>
|
||||
#include <functional>
|
||||
@@ -15,8 +16,6 @@
|
||||
|
||||
namespace esphome::esp32_ble {
|
||||
|
||||
class ESPBTUUID;
|
||||
|
||||
class BLEAdvertising {
|
||||
public:
|
||||
BLEAdvertising(uint32_t advertising_cycle_time);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#include "ble_uuid.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP32_BLE_UUID
|
||||
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::esp32_ble {
|
||||
|
||||
static const char *const TAG = "esp32_ble";
|
||||
|
||||
ESPBTUUID::ESPBTUUID() : uuid_() {}
|
||||
ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.uuid_.len = ESP_UUID_LEN_16;
|
||||
ret.uuid_.uuid.uuid16 = uuid;
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.uuid_.len = ESP_UUID_LEN_32;
|
||||
ret.uuid_.uuid.uuid32 = uuid;
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.uuid_.len = ESP_UUID_LEN_128;
|
||||
memcpy(ret.uuid_.uuid.uuid128, data, ESP_UUID_LEN_128);
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.uuid_.len = ESP_UUID_LEN_128;
|
||||
for (uint8_t i = 0; i < ESP_UUID_LEN_128; i++)
|
||||
ret.uuid_.uuid.uuid128[ESP_UUID_LEN_128 - 1 - i] = data[i];
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
|
||||
ESPBTUUID ret;
|
||||
if (length == 4) {
|
||||
// 16-bit UUID as 4-character hex string
|
||||
auto parsed = parse_hex<uint16_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.uuid_.len = ESP_UUID_LEN_16;
|
||||
ret.uuid_.uuid.uuid16 = parsed.value();
|
||||
}
|
||||
} else if (length == 8) {
|
||||
// 32-bit UUID as 8-character hex string
|
||||
auto parsed = parse_hex<uint32_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.uuid_.len = ESP_UUID_LEN_32;
|
||||
ret.uuid_.uuid.uuid32 = parsed.value();
|
||||
}
|
||||
} else if (length == 16) { // how we can have 16 byte length string reprezenting 128 bit uuid??? needs to be
|
||||
// investigated (lack of time)
|
||||
ret.uuid_.len = ESP_UUID_LEN_128;
|
||||
memcpy(ret.uuid_.uuid.uuid128, reinterpret_cast<const uint8_t *>(data), 16);
|
||||
} else if (length == 36) {
|
||||
// If the length of the string is 36 bytes then we will assume it is a long hex string in
|
||||
// UUID format.
|
||||
ret.uuid_.len = ESP_UUID_LEN_128;
|
||||
int n = 0;
|
||||
for (size_t i = 0; i < length; i += 2) {
|
||||
if (data[i] == '-')
|
||||
i++;
|
||||
uint8_t msb = data[i];
|
||||
uint8_t lsb = data[i + 1];
|
||||
|
||||
if (msb > '9')
|
||||
msb -= 7;
|
||||
if (lsb > '9')
|
||||
lsb -= 7;
|
||||
ret.uuid_.uuid.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "ERROR: UUID value not 2, 4, 16 or 36 bytes - %s", data);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.uuid_.len = uuid.len;
|
||||
if (uuid.len == ESP_UUID_LEN_16) {
|
||||
ret.uuid_.uuid.uuid16 = uuid.uuid.uuid16;
|
||||
} else if (uuid.len == ESP_UUID_LEN_32) {
|
||||
ret.uuid_.uuid.uuid32 = uuid.uuid.uuid32;
|
||||
} else if (uuid.len == ESP_UUID_LEN_128) {
|
||||
memcpy(ret.uuid_.uuid.uuid128, uuid.uuid.uuid128, ESP_UUID_LEN_128);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
if (this->uuid_.len == ESP_UUID_LEN_128) {
|
||||
return *this;
|
||||
}
|
||||
uint8_t data[] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
uint32_t uuid32;
|
||||
if (this->uuid_.len == ESP_UUID_LEN_32) {
|
||||
uuid32 = this->uuid_.uuid.uuid32;
|
||||
} else {
|
||||
uuid32 = this->uuid_.uuid.uuid16;
|
||||
}
|
||||
for (uint16_t i = 0; i < this->uuid_.len; i++) {
|
||||
data[12 + i] = ((uuid32 >> i * 8) & 0xFF);
|
||||
}
|
||||
return ESPBTUUID::from_raw(data);
|
||||
}
|
||||
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
|
||||
if (this->uuid_.len == ESP_UUID_LEN_16) {
|
||||
return (this->uuid_.uuid.uuid16 >> 8) == data2 && (this->uuid_.uuid.uuid16 & 0xFF) == data1;
|
||||
} else if (this->uuid_.len == ESP_UUID_LEN_32) {
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
bool a = ((this->uuid_.uuid.uuid32 >> i * 8) & 0xFF) == data1;
|
||||
bool b = ((this->uuid_.uuid.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
|
||||
if (a && b)
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
for (uint8_t i = 0; i < 15; i++) {
|
||||
if (this->uuid_.uuid.uuid128[i] == data1 && this->uuid_.uuid.uuid128[i + 1] == data2)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const {
|
||||
if (this->uuid_.len == uuid.uuid_.len) {
|
||||
switch (this->uuid_.len) {
|
||||
case ESP_UUID_LEN_16:
|
||||
return this->uuid_.uuid.uuid16 == uuid.uuid_.uuid.uuid16;
|
||||
case ESP_UUID_LEN_32:
|
||||
return this->uuid_.uuid.uuid32 == uuid.uuid_.uuid.uuid32;
|
||||
case ESP_UUID_LEN_128:
|
||||
return memcmp(this->uuid_.uuid.uuid128, uuid.uuid_.uuid.uuid128, ESP_UUID_LEN_128) == 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return this->as_128bit() == uuid.as_128bit();
|
||||
}
|
||||
esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; }
|
||||
const char *ESPBTUUID::to_str(std::span<char, UUID_STR_LEN> output) const {
|
||||
char *pos = output.data();
|
||||
|
||||
switch (this->uuid_.len) {
|
||||
case ESP_UUID_LEN_16:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F);
|
||||
*pos = '\0';
|
||||
return output.data();
|
||||
|
||||
case ESP_UUID_LEN_32:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
for (int shift = 28; shift >= 0; shift -= 4) {
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F);
|
||||
}
|
||||
*pos = '\0';
|
||||
return output.data();
|
||||
|
||||
default:
|
||||
case ESP_UUID_LEN_128:
|
||||
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
for (int8_t i = 15; i >= 0; i--) {
|
||||
uint8_t byte = this->uuid_.uuid.uuid128[i];
|
||||
*pos++ = format_hex_pretty_char(byte >> 4);
|
||||
*pos++ = format_hex_pretty_char(byte & 0x0F);
|
||||
if (i == 12 || i == 10 || i == 8 || i == 6) {
|
||||
*pos++ = '-';
|
||||
}
|
||||
}
|
||||
*pos = '\0';
|
||||
return output.data();
|
||||
}
|
||||
}
|
||||
} // namespace esphome::esp32_ble
|
||||
|
||||
#endif // USE_ESP32_BLE_UUID
|
||||
#endif // USE_ESP32
|
||||
@@ -1,56 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP32_BLE_UUID
|
||||
|
||||
#include <initializer_list>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <esp_bt_defs.h>
|
||||
// The BLE UUID type is owned by the platform-neutral ble_device_base layer;
|
||||
// this header re-exports it under the historical esp32_ble name (esp32 only).
|
||||
// The full historical API surface — including from_uuid()/get_uuid() with the
|
||||
// ESP-IDF esp_bt_uuid_t type — is preserved on esp32 builds.
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::esp32_ble {
|
||||
|
||||
/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0"
|
||||
static constexpr size_t UUID_STR_LEN = 37;
|
||||
|
||||
class ESPBTUUID {
|
||||
public:
|
||||
ESPBTUUID();
|
||||
|
||||
static ESPBTUUID from_uint16(uint16_t uuid);
|
||||
|
||||
static ESPBTUUID from_uint32(uint32_t uuid);
|
||||
|
||||
static ESPBTUUID from_raw(const uint8_t *data);
|
||||
static ESPBTUUID from_raw_reversed(const uint8_t *data);
|
||||
|
||||
static ESPBTUUID from_raw(const char *data, size_t length);
|
||||
static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); }
|
||||
static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); }
|
||||
static ESPBTUUID from_raw(std::initializer_list<uint8_t> data) {
|
||||
return from_raw(reinterpret_cast<const char *>(data.begin()), data.size());
|
||||
}
|
||||
|
||||
static ESPBTUUID from_uuid(esp_bt_uuid_t uuid);
|
||||
|
||||
ESPBTUUID as_128bit() const;
|
||||
|
||||
bool contains(uint8_t data1, uint8_t data2) const;
|
||||
|
||||
bool operator==(const ESPBTUUID &uuid) const;
|
||||
bool operator!=(const ESPBTUUID &uuid) const { return !(*this == uuid); }
|
||||
|
||||
esp_bt_uuid_t get_uuid() const;
|
||||
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const;
|
||||
|
||||
protected:
|
||||
esp_bt_uuid_t uuid_;
|
||||
};
|
||||
using ble_device_base::UUID_STR_LEN;
|
||||
using ESPBTUUID = ble_device_base::ESPBTUUID;
|
||||
|
||||
} // namespace esphome::esp32_ble
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, ota
|
||||
from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
request_bluetooth,
|
||||
@@ -39,7 +39,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble"]
|
||||
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
CODEOWNERS = ["@bdraco"]
|
||||
|
||||
@@ -93,6 +93,7 @@ def register_ble_features(features: set[BLEFeatures]) -> None:
|
||||
esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker")
|
||||
ESP32BLETracker = esp32_ble_tracker_ns.class_(
|
||||
"ESP32BLETracker",
|
||||
ble_device_base.BLEHub,
|
||||
cg.Component,
|
||||
cg.Parented.template(esp32_ble.ESP32BLE),
|
||||
)
|
||||
@@ -153,26 +154,11 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def as_hex(value):
|
||||
return cg.RawExpression(f"0x{value}ULL")
|
||||
|
||||
|
||||
def as_hex_array(value):
|
||||
value = value.replace("-", "")
|
||||
cpp_array = [
|
||||
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
||||
]
|
||||
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
|
||||
|
||||
|
||||
def as_reversed_hex_array(value):
|
||||
value = value.replace("-", "")
|
||||
cpp_array = [
|
||||
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
||||
]
|
||||
return cg.RawExpression(
|
||||
f"(uint8_t*)(const uint8_t[16]){{{','.join(reversed(cpp_array))}}}"
|
||||
)
|
||||
# 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
|
||||
as_hex_array = ble_device_base.as_hex_array
|
||||
as_reversed_hex_array = ble_device_base.as_reversed_hex_array
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
@@ -254,6 +240,10 @@ async def to_code(config):
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN)
|
||||
|
||||
# Behavior parity with the pre-split tracker: IRK resolution is always
|
||||
# available on esp32 (sensors with irk: worked without opting in).
|
||||
ble_device_base.request_irk_support()
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -346,6 +336,14 @@ async def to_code(config):
|
||||
async def _add_ble_features():
|
||||
# Add feature-specific defines based on what's needed
|
||||
required_features = _get_required_features()
|
||||
# Sensors registered through the neutral ble_device_base path (BLEHub) need
|
||||
# the parsed-device pipeline compiled in, exactly like esp32-path listeners.
|
||||
neutral_listener_count = ble_device_base.get_listener_count()
|
||||
if neutral_listener_count > 0:
|
||||
required_features.add(BLEFeatures.ESP_BT_DEVICE)
|
||||
# StaticVector sizing for the neutral (BLEHub) listener list — same
|
||||
# pattern as the esp32-path registration counts below.
|
||||
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count)
|
||||
if BLEFeatures.ESP_BT_DEVICE in required_features:
|
||||
cg.add_define("USE_ESP32_BLE_DEVICE")
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
#include <esp_coexist.h>
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
#ifdef USE_BLE_TRACKER_PSA_AES
|
||||
#include <psa/crypto.h>
|
||||
#else
|
||||
#define MBEDTLS_AES_ALT
|
||||
#include <aes_alt.h>
|
||||
#endif
|
||||
#endif // USE_ESP32_BLE_DEVICE
|
||||
|
||||
// bt_trace.h
|
||||
#undef TAG
|
||||
|
||||
@@ -43,9 +34,6 @@ namespace esphome::esp32_ble_tracker {
|
||||
|
||||
static const char *const TAG = "esp32_ble_tracker";
|
||||
|
||||
// BLE advertisement max: 31 bytes adv data + 31 bytes scan response
|
||||
static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62;
|
||||
|
||||
ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
const char *client_state_to_string(ClientState state) {
|
||||
@@ -263,10 +251,14 @@ void ESP32BLETracker::start_scan_(bool first) {
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->neutral_listeners_)
|
||||
listener->on_scan_end();
|
||||
#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;
|
||||
@@ -304,6 +296,21 @@ void ESP32BLETracker::register_client(ESPBTClient *client) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *listener) {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Neutral BLEHub path (migrated sensors): parsed-advertisement consumers only.
|
||||
this->neutral_listeners_.push_back(listener);
|
||||
this->parse_advertisements_ = true;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) {
|
||||
get_mac_address_raw(out); // WiFi base MAC, MSB-first
|
||||
// BT MAC = base MAC + 2 on the last octet only, wrapping without carry —
|
||||
// exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET.
|
||||
out[5] += 2;
|
||||
}
|
||||
|
||||
void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) {
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
listener->set_parent(this);
|
||||
@@ -315,6 +322,13 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) {
|
||||
void ESP32BLETracker::recalculate_advertisement_parser_types() {
|
||||
this->raw_advertisements_ = false;
|
||||
this->parse_advertisements_ = false;
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in
|
||||
// listeners_; without this, any later esp32-path registration (e.g. the proxy's
|
||||
// GATT clients) would recompute the flags and silently drop parsed dispatch.
|
||||
if (!this->neutral_listeners_.empty())
|
||||
this->parse_advertisements_ = true;
|
||||
#endif
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_) {
|
||||
if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
|
||||
@@ -434,270 +448,6 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); }
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
|
||||
if (!data.uuid.contains(0x4C, 0x00))
|
||||
return {};
|
||||
|
||||
if (data.data.size() != 23)
|
||||
return {};
|
||||
return ESPBLEiBeacon(data.data.data());
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) {
|
||||
this->scan_result_ = &scan_result;
|
||||
for (uint8_t i = 0; i < ESP_BD_ADDR_LEN; i++)
|
||||
this->address_[i] = scan_result.bda[i];
|
||||
this->address_type_ = static_cast<esp_ble_addr_type_t>(scan_result.ble_addr_type);
|
||||
this->rssi_ = scan_result.rssi;
|
||||
|
||||
// Parse advertisement data directly
|
||||
uint8_t total_len = scan_result.adv_data_len + scan_result.scan_rsp_len;
|
||||
this->parse_adv_(scan_result.ble_adv, total_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;
|
||||
}
|
||||
ESP_LOGVV(TAG, " Address: %02X:%02X:%02X:%02X:%02X:%02X (%s)", this->address_[0], this->address_[1],
|
||||
this->address_[2], this->address_[3], this->address_[4], this->address_[5], address_type);
|
||||
|
||||
ESP_LOGVV(TAG, " RSSI: %d", this->rssi_);
|
||||
ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str());
|
||||
for (auto &it : this->tx_powers_) {
|
||||
ESP_LOGVV(TAG, " TX Power: %d", it);
|
||||
}
|
||||
if (this->appearance_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_);
|
||||
}
|
||||
if (this->ad_flag_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_);
|
||||
}
|
||||
for (auto &uuid : this->service_uuids_) {
|
||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||
uuid.to_str(uuid_buf);
|
||||
ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf);
|
||||
}
|
||||
char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)];
|
||||
for (auto &data : this->manufacturer_datas_) {
|
||||
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data);
|
||||
if (ibeacon.has_value()) {
|
||||
ESP_LOGVV(TAG, " Manufacturer iBeacon:");
|
||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||
ibeacon.value().get_uuid().to_str(uuid_buf);
|
||||
ESP_LOGVV(TAG, " UUID: %s", 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());
|
||||
} else {
|
||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||
data.uuid.to_str(uuid_buf);
|
||||
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", uuid_buf,
|
||||
format_hex_pretty_to(hex_buf, data.data.data(), data.data.size()));
|
||||
}
|
||||
}
|
||||
for (auto &data : this->service_datas_) {
|
||||
ESP_LOGVV(TAG, " Service data:");
|
||||
char uuid_buf[esp32_ble::UUID_STR_LEN];
|
||||
data.uuid.to_str(uuid_buf);
|
||||
ESP_LOGVV(TAG, " UUID: %s", uuid_buf);
|
||||
ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size()));
|
||||
}
|
||||
|
||||
ESP_LOGVV(TAG, " Adv data: %s",
|
||||
format_hex_pretty_to(hex_buf, scan_result.ble_adv, scan_result.adv_data_len + scan_result.scan_rsp_len));
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) {
|
||||
size_t offset = 0;
|
||||
|
||||
while (offset + 2 < len) {
|
||||
const uint8_t field_length = payload[offset++]; // First byte is length of adv record
|
||||
if (field_length == 0) {
|
||||
continue; // Possible zero padded advertisement data
|
||||
}
|
||||
|
||||
// Validate field fits in remaining payload
|
||||
if (offset + field_length > len) {
|
||||
break;
|
||||
}
|
||||
|
||||
// first byte of adv record is adv record type
|
||||
const uint8_t record_type = payload[offset++];
|
||||
const uint8_t *record = &payload[offset];
|
||||
const uint8_t record_length = field_length - 1;
|
||||
offset += record_length;
|
||||
|
||||
// See also Generic Access Profile Assigned Numbers:
|
||||
// https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ See also ADVERTISING AND SCAN
|
||||
// RESPONSE DATA FORMAT: https://www.bluetooth.com/specifications/bluetooth-core-specification/ (vol 3, part C, 11)
|
||||
// See also Core Specification Supplement: https://www.bluetooth.com/specifications/bluetooth-core-specification/
|
||||
// (called CSS here)
|
||||
|
||||
switch (record_type) {
|
||||
case ESP_BLE_AD_TYPE_NAME_SHORT:
|
||||
case ESP_BLE_AD_TYPE_NAME_CMPL: {
|
||||
// CSS 1.2 LOCAL NAME
|
||||
// "The Local Name data type shall be the same as, or a shortened version of, the local name assigned to the
|
||||
// device." CSS 1: Optional in this context; shall not appear more than once in a block.
|
||||
// SHORTENED LOCAL NAME
|
||||
// "The Shortened Local Name data type defines a shortened version of the Local Name data type. The Shortened
|
||||
// Local Name data type shall not be used to advertise a name that is longer than the Local Name data type."
|
||||
if (record_length > this->name_.length()) {
|
||||
this->name_ = std::string(reinterpret_cast<const char *>(record), record_length);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_TX_PWR: {
|
||||
// CSS 1.5 TX POWER LEVEL
|
||||
// "The TX Power Level data type indicates the transmitted power level of the packet containing the data type."
|
||||
// CSS 1: Optional in this context (may appear more than once in a block).
|
||||
this->tx_powers_.push_back(*record);
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_APPEARANCE: {
|
||||
// CSS 1.12 APPEARANCE
|
||||
// "The Appearance data type defines the external appearance of the device."
|
||||
// See also https://www.bluetooth.com/specifications/gatt/characteristics/
|
||||
// CSS 1: Optional in this context; shall not appear more than once in a block and shall not appear in both
|
||||
// the AD and SRD of the same extended advertising interval.
|
||||
this->appearance_ = *reinterpret_cast<const uint16_t *>(record);
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_FLAG: {
|
||||
// CSS 1.3 FLAGS
|
||||
// "The Flags data type contains one bit Boolean flags. The Flags data type shall be included when any of the
|
||||
// Flag bits are non-zero and the advertising packet is connectable, otherwise the Flags data type may be
|
||||
// omitted."
|
||||
// CSS 1: Optional in this context; shall not appear more than once in a block.
|
||||
this->ad_flag_ = *record;
|
||||
break;
|
||||
}
|
||||
// CSS 1.1 SERVICE UUID
|
||||
// The Service UUID data type is used to include a list of Service or Service Class UUIDs.
|
||||
// There are six data types defined for the three sizes of Service UUIDs that may be returned:
|
||||
// CSS 1: Optional in this context (may appear more than once in a block).
|
||||
case ESP_BLE_AD_TYPE_16SRV_CMPL:
|
||||
case ESP_BLE_AD_TYPE_16SRV_PART: {
|
||||
// • 16-bit Bluetooth Service UUIDs
|
||||
for (uint8_t i = 0; i < record_length / 2; i++) {
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint16(*reinterpret_cast<const uint16_t *>(record + 2 * i)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_32SRV_CMPL:
|
||||
case ESP_BLE_AD_TYPE_32SRV_PART: {
|
||||
// • 32-bit Bluetooth Service UUIDs
|
||||
for (uint8_t i = 0; i < record_length / 4; i++) {
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint32(*reinterpret_cast<const uint32_t *>(record + 4 * i)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_128SRV_CMPL:
|
||||
case ESP_BLE_AD_TYPE_128SRV_PART: {
|
||||
// • Global 128-bit Service UUIDs
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_raw(record));
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE: {
|
||||
// CSS 1.4 MANUFACTURER SPECIFIC DATA
|
||||
// "The Manufacturer Specific data type is used for manufacturer specific data. The first two data octets shall
|
||||
// contain a company identifier from Assigned Numbers. The interpretation of any other octets within the data
|
||||
// shall be defined by the manufacturer specified by the company identifier."
|
||||
// CSS 1: Optional in this context (may appear more than once in a block).
|
||||
if (record_length < 2) {
|
||||
ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE");
|
||||
break;
|
||||
}
|
||||
ServiceData data{};
|
||||
data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast<const uint16_t *>(record));
|
||||
data.data.assign(record + 2UL, record + record_length);
|
||||
this->manufacturer_datas_.push_back(data);
|
||||
break;
|
||||
}
|
||||
|
||||
// CSS 1.11 SERVICE DATA
|
||||
// "The Service Data data type consists of a service UUID with the data associated with that service."
|
||||
// CSS 1: Optional in this context (may appear more than once in a block).
|
||||
case ESP_BLE_AD_TYPE_SERVICE_DATA: {
|
||||
// «Service Data - 16 bit UUID»
|
||||
// Size: 2 or more octets
|
||||
// The first 2 octets contain the 16 bit Service UUID fol- lowed by additional service data
|
||||
if (record_length < 2) {
|
||||
ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_SERVICE_DATA");
|
||||
break;
|
||||
}
|
||||
ServiceData data{};
|
||||
data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast<const uint16_t *>(record));
|
||||
data.data.assign(record + 2UL, record + record_length);
|
||||
this->service_datas_.push_back(data);
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_32SERVICE_DATA: {
|
||||
// «Service Data - 32 bit UUID»
|
||||
// Size: 4 or more octets
|
||||
// The first 4 octets contain the 32 bit Service UUID fol- lowed by additional service data
|
||||
if (record_length < 4) {
|
||||
ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_32SERVICE_DATA");
|
||||
break;
|
||||
}
|
||||
ServiceData data{};
|
||||
data.uuid = ESPBTUUID::from_uint32(*reinterpret_cast<const uint32_t *>(record));
|
||||
data.data.assign(record + 4UL, record + record_length);
|
||||
this->service_datas_.push_back(data);
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_128SERVICE_DATA: {
|
||||
// «Service Data - 128 bit UUID»
|
||||
// Size: 16 or more octets
|
||||
// The first 16 octets contain the 128 bit Service UUID followed by additional service data
|
||||
if (record_length < 16) {
|
||||
ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_128SERVICE_DATA");
|
||||
break;
|
||||
}
|
||||
ServiceData data{};
|
||||
data.uuid = ESPBTUUID::from_raw(record);
|
||||
data.data.assign(record + 16UL, record + record_length);
|
||||
this->service_datas_.push_back(data);
|
||||
break;
|
||||
}
|
||||
case ESP_BLE_AD_TYPE_INT_RANGE:
|
||||
// Avoid logging this as it's very verbose
|
||||
break;
|
||||
default: {
|
||||
ESP_LOGV(TAG, "Unhandled type: advType: 0x%02x", record_type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string ESPBTDevice::address_str() const {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return this->address_str_to(buf);
|
||||
}
|
||||
|
||||
uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); }
|
||||
#endif // USE_ESP32_BLE_DEVICE
|
||||
|
||||
void ESP32BLETracker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "BLE Tracker:");
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -721,102 +471,12 @@ 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);
|
||||
}
|
||||
|
||||
bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
|
||||
static constexpr size_t AES_BLOCK_SIZE = 16;
|
||||
static constexpr size_t AES_KEY_BITS = 128;
|
||||
|
||||
uint8_t ecb_key[AES_BLOCK_SIZE];
|
||||
uint8_t ecb_plaintext[AES_BLOCK_SIZE];
|
||||
uint8_t ecb_ciphertext[AES_BLOCK_SIZE];
|
||||
|
||||
uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_);
|
||||
|
||||
memcpy(&ecb_key, irk, AES_BLOCK_SIZE);
|
||||
memset(&ecb_plaintext, 0, AES_BLOCK_SIZE);
|
||||
|
||||
ecb_plaintext[13] = (addr64 >> 40) & 0xff;
|
||||
ecb_plaintext[14] = (addr64 >> 32) & 0xff;
|
||||
ecb_plaintext[15] = (addr64 >> 24) & 0xff;
|
||||
|
||||
#ifdef USE_BLE_TRACKER_PSA_AES
|
||||
// Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+)
|
||||
psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT;
|
||||
psa_set_key_type(&attributes, PSA_KEY_TYPE_AES);
|
||||
psa_set_key_bits(&attributes, AES_KEY_BITS);
|
||||
psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT);
|
||||
psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING);
|
||||
|
||||
mbedtls_svc_key_id_t key_id;
|
||||
if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t output_length;
|
||||
psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE,
|
||||
ecb_ciphertext, AES_BLOCK_SIZE, &output_length);
|
||||
psa_destroy_key(key_id);
|
||||
if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
// Use legacy mbedtls AES API (IDF < 6.0)
|
||||
mbedtls_aes_context ctx = {0, 0, {0}};
|
||||
mbedtls_aes_init(&ctx);
|
||||
|
||||
if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) {
|
||||
mbedtls_aes_free(&ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mbedtls_aes_crypt_ecb(&ctx, ESP_AES_ENCRYPT, ecb_plaintext, ecb_ciphertext) != 0) {
|
||||
mbedtls_aes_free(&ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
mbedtls_aes_free(&ctx);
|
||||
#endif
|
||||
|
||||
return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) &&
|
||||
ecb_ciphertext[13] == ((addr64 >> 16) & 0xff);
|
||||
}
|
||||
// resolve_irk() is provided by ble_device_base (portable software AES).
|
||||
|
||||
#endif // USE_ESP32_BLE_DEVICE
|
||||
|
||||
@@ -848,6 +508,12 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
|
||||
found = true;
|
||||
}
|
||||
#endif
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->neutral_listeners_) {
|
||||
if (listener->parse_device(device))
|
||||
found = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
for (auto *client : this->clients_) {
|
||||
@@ -867,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;
|
||||
@@ -876,6 +542,10 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->neutral_listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
|
||||
this->set_scanner_state_(ScannerState::IDLE);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API.
|
||||
// Use the PSA Crypto API instead.
|
||||
#define USE_BLE_TRACKER_PSA_AES
|
||||
#endif
|
||||
|
||||
#include <esp_bt_defs.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
@@ -26,6 +19,8 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/components/esp32_ble/ble_uuid.h"
|
||||
#include "esphome/components/esp32_ble/ble_scan_result.h"
|
||||
@@ -38,7 +33,7 @@ namespace esphome::esp32_ble_tracker {
|
||||
|
||||
using namespace esp32_ble;
|
||||
|
||||
using adv_data_t = std::vector<uint8_t>;
|
||||
using adv_data_t = ble_device_base::adv_data_t;
|
||||
|
||||
enum AdvertisementParserType {
|
||||
PARSED_ADVERTISEMENTS,
|
||||
@@ -46,105 +41,27 @@ enum AdvertisementParserType {
|
||||
};
|
||||
|
||||
#ifdef USE_ESP32_BLE_UUID
|
||||
struct ServiceData {
|
||||
ESPBTUUID uuid;
|
||||
adv_data_t data;
|
||||
};
|
||||
using ServiceData = ble_device_base::ServiceData;
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
class ESPBLEiBeacon {
|
||||
public:
|
||||
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
|
||||
ESPBLEiBeacon(const uint8_t *data);
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
|
||||
|
||||
uint16_t get_major() { return byteswap(this->beacon_data_.major); }
|
||||
uint16_t get_minor() { return byteswap(this->beacon_data_.minor); }
|
||||
int8_t get_signal_power() { return this->beacon_data_.signal_power; }
|
||||
ESPBTUUID get_uuid() { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
|
||||
|
||||
protected:
|
||||
struct {
|
||||
uint8_t sub_type;
|
||||
uint8_t length;
|
||||
uint8_t proximity_uuid[16];
|
||||
uint16_t major;
|
||||
uint16_t minor;
|
||||
int8_t signal_power;
|
||||
} PACKED beacon_data_;
|
||||
};
|
||||
|
||||
class ESPBTDevice {
|
||||
public:
|
||||
void parse_scan_rst(const BLEScanResult &scan_result);
|
||||
|
||||
std::string address_str() const;
|
||||
|
||||
/// Format MAC address into provided buffer, returns pointer to buffer for convenience
|
||||
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
|
||||
format_mac_addr_upper(this->address_, buf.data());
|
||||
return buf.data();
|
||||
}
|
||||
|
||||
uint64_t address_uint64() const;
|
||||
|
||||
const uint8_t *address() const { return address_; }
|
||||
|
||||
esp_ble_addr_type_t get_address_type() const { return this->address_type_; }
|
||||
int get_rssi() const { return rssi_; }
|
||||
const std::string &get_name() const { return this->name_; }
|
||||
|
||||
const std::vector<int8_t> &get_tx_powers() const { return tx_powers_; }
|
||||
|
||||
const optional<uint16_t> &get_appearance() const { return appearance_; }
|
||||
const optional<uint8_t> &get_ad_flag() const { return ad_flag_; }
|
||||
const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
|
||||
|
||||
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
|
||||
|
||||
const std::vector<ServiceData> &get_service_datas() const { return service_datas_; }
|
||||
|
||||
// Exposed through a function for use in lambdas
|
||||
const BLEScanResult &get_scan_result() const { return *scan_result_; }
|
||||
|
||||
bool resolve_irk(const uint8_t *irk) const;
|
||||
|
||||
optional<ESPBLEiBeacon> get_ibeacon() const {
|
||||
for (auto &it : this->manufacturer_datas_) {
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
protected:
|
||||
void parse_adv_(const uint8_t *payload, uint8_t len);
|
||||
|
||||
esp_bd_addr_t address_{
|
||||
0,
|
||||
};
|
||||
esp_ble_addr_type_t address_type_{BLE_ADDR_TYPE_PUBLIC};
|
||||
int rssi_{0};
|
||||
std::string name_{};
|
||||
std::vector<int8_t> tx_powers_{};
|
||||
optional<uint16_t> appearance_{};
|
||||
optional<uint8_t> ad_flag_{};
|
||||
std::vector<ESPBTUUID> service_uuids_{};
|
||||
std::vector<ServiceData> manufacturer_datas_{};
|
||||
std::vector<ServiceData> service_datas_{};
|
||||
const BLEScanResult *scan_result_{nullptr};
|
||||
};
|
||||
// The advertisement device types are owned by the platform-neutral
|
||||
// ble_device_base layer; re-exported here (esp32 only) for backward
|
||||
// compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult.
|
||||
using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon;
|
||||
using ESPBTDevice = ble_device_base::ESPBTDevice;
|
||||
#endif // USE_ESP32_BLE_DEVICE
|
||||
|
||||
class ESP32BLETracker;
|
||||
|
||||
class ESPBTDeviceListener {
|
||||
// esp32-flavored listener: the neutral parse_device/on_scan_end come from
|
||||
// ble_device_base; this subclass adds the esp32-only raw-advertisement path
|
||||
// (BLEScanResult batches) and the tracker back-pointer.
|
||||
class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
virtual void on_scan_end() {}
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
virtual bool parse_device(const ESPBTDevice &device) = 0;
|
||||
#ifndef USE_ESP32_BLE_DEVICE
|
||||
// Raw-only build: no parsed-device support is compiled in.
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
|
||||
#endif
|
||||
virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; };
|
||||
virtual AdvertisementParserType get_advertisement_parser_type() {
|
||||
@@ -295,6 +212,7 @@ class ESPBTClient : public ESPBTDeviceListener {
|
||||
};
|
||||
|
||||
class ESP32BLETracker final : public Component,
|
||||
public ble_device_base::BLEHub,
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
public ota::OTAGlobalStateListener,
|
||||
#endif
|
||||
@@ -314,10 +232,23 @@ class ESP32BLETracker final : public Component,
|
||||
|
||||
void loop() override;
|
||||
|
||||
// esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer).
|
||||
void register_listener(ESPBTDeviceListener *listener);
|
||||
void register_client(ESPBTClient *client);
|
||||
void recalculate_advertisement_parser_types();
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
ble_device_base::HubCapabilities get_capabilities() const override {
|
||||
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true};
|
||||
}
|
||||
void get_adapter_mac(uint8_t out[6]) override;
|
||||
bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; }
|
||||
bool scan_active() override { return this->scan_active_; }
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
void print_bt_device_info(const ESPBTDevice &device);
|
||||
#endif
|
||||
@@ -405,9 +336,15 @@ class ESP32BLETracker final : public Component,
|
||||
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
|
||||
#endif
|
||||
std::vector<BLEScannerStateListener *> scanner_state_listeners_;
|
||||
// Parsed listeners registered through the neutral BLEHub contract (migrated
|
||||
// sensors); dispatched alongside listeners_.
|
||||
#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};
|
||||
#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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
@@ -20,9 +21,16 @@ from esphome.const import (
|
||||
PLATFORM_ESP8266,
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
CoroPriority,
|
||||
EsphomeError,
|
||||
Lambda,
|
||||
coroutine_with_priority,
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
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
|
||||
@@ -237,6 +245,32 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def check_rosetta() -> None:
|
||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||
|
||||
There is no native arm64 build of the xtensa-lx106 toolchain; on Apple
|
||||
Silicon it runs under Rosetta 2, which macOS updates can remove.
|
||||
"""
|
||||
if not IS_MACOS or platform.machine() != "arm64":
|
||||
return
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/arch", "-x86_64", "/usr/bin/true"],
|
||||
capture_output=True,
|
||||
close_fds=False,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
return # arch(1) unavailable; let the build proceed
|
||||
if result.returncode != 0:
|
||||
raise EsphomeError(
|
||||
"ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) "
|
||||
"compiler that requires Rosetta 2, which is not installed on "
|
||||
"this system. Install it with:\n"
|
||||
" softwareupdate --install-rosetta --agree-to-license"
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config):
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
@@ -261,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",
|
||||
@@ -410,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
|
||||
|
||||
@@ -4,7 +4,12 @@ import logging
|
||||
from esphome import automation, pins
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.network import add_use_address, ip_address_literal
|
||||
from esphome.components.network import (
|
||||
add_use_address,
|
||||
get_network_priority,
|
||||
get_priority_interfaces_from_full_config,
|
||||
ip_address_literal,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -50,7 +55,6 @@ from esphome.core import (
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONFLICTS_WITH = ["wifi"]
|
||||
AUTO_LOAD = ["network"]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -535,6 +539,14 @@ def phy_register(address: int, value: int, page: int):
|
||||
@coroutine_with_priority(CoroPriority.COMMUNICATION)
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
# Apply network priority before register_component (which emits the user's
|
||||
# explicit setup_priority: if set) so that, as in wifi, an explicit
|
||||
# setup_priority: still wins over the network-priority-derived value.
|
||||
prio = get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.set_setup_priority(var, prio)
|
||||
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if CORE.is_esp32:
|
||||
@@ -644,8 +656,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler, which disables the
|
||||
# WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi.
|
||||
# Register Ethernet with the esp32 sdkconfig reconciler. It disables the
|
||||
# WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi,
|
||||
# so multi-interface configs (network: priority: with both) keep WiFi.
|
||||
request_ethernet()
|
||||
|
||||
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
|
||||
@@ -732,6 +745,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
"""Final validation for Ethernet component."""
|
||||
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
|
||||
if "wifi" in fv.full_config.get():
|
||||
priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get())
|
||||
missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces]
|
||||
if missing and priority_ifaces:
|
||||
# A priority list exists but is incomplete: point at what to add.
|
||||
raise cv.Invalid(
|
||||
"When ethernet and wifi are used together, 'network: priority:' must "
|
||||
f"list both interfaces; missing: {', '.join(missing)}"
|
||||
)
|
||||
if missing:
|
||||
raise cv.Invalid(
|
||||
"Component ethernet cannot be used together with component wifi "
|
||||
"unless both are listed under 'network: priority:'"
|
||||
)
|
||||
|
||||
_final_validate_spi(config)
|
||||
_final_validate_rmii_pins(config)
|
||||
return config
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -488,10 +488,10 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
|
||||
body = this->body_.value(x...);
|
||||
}
|
||||
if (!this->json_.empty()) {
|
||||
body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); });
|
||||
body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); });
|
||||
}
|
||||
if (this->json_func_ != nullptr) {
|
||||
body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); });
|
||||
body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); });
|
||||
}
|
||||
std::vector<Header> request_headers;
|
||||
request_headers.reserve(this->request_headers_.size());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -580,8 +580,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
|
||||
)
|
||||
|
||||
@@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() {
|
||||
this->set_flag_(FLAG_HAS_STATE);
|
||||
}
|
||||
|
||||
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
|
||||
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
|
||||
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
|
||||
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
|
||||
if (brightness == 0.0f) {
|
||||
this->brightness_ = 1.0f;
|
||||
this->set_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) {
|
||||
this->brightness_ = 1.0f;
|
||||
this->set_flag_(FLAG_HAS_BRIGHTNESS);
|
||||
}
|
||||
|
||||
// Set color brightness to 100% if currently zero and a color is set.
|
||||
|
||||
@@ -71,6 +71,14 @@ void LightState::setup() {
|
||||
break;
|
||||
}
|
||||
|
||||
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
|
||||
// state is on but its brightness is zero (e.g. a stale/persisted value from before a
|
||||
// forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on),
|
||||
// reset it to full brightness.
|
||||
if (recovered.state && recovered.brightness == 0.0f) {
|
||||
recovered.brightness = 1.0f;
|
||||
}
|
||||
|
||||
call.set_color_mode_if_supported(recovered.color_mode);
|
||||
call.set_state(recovered.state);
|
||||
call.set_brightness_if_supported(recovered.brightness);
|
||||
|
||||
@@ -2,6 +2,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from esphome import automation, external_files, git
|
||||
@@ -9,6 +10,7 @@ from esphome.automation import register_action, register_condition
|
||||
from esphome.bundle import add_bundle_file
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, microphone, ota, psram
|
||||
from esphome.components.http_request import validate_url
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FILE,
|
||||
@@ -209,33 +211,13 @@ def _validate_manifest_version(manifest_data):
|
||||
raise cv.Invalid("Invalid manifest file, missing 'version' key")
|
||||
|
||||
|
||||
def _process_http_source(config):
|
||||
url = config[CONF_URL]
|
||||
path = _compute_local_file_path(config)
|
||||
|
||||
json_path = path / "manifest.json"
|
||||
|
||||
json_contents = external_files.download_content(url, json_path)
|
||||
|
||||
manifest_data = json.loads(json_contents)
|
||||
if not isinstance(manifest_data, dict):
|
||||
raise cv.Invalid("Manifest file must contain a JSON object")
|
||||
|
||||
model = manifest_data[CONF_MODEL]
|
||||
model_url = urljoin(url, model)
|
||||
|
||||
model_path = path / model
|
||||
|
||||
external_files.download_content(str(model_url), model_path)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
HTTP_SCHEMA = cv.All(
|
||||
HTTP_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
},
|
||||
_process_http_source,
|
||||
# validate_url only accepts http(s); the shorthand validator relies
|
||||
# on this branch rejecting git shorthands ("github://...") so they
|
||||
# fall through to the git branch.
|
||||
cv.Required(CONF_URL): validate_url,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -280,6 +262,13 @@ LOCAL_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
# Bare model names in the official model repository ("okay_nabu"). Must not
|
||||
# overlap with local paths, http(s) urls, or git shorthands
|
||||
# ("github://user/repo/file.json@ref"), which the shorthand validator tries
|
||||
# next; anything containing "/", ":" or "@" is not a model name.
|
||||
_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+")
|
||||
|
||||
|
||||
def _validate_source_model_name(value):
|
||||
if not isinstance(value, str):
|
||||
raise cv.Invalid("Model name must be a string")
|
||||
@@ -287,6 +276,9 @@ def _validate_source_model_name(value):
|
||||
if value.endswith(".json"):
|
||||
raise cv.Invalid("Model name must not end with .json")
|
||||
|
||||
if not _MODEL_NAME_RE.fullmatch(value):
|
||||
raise cv.Invalid("Model name may only contain letters, numbers, . _ -")
|
||||
|
||||
return MODEL_SOURCE_SCHEMA(
|
||||
{
|
||||
CONF_TYPE: TYPE_HTTP,
|
||||
@@ -376,6 +368,58 @@ def _maybe_empty_vad_schema(value):
|
||||
return VAD_MODEL_SCHEMA(value)
|
||||
|
||||
|
||||
def _download_http_models(config: ConfigType) -> ConfigType:
|
||||
"""Download every http-sourced manifest and model file in two concurrent
|
||||
batches (all manifests, then all model files).
|
||||
|
||||
The model file's URL only becomes known once its manifest has been
|
||||
fetched and parsed, so the two stages cannot be merged into one batch.
|
||||
"""
|
||||
model_parameters = [*config[CONF_MODELS]]
|
||||
if vad := config.get(CONF_VAD):
|
||||
model_parameters.append(vad)
|
||||
# Keyed by cache path so a URL referenced twice is fetched and parsed once
|
||||
http_models: dict[Path, str] = {
|
||||
_compute_local_file_path(model_config): model_config[CONF_URL]
|
||||
for parameters in model_parameters
|
||||
if (model_config := parameters.get(CONF_MODEL)) is not None
|
||||
and model_config.get(CONF_TYPE) == TYPE_HTTP
|
||||
}
|
||||
if not http_models:
|
||||
return config
|
||||
|
||||
external_files.download_content_many(
|
||||
((url, path / "manifest.json") for path, url in http_models.items()),
|
||||
description="wake word manifest(s)",
|
||||
)
|
||||
|
||||
model_files: list[tuple[str, Path]] = []
|
||||
errors: list[cv.Invalid] = []
|
||||
for path, url in http_models.items():
|
||||
try:
|
||||
manifest_data = json.loads((path / "manifest.json").read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}"))
|
||||
continue
|
||||
if not isinstance(manifest_data, dict):
|
||||
errors.append(
|
||||
cv.Invalid(f"Manifest file at {url} must contain a JSON object")
|
||||
)
|
||||
continue
|
||||
model = manifest_data.get(CONF_MODEL)
|
||||
if not isinstance(model, str):
|
||||
errors.append(
|
||||
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
|
||||
)
|
||||
continue
|
||||
model_files.append((urljoin(url, model), path / model))
|
||||
if errors:
|
||||
raise cv.MultipleInvalid(errors)
|
||||
|
||||
external_files.download_content_many(model_files, description="wake word model(s)")
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -409,6 +453,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_download_http_models,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -385,10 +385,10 @@ class MipiSpi : public display::Display,
|
||||
* @param ptr The pointer to the pixel data
|
||||
* @param w Width of each line in bytes
|
||||
* @param h Height of the buffer in rows
|
||||
* @param pad Padding in bytes after each line
|
||||
* @param stride Total length of each line in bytes, including any padding
|
||||
*/
|
||||
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) {
|
||||
if (pad == 0) {
|
||||
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) {
|
||||
if (stride == w) {
|
||||
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
|
||||
this->write_array(ptr, w * h);
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
|
||||
@@ -405,7 +405,7 @@ class MipiSpi : public display::Display,
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) {
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8);
|
||||
}
|
||||
ptr += w + pad;
|
||||
ptr += stride;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,7 +423,7 @@ class MipiSpi : public display::Display,
|
||||
ptr += y_offset * (x_offset + w + x_pad) + x_offset;
|
||||
if constexpr (BUFFERPIXEL == DISPLAYPIXEL) {
|
||||
this->write_display_data_(reinterpret_cast<const uint8_t *>(ptr), w * sizeof(BUFFERTYPE), h,
|
||||
x_pad * sizeof(BUFFERTYPE));
|
||||
(x_offset + w + x_pad) * sizeof(BUFFERTYPE));
|
||||
} else {
|
||||
// type conversion required, do it in chunks
|
||||
uint8_t dbuffer[DISPLAYPIXEL * 48];
|
||||
@@ -459,14 +459,14 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
// buffer full? Flush.
|
||||
if (dptr == dbuffer + sizeof(dbuffer)) {
|
||||
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0);
|
||||
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer));
|
||||
dptr = dbuffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
// flush any remaining data
|
||||
if (dptr != dbuffer) {
|
||||
this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0);
|
||||
this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer);
|
||||
}
|
||||
}
|
||||
this->disable();
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -51,7 +51,7 @@ void ModbusClientHub::loop() {
|
||||
// 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];
|
||||
uint8_t expected_address = wfr.frame.address();
|
||||
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,
|
||||
@@ -214,11 +214,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,8 +257,16 @@ 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) {
|
||||
// Bounds contract, enforced by the parser (parse_modbus_server_frame_) rather than locally:
|
||||
// - pdu is never empty: helpers::server_pdu_length() returns at least MIN_PDU_SIZE (1) on every
|
||||
// branch, and find_custom_frame_end_() only ever lengthens the frame, so the PDU always holds
|
||||
// at least the function code.
|
||||
// - When the exception bit is set, pdu has at least 2 bytes: server_pdu_length() checks the
|
||||
// exception bit before anything else and pins those PDUs to 2 bytes, so the exception code
|
||||
// read below is always present.
|
||||
// Keep those guarantees in mind when changing server_pdu_length() or adding callers.
|
||||
void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) {
|
||||
const uint8_t function_code = pdu[0];
|
||||
if (!this->waiting_for_response_.has_value()) {
|
||||
ESP_LOGW(TAG,
|
||||
"Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send",
|
||||
@@ -269,8 +276,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct
|
||||
// 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];
|
||||
uint8_t expected_address = wfr.frame.address();
|
||||
uint8_t expected_function_code = wfr.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
|
||||
@@ -292,20 +299,23 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct
|
||||
return;
|
||||
} else { // We have a valid device waiting for this response
|
||||
|
||||
ModbusClientDevice *device = wfr.device;
|
||||
// Move the command out of the waiting slot so the request PDU stays alive for the callback.
|
||||
ModbusDeviceCommand command = std::move(this->waiting_for_response_.value());
|
||||
this->waiting_for_response_.reset();
|
||||
ModbusClientDevice *device = command.device;
|
||||
// The request PDU is the sent frame without the leading address and the trailing CRC.
|
||||
std::span<const uint8_t> request_pdu = command.frame.pdu();
|
||||
// Is it an error response?
|
||||
if (helpers::is_function_code_exception(function_code)) {
|
||||
uint8_t exception = len > 0 ? data[0] : 0;
|
||||
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_);
|
||||
if (device)
|
||||
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
|
||||
device->on_error(request_pdu, static_cast<ExceptionCode>(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));
|
||||
device->on_response(request_pdu, pdu);
|
||||
} 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_);
|
||||
@@ -314,7 +324,7 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct
|
||||
}
|
||||
}
|
||||
|
||||
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 +354,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 +373,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 +403,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 +415,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 +424,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 +432,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 +451,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()) {
|
||||
@@ -503,7 +513,7 @@ void ModbusClientHub::send_next_frame_() {
|
||||
this->waiting_for_response_ = std::move(command);
|
||||
} else {
|
||||
if (command.device)
|
||||
command.device->on_modbus_not_sent();
|
||||
command.device->on_not_sent();
|
||||
}
|
||||
|
||||
this->tx_buffer_.pop_front();
|
||||
@@ -553,7 +563,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,11 +571,10 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo
|
||||
this->send_raw_(raw_frame, 3);
|
||||
}
|
||||
|
||||
// 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();
|
||||
const bool retry = wfr.device->on_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)
|
||||
@@ -577,19 +586,28 @@ void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) {
|
||||
void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) {
|
||||
const ModbusFrame &frame = wfr.frame;
|
||||
if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
|
||||
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]);
|
||||
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address());
|
||||
if (wfr.device != nullptr)
|
||||
wfr.device->on_modbus_not_sent();
|
||||
wfr.device->on_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);
|
||||
this->tx_buffer_.emplace_back(wfr.device, frame.address(), frame.pdu());
|
||||
}
|
||||
|
||||
void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
|
||||
if (pdu_len == 0) {
|
||||
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
|
||||
void ModbusClientHub::send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device) {
|
||||
if (pdu.empty()) {
|
||||
if (device)
|
||||
device->on_modbus_not_sent();
|
||||
device->on_not_sent();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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, dropped: %" PRIu8 ":%zu bytes", address, pdu.size());
|
||||
if (device)
|
||||
device->on_not_sent();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -597,28 +615,29 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p
|
||||
#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);
|
||||
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);
|
||||
} 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));
|
||||
ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address,
|
||||
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
|
||||
if (device)
|
||||
device->on_modbus_not_sent();
|
||||
device->on_not_sent();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
|
||||
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.address() == 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) {
|
||||
if (this->waiting_for_response_.value().frame.address() == 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;
|
||||
@@ -644,10 +663,10 @@ void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) {
|
||||
void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device) {
|
||||
if (payload.size() < 2) {
|
||||
if (device)
|
||||
device->on_modbus_not_sent();
|
||||
device->on_not_sent();
|
||||
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
|
||||
@@ -699,4 +718,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 ? (static_cast<size_t>(count_or_value) + 7) / 8 : 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,6 +9,7 @@
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
@@ -39,6 +40,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,8 +67,8 @@ 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);
|
||||
bool send_frame_(const ModbusFrame &frame);
|
||||
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
|
||||
@@ -89,6 +97,10 @@ struct ModbusDeviceCommand {
|
||||
|
||||
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: a caller-supplied PDU, or an existing frame's own pdu() when re-queueing
|
||||
/// Callers must bound the PDU to MAX_PDU_SIZE
|
||||
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu)
|
||||
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())) {}
|
||||
};
|
||||
|
||||
class ModbusClientHub : public Modbus {
|
||||
@@ -104,13 +116,12 @@ 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);
|
||||
}
|
||||
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr);
|
||||
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);
|
||||
void clear_tx_queue_for_device(ModbusClientDevice *device);
|
||||
@@ -118,14 +129,12 @@ class ModbusClientHub : public Modbus {
|
||||
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.
|
||||
// Notify the waiting device of no response; re-queues the frame if on_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);
|
||||
|
||||
uint16_t send_wait_time_{2000};
|
||||
uint16_t turnaround_delay_ms_{0};
|
||||
@@ -146,8 +155,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,7 +163,7 @@ 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_;
|
||||
@@ -166,6 +174,9 @@ class ModbusServerHub : public Modbus {
|
||||
uint16_t deferred_payload_len_{0};
|
||||
};
|
||||
|
||||
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
|
||||
using ResponseStatus = std::optional<ExceptionCode>;
|
||||
|
||||
class ModbusClientDevice {
|
||||
public:
|
||||
ModbusClientDevice() = default;
|
||||
@@ -180,22 +191,144 @@ 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 no request could be sent (e.g. queue full, transmission blocked)
|
||||
/// Do not attempt to queue a command in this callback.
|
||||
/// (The on_modbus_* names are signature-identical renames, so the new defaults forward to the old
|
||||
/// virtuals: external devices overriding the old names keep working through the deprecation window.
|
||||
/// Remove the forwards together with the deprecated names.)
|
||||
virtual void on_not_sent() {
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
this->on_modbus_not_sent();
|
||||
#pragma GCC diagnostic pop
|
||||
}
|
||||
/// 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() {
|
||||
#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); }
|
||||
ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
|
||||
void send_raw(const std::vector<uint8_t> &payload) {
|
||||
if (payload.empty()) {
|
||||
this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled
|
||||
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() signals via on_not_sent().
|
||||
void read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities) {
|
||||
this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address,
|
||||
number_of_entities));
|
||||
}
|
||||
void read_input_registers(uint16_t start_address, uint16_t number_of_registers) {
|
||||
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers));
|
||||
}
|
||||
void read_holding_registers(uint16_t start_address, uint16_t number_of_registers) {
|
||||
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers));
|
||||
}
|
||||
void read_coils(uint16_t start_address, uint16_t number_of_coils) {
|
||||
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils));
|
||||
}
|
||||
void read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs) {
|
||||
this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs));
|
||||
}
|
||||
void write_single_register(uint16_t start_address, uint16_t value) {
|
||||
this->send_pdu(helpers::create_write_single_register_pdu(start_address, value));
|
||||
}
|
||||
void write_single_coil(uint16_t address, bool value) {
|
||||
this->send_pdu(helpers::create_write_single_coil_pdu(address, value));
|
||||
}
|
||||
void write_multiple_registers(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
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.
|
||||
void write_multiple_coils(uint16_t start_address, std::span<const bool> values) {
|
||||
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.
|
||||
void write_multiple_coils(uint16_t start_address, PackedBits bits) {
|
||||
this->send_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
}
|
||||
inline void clear_tx_queue_for_address(bool clear_sent = true) {
|
||||
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
|
||||
}
|
||||
@@ -207,18 +340,37 @@ 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 {
|
||||
auto payload = 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 +389,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 +400,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,65 @@ 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;
|
||||
/** 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>((this->count_ + 7) / 8, 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
|
||||
|
||||
@@ -7,74 +7,157 @@ namespace esphome::modbus::helpers {
|
||||
|
||||
static const char *const TAG = "modbus_helpers";
|
||||
|
||||
uint16_t server_frame_length(const uint8_t *frame, size_t size) {
|
||||
if (size < 2)
|
||||
return MIN_FRAME_SIZE;
|
||||
if (is_function_code_exception(frame[1])) {
|
||||
return 5; // address(1) + function(1) + exception(1) + CRC(2)
|
||||
uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
|
||||
if (size < MIN_PDU_SIZE)
|
||||
return MIN_PDU_SIZE;
|
||||
if (is_function_code_exception(frame[0])) {
|
||||
return 2; // function(1) + exception(1)
|
||||
}
|
||||
switch (static_cast<ModbusFunctionCode>(frame[1])) {
|
||||
case ModbusFunctionCode::READ_COILS:
|
||||
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
|
||||
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
|
||||
case ModbusFunctionCode::READ_INPUT_REGISTERS:
|
||||
// address(1) + function(1) + byte count(1) + data + CRC(2)
|
||||
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
|
||||
case ModbusFunctionCode::WRITE_SINGLE_COIL:
|
||||
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
|
||||
switch (static_cast<FunctionCode>(frame[0])) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
// function(1) + byte count(1) + data
|
||||
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
|
||||
case FunctionCode::WRITE_SINGLE_COIL:
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
return 5; // function(1) + output/register address(2) + value(2)
|
||||
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
|
||||
case ModbusFunctionCode::READ_FILE_RECORD:
|
||||
case ModbusFunctionCode::WRITE_FILE_RECORD:
|
||||
// address(1) + function(1) + byte count(1) + data + CRC(2)
|
||||
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
|
||||
case ModbusFunctionCode::MASK_WRITE_REGISTER:
|
||||
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
|
||||
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
// address(1) + function(1) + byte count(1) + data + CRC(2)
|
||||
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
|
||||
case ModbusFunctionCode::READ_FIFO_QUEUE:
|
||||
// address(1) + function(1) + fifo address(2) CRC(2)
|
||||
return 6;
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
case FunctionCode::WRITE_FILE_RECORD:
|
||||
// function(1) + byte count(1) + data
|
||||
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0);
|
||||
case FunctionCode::MASK_WRITE_REGISTER:
|
||||
return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
// function(1) + byte count(1) + data
|
||||
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
|
||||
case FunctionCode::READ_FIFO_QUEUE:
|
||||
// function(1) + fifo address(2)
|
||||
return 3;
|
||||
default:
|
||||
return MIN_FRAME_SIZE; // unknown length
|
||||
return MIN_PDU_SIZE; // unknown length
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t client_frame_length(const uint8_t *frame, size_t size) {
|
||||
if (size < 2)
|
||||
return MIN_FRAME_SIZE;
|
||||
switch (static_cast<ModbusFunctionCode>(frame[1])) {
|
||||
case ModbusFunctionCode::READ_COILS:
|
||||
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
|
||||
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
|
||||
case ModbusFunctionCode::READ_INPUT_REGISTERS:
|
||||
// address(1) + function(1) + start address(2) + quantity(2) + CRC(2)
|
||||
case ModbusFunctionCode::WRITE_SINGLE_COIL:
|
||||
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
|
||||
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
// address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2)
|
||||
return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
|
||||
uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
|
||||
if (size < MIN_PDU_SIZE)
|
||||
return MIN_PDU_SIZE;
|
||||
switch (static_cast<FunctionCode>(frame[0])) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
// function(1) + start address(2) + quantity(2)
|
||||
case FunctionCode::WRITE_SINGLE_COIL:
|
||||
case FunctionCode::WRITE_SINGLE_REGISTER:
|
||||
return 5; // function(1) + output/register address(2) + value(2)
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
// function(1) + start address(2) + quantity(2) + byte count(1) + data
|
||||
return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
|
||||
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
|
||||
case ModbusFunctionCode::READ_FILE_RECORD:
|
||||
case ModbusFunctionCode::WRITE_FILE_RECORD:
|
||||
// address(1) + function(1) + byte count(1) + data + CRC(2)
|
||||
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
|
||||
case ModbusFunctionCode::MASK_WRITE_REGISTER:
|
||||
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
|
||||
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
// address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) +
|
||||
// write quantity(2) + byte count(1) + data + CRC(2)
|
||||
return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
|
||||
case ModbusFunctionCode::READ_FIFO_QUEUE:
|
||||
// address(1) + function(1) + fifo address(2) CRC(2)
|
||||
return 6;
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
case FunctionCode::WRITE_FILE_RECORD:
|
||||
// function(1) + byte count(1) + data
|
||||
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0);
|
||||
case FunctionCode::MASK_WRITE_REGISTER:
|
||||
return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
// function(1) + read start address(2) + read quantity(2) + write start address(2) +
|
||||
// write quantity(2) + byte count(1) + data
|
||||
return 10 + (size > 9 ? std::min(frame[9], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2)) : 0);
|
||||
case FunctionCode::READ_FIFO_QUEUE:
|
||||
// function(1) + fifo address(2)
|
||||
return 3;
|
||||
default:
|
||||
return MIN_FRAME_SIZE; // unknown length
|
||||
return MIN_PDU_SIZE; // unknown length
|
||||
}
|
||||
}
|
||||
|
||||
bool is_server_pdu_standard(const uint8_t *pdu, size_t size) {
|
||||
if (server_pdu_length(pdu, size) != size)
|
||||
return false;
|
||||
|
||||
switch (static_cast<FunctionCode>(pdu[0])) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
// A conformant bit-read response carries at least one packed byte (up to 2000 bits = 250 bytes).
|
||||
return pdu[1] != 0 && pdu[1] <= uint8_t((MAX_NUM_OF_COILS_TO_READ + 7) / 8);
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
// Registers are 2 bytes each: the byte count must be a non-zero even count within the read maximum.
|
||||
return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2);
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
case FunctionCode::WRITE_FILE_RECORD:
|
||||
return pdu[1] <= uint8_t(MAX_PDU_SIZE - 2);
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
|
||||
return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2);
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
|
||||
// The response echoes start address and quantity: bound them like the request side does.
|
||||
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS;
|
||||
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
|
||||
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
|
||||
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
|
||||
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u;
|
||||
}
|
||||
case FunctionCode::WRITE_SINGLE_COIL:
|
||||
// The response echoes the request, so the same ON/OFF constraint applies.
|
||||
return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00;
|
||||
default:
|
||||
return true; // All other function codes validated by length alone
|
||||
}
|
||||
}
|
||||
|
||||
bool is_client_pdu_standard(const uint8_t *pdu, size_t size) {
|
||||
if (client_pdu_length(pdu, size) != size)
|
||||
return false;
|
||||
|
||||
switch (static_cast<FunctionCode>(pdu[0])) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS: {
|
||||
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::READ_COILS ||
|
||||
static_cast<FunctionCode>(pdu[0]) == FunctionCode::READ_DISCRETE_INPUTS;
|
||||
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
|
||||
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
|
||||
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ;
|
||||
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u;
|
||||
}
|
||||
case FunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
|
||||
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS;
|
||||
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
|
||||
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
|
||||
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
|
||||
// Coils are packed 8 per data byte; registers are 2 bytes each.
|
||||
const size_t expected_data_bytes = bits ? (static_cast<size_t>(quantity) + 7) / 8 : quantity * 2;
|
||||
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u &&
|
||||
pdu[5] == expected_data_bytes;
|
||||
}
|
||||
case FunctionCode::READ_FILE_RECORD:
|
||||
case FunctionCode::WRITE_FILE_RECORD:
|
||||
return pdu[1] <= MAX_PDU_SIZE - 2;
|
||||
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
|
||||
const uint16_t start_address_read = get_data<uint16_t>(pdu, 1);
|
||||
const uint16_t quantity_read = get_data<uint16_t>(pdu, 3);
|
||||
const uint16_t start_address_write = get_data<uint16_t>(pdu, 5);
|
||||
const uint16_t quantity_write = get_data<uint16_t>(pdu, 7);
|
||||
return quantity_read != 0 && quantity_read <= MAX_NUM_OF_REGISTERS_TO_READ && quantity_write != 0 &&
|
||||
quantity_write <= MAX_NUM_OF_REGISTERS_TO_WRITE_RW &&
|
||||
(uint32_t) start_address_read + quantity_read <= 0x10000u &&
|
||||
(uint32_t) start_address_write + quantity_write <= 0x10000u && pdu[9] == quantity_write * 2;
|
||||
}
|
||||
case FunctionCode::WRITE_SINGLE_COIL:
|
||||
// The one variable field in an otherwise fixed-shape PDU: the spec allows exactly ON/OFF.
|
||||
return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00;
|
||||
default:
|
||||
return true; // All other function codes validated by length alone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,101 +280,250 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
|
||||
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
|
||||
uint16_t number_of_entities, const uint8_t *values,
|
||||
size_t values_len) {
|
||||
// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit
|
||||
// fields (start address + quantity for reads and multi-writes, address + value for single writes).
|
||||
template<size_t CAP>
|
||||
static void append_pdu_header(StaticVector<uint8_t, CAP> &pdu, FunctionCode function_code, uint16_t first,
|
||||
uint16_t second) {
|
||||
pdu.push_back(static_cast<uint8_t>(function_code));
|
||||
pdu.push_back(first >> 8);
|
||||
pdu.push_back(first >> 0);
|
||||
pdu.push_back(second >> 8);
|
||||
pdu.push_back(second >> 0);
|
||||
}
|
||||
|
||||
ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities) {
|
||||
ReadPdu pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
if (number_of_entities == 0) {
|
||||
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
if (uint32_t(start_address) + number_of_entities > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
|
||||
start_address);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
switch (function_code) {
|
||||
case FunctionCode::READ_COILS:
|
||||
if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
break;
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
break;
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ESP_LOGE(TAG, "Unsupported function code %02X for read PDU creation", static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
append_pdu_header(pdu, function_code, start_address, number_of_entities);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities,
|
||||
const uint8_t *values, size_t values_len) {
|
||||
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
|
||||
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
|
||||
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
|
||||
if (values != nullptr || values_len > 0) {
|
||||
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
|
||||
static_cast<uint8_t>(function_code));
|
||||
}
|
||||
} else if (is_function_code_write(static_cast<uint8_t>(function_code))) {
|
||||
if (values == nullptr || values_len == 0) {
|
||||
ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
auto read_pdu = create_read_pdu(function_code, start_address, number_of_entities);
|
||||
pdu.assign(read_pdu.begin(), read_pdu.end());
|
||||
return pdu;
|
||||
}
|
||||
// Exact codes only: is_function_code_write() masks the exception bit, which would let the
|
||||
// exception-flagged forms (0x85/0x86/0x8F/0x90) build a request announcing itself as an exception.
|
||||
const bool is_single =
|
||||
function_code == FunctionCode::WRITE_SINGLE_COIL || function_code == FunctionCode::WRITE_SINGLE_REGISTER;
|
||||
const bool is_multi =
|
||||
function_code == FunctionCode::WRITE_MULTIPLE_COILS || function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS;
|
||||
if (!is_single && !is_multi) {
|
||||
ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
return pdu;
|
||||
}
|
||||
|
||||
// Generic write builder: raw caller-supplied bytes, so we can only guard against the PDU byte capacity here.
|
||||
if (values == nullptr || values_len == 0) {
|
||||
ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
if (number_of_entities == 0) {
|
||||
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
return pdu;
|
||||
}
|
||||
// number_of_entities is ignored for single write, so only validate it for the multiple variants.
|
||||
// The bound is per function code (coils pack 8 per byte, so their quantity limit is far higher) -
|
||||
// the same limits is_client_pdu_standard() accepts, so builder and validator agree.
|
||||
const uint16_t max_entities =
|
||||
function_code == FunctionCode::WRITE_MULTIPLE_COILS ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
|
||||
if (!is_single && number_of_entities > max_entities) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum %u for function code %02X", number_of_entities, max_entities,
|
||||
static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
|
||||
start_address);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
switch (function_code) {
|
||||
case ModbusFunctionCode::READ_COILS:
|
||||
if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
}
|
||||
break;
|
||||
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
|
||||
if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
}
|
||||
break;
|
||||
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
|
||||
case ModbusFunctionCode::READ_INPUT_REGISTERS:
|
||||
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
}
|
||||
break;
|
||||
case ModbusFunctionCode::WRITE_SINGLE_COIL:
|
||||
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
|
||||
break; // number_of_entities is ignored for single write, so no need to validate
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
|
||||
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
|
||||
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) {
|
||||
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X",
|
||||
number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast<uint8_t>(function_code));
|
||||
return {};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast<unsigned int>(function_code));
|
||||
return {};
|
||||
}
|
||||
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
|
||||
pdu.push_back(static_cast<uint8_t>(function_code));
|
||||
pdu.push_back(start_address >> 8);
|
||||
pdu.push_back(start_address >> 0);
|
||||
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
|
||||
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
|
||||
pdu.push_back(number_of_entities >> 8);
|
||||
pdu.push_back(number_of_entities >> 0);
|
||||
}
|
||||
|
||||
if (is_function_code_write(static_cast<uint8_t>(function_code))) {
|
||||
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
|
||||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
|
||||
// 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values
|
||||
static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6;
|
||||
if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) {
|
||||
ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len,
|
||||
MAX_WRITE_MULTIPLE_VALUES_LEN);
|
||||
return {};
|
||||
}
|
||||
pdu.push_back(values_len); // Byte count is required for write multiple
|
||||
for (size_t i = 0; i < values_len; i++)
|
||||
pdu.push_back(values[i]);
|
||||
} else {
|
||||
// Write single register or coil (2 bytes)
|
||||
if (values_len < 2) {
|
||||
ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len);
|
||||
return {};
|
||||
}
|
||||
pdu.push_back(values[0]);
|
||||
pdu.push_back(values[1]);
|
||||
if (is_single) {
|
||||
// Write single register or coil: the two value bytes are the header's second field.
|
||||
if (values_len < 2) {
|
||||
ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len);
|
||||
return pdu;
|
||||
}
|
||||
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil write - the same rule
|
||||
// is_client_pdu_standard() enforces, so a built frame cannot be misclassified on reply.
|
||||
if (function_code == FunctionCode::WRITE_SINGLE_COIL &&
|
||||
((values[0] != 0xFF && values[0] != 0x00) || values[1] != 0x00)) {
|
||||
ESP_LOGE(TAG, "Invalid single-coil value %02X%02X (must be FF00 or 0000), dropping request", values[0],
|
||||
values[1]);
|
||||
return pdu;
|
||||
}
|
||||
append_pdu_header(pdu, function_code, start_address, uint16_t((values[0] << 8) | values[1]));
|
||||
return pdu;
|
||||
}
|
||||
// The quantity is spec-bounded above, so the data length just has to agree with it exactly
|
||||
// (registers are 2 bytes each, coils pack 8 per byte). This is the same consistency the response
|
||||
// dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified
|
||||
// non-standard on reply, and the spec bound keeps the PDU within capacity by construction.
|
||||
// Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one.
|
||||
const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS;
|
||||
const size_t expected_len =
|
||||
bits ? (static_cast<size_t>(number_of_entities) + 7) / 8 : static_cast<size_t>(number_of_entities) * 2;
|
||||
if (values_len != expected_len) {
|
||||
ESP_LOGE(TAG, "values_len %zu does not match %u entities (expected %zu) for function code %02X, dropping request",
|
||||
values_len, number_of_entities, expected_len, static_cast<uint8_t>(function_code));
|
||||
return pdu;
|
||||
}
|
||||
append_pdu_header(pdu, function_code, start_address, number_of_entities);
|
||||
pdu.push_back(values_len); // Byte count is required for write multiple
|
||||
for (size_t i = 0; i < values_len; i++)
|
||||
pdu.push_back(values[i]);
|
||||
// Zero the unused bits of the final byte as the spec requires, matching the typed coil builder
|
||||
// so both produce identical wire bytes for the same write.
|
||||
if (bits && number_of_entities % 8 != 0) {
|
||||
pdu[pdu.size() - 1] &= static_cast<uint8_t>((1 << (number_of_entities % 8)) - 1);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
|
||||
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
if (values.empty()) {
|
||||
ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request");
|
||||
return pdu;
|
||||
}
|
||||
// Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE.
|
||||
if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) {
|
||||
ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(),
|
||||
MAX_NUM_OF_REGISTERS_TO_WRITE);
|
||||
return pdu;
|
||||
}
|
||||
if (uint32_t(start_address) + values.size() > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(),
|
||||
start_address);
|
||||
return pdu;
|
||||
}
|
||||
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
|
||||
pdu.push_back(static_cast<uint8_t>(values.size() * 2)); // byte count
|
||||
for (auto v : values) {
|
||||
auto decoded_value = decode_value(v);
|
||||
pdu.push_back(decoded_value[0]);
|
||||
pdu.push_back(decoded_value[1]);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value) {
|
||||
WriteSinglePdu pdu;
|
||||
append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_REGISTER, start_address, value);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value) {
|
||||
WriteSinglePdu pdu;
|
||||
append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_COIL, address, value ? 0xFF00 : 0x0000);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
// Shared core for the two coil-write overloads: validates, then builds into the caller's named
|
||||
// pdu (left empty on failure). Each overload's returns all name one local, so NRVO fires.
|
||||
static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, PackedBits bits) {
|
||||
const uint16_t count = bits.size();
|
||||
const std::span<const uint8_t> packed_bits = bits.bytes();
|
||||
if (count == 0) {
|
||||
ESP_LOGE(TAG, "No coils requested for write multiple coils, dropping request");
|
||||
return;
|
||||
}
|
||||
if (count > MAX_NUM_OF_COILS_TO_WRITE) {
|
||||
ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE);
|
||||
return;
|
||||
}
|
||||
if (uint32_t(start_address) + count > 0x10000u) {
|
||||
ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address);
|
||||
return;
|
||||
}
|
||||
const size_t byte_count = (count + 7) / 8;
|
||||
if (packed_bits.size() < byte_count) {
|
||||
ESP_LOGE(TAG, "packed_bits (%zu bytes) does not cover %u coils (%zu bytes), dropping request", packed_bits.size(),
|
||||
count, byte_count);
|
||||
return;
|
||||
}
|
||||
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_COILS, start_address, count);
|
||||
pdu.push_back(static_cast<uint8_t>(byte_count));
|
||||
for (size_t i = 0; i != byte_count; i++) {
|
||||
pdu.push_back(packed_bits[i]);
|
||||
}
|
||||
// Zero the unused bits of the final byte, as the spec requires
|
||||
if (count % 8 != 0) {
|
||||
pdu[pdu.size() - 1] &= static_cast<uint8_t>((1 << (count % 8)) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) {
|
||||
PduBuffer pdu;
|
||||
build_write_coils_pdu(pdu, start_address, bits);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
|
||||
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
|
||||
// Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest.
|
||||
if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) {
|
||||
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(),
|
||||
MAX_NUM_OF_COILS_TO_WRITE);
|
||||
return pdu;
|
||||
}
|
||||
StaticVector<uint8_t, (MAX_NUM_OF_COILS_TO_WRITE + 7) / 8> packed;
|
||||
for (size_t i = 0; i != values.size(); i++) {
|
||||
if (i % 8 == 0)
|
||||
packed.push_back(0);
|
||||
if (values[i])
|
||||
packed[i / 8] |= (1 << (i % 8));
|
||||
}
|
||||
build_write_coils_pdu(pdu, start_address,
|
||||
PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), values.size()));
|
||||
return pdu;
|
||||
}
|
||||
} // namespace esphome::modbus::helpers
|
||||
|
||||
@@ -12,19 +12,19 @@
|
||||
namespace esphome::modbus::helpers {
|
||||
|
||||
inline bool is_function_code_read(uint8_t function_code) {
|
||||
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
|
||||
return masked_function_code == ModbusFunctionCode::READ_COILS ||
|
||||
masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
|
||||
masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
|
||||
masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS;
|
||||
FunctionCode masked_function_code = static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK);
|
||||
return masked_function_code == FunctionCode::READ_COILS ||
|
||||
masked_function_code == FunctionCode::READ_DISCRETE_INPUTS ||
|
||||
masked_function_code == FunctionCode::READ_HOLDING_REGISTERS ||
|
||||
masked_function_code == FunctionCode::READ_INPUT_REGISTERS;
|
||||
}
|
||||
|
||||
inline bool is_function_code_write(uint8_t function_code) {
|
||||
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
|
||||
return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
|
||||
masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
|
||||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
|
||||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS;
|
||||
FunctionCode masked_function_code = static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK);
|
||||
return masked_function_code == FunctionCode::WRITE_SINGLE_COIL ||
|
||||
masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER ||
|
||||
masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS ||
|
||||
masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS;
|
||||
}
|
||||
|
||||
inline bool is_function_code_exception(uint8_t function_code) {
|
||||
@@ -39,28 +39,70 @@ inline bool is_function_code_custom(uint8_t function_code) {
|
||||
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
|
||||
}
|
||||
|
||||
// Returns the expected length of a server response frame based on the function code
|
||||
// If the frame is too short to determine the length, returns the minimum length
|
||||
uint16_t server_frame_length(const uint8_t *frame, size_t size);
|
||||
// Returns the expected length of a server response PDU based on the function code.
|
||||
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
|
||||
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
|
||||
// bytes): only fixed header positions are interpreted, so surplus bytes are never misread.
|
||||
uint16_t server_pdu_length(const uint8_t *frame, size_t size);
|
||||
// Frame counterpart: address(1) + PDU + CRC(2). Passes every received byte after the address through,
|
||||
// so header fields (e.g. a byte count) are interpreted as soon as they arrive.
|
||||
inline uint16_t server_frame_length(const uint8_t *frame, size_t size) {
|
||||
if (size < 2)
|
||||
return MIN_FRAME_SIZE; // function code not received yet
|
||||
return server_pdu_length(frame + 1, size - 1) + 3;
|
||||
}
|
||||
|
||||
// Returns the expected length of a client request frame based on the function code
|
||||
// If the frame is too short to determine the length, returns the minimum length
|
||||
uint16_t client_frame_length(const uint8_t *frame, size_t size);
|
||||
// Returns the expected length of a client request PDU based on the function code.
|
||||
// Same contract as server_pdu_length(): `size` is bytes available so far, may exceed the PDU.
|
||||
uint16_t client_pdu_length(const uint8_t *frame, size_t size);
|
||||
inline uint16_t client_frame_length(const uint8_t *frame, size_t size) {
|
||||
if (size < 2)
|
||||
return MIN_FRAME_SIZE; // function code not received yet
|
||||
return client_pdu_length(frame + 1, size - 1) + 3;
|
||||
}
|
||||
|
||||
// Returns true if pdu is a complete transaction whose shape is consistent with its function code.
|
||||
// Unlike *_pdu_length(), `size` here is the exact PDU length: a size mismatch is non-conformant.
|
||||
// Function codes with nothing variable to cross-check are validated by their fixed length alone: the
|
||||
// single writes (except 0x05's value field, which must be 0x0000 or 0xFF00), mask-write and FIFO, and
|
||||
// - deliberately - custom/unknown codes and exception responses, so a dispatcher can still route
|
||||
// them by function code rather than reject them outright. Tests pin this contract.
|
||||
bool is_server_pdu_standard(const uint8_t *pdu, size_t size);
|
||||
|
||||
// Client counterpart: additionally checks quantity bounds and address-range arithmetic per function code.
|
||||
// The same acceptance rule applies to custom/unknown function codes.
|
||||
bool is_client_pdu_standard(const uint8_t *pdu, size_t size);
|
||||
|
||||
// Remove before 2027.2.0
|
||||
ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in 2027.2.0", "2026.8.0")
|
||||
inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) {
|
||||
if (size < 2)
|
||||
return 0;
|
||||
switch (static_cast<ModbusFunctionCode>(frame[1])) {
|
||||
case ModbusFunctionCode::READ_COILS:
|
||||
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
|
||||
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
|
||||
case ModbusFunctionCode::READ_INPUT_REGISTERS:
|
||||
switch (static_cast<FunctionCode>(frame[1])) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
case FunctionCode::READ_INPUT_REGISTERS:
|
||||
return 3; // address(1) + function(1) + byte count(1) + data + CRC(2)
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the payload portion of a server response PDU: the bytes after the function code, and for the
|
||||
* standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a
|
||||
* byte-count byte, but those codes are not implemented and their count byte is left in the payload. For
|
||||
* an exception PDU the payload is the exception code byte (the read check must not see the masked
|
||||
* function code, or an exception-of-read would classify as a read and return an empty span). Returns an
|
||||
* empty span if the PDU is too short.
|
||||
*/
|
||||
inline std::span<const uint8_t> server_pdu_payload(std::span<const uint8_t> pdu) {
|
||||
if (pdu.empty())
|
||||
return {};
|
||||
const size_t offset = (!is_function_code_exception(pdu[0]) && is_function_code_read(pdu[0])) ? 2 : 1;
|
||||
return pdu.size() > offset ? pdu.subspan(offset) : std::span<const uint8_t>();
|
||||
}
|
||||
|
||||
inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; }
|
||||
|
||||
enum class SensorValueType : uint8_t {
|
||||
@@ -84,32 +126,32 @@ inline bool value_type_is_float(SensorValueType v) {
|
||||
return v == SensorValueType::FP32 || v == SensorValueType::FP32_R;
|
||||
}
|
||||
|
||||
inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) {
|
||||
inline FunctionCode modbus_register_read_function(EntityType reg_type) {
|
||||
switch (reg_type) {
|
||||
case ModbusRegisterType::COIL:
|
||||
return ModbusFunctionCode::READ_COILS;
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
return ModbusFunctionCode::READ_DISCRETE_INPUTS;
|
||||
case ModbusRegisterType::HOLDING:
|
||||
return ModbusFunctionCode::READ_HOLDING_REGISTERS;
|
||||
case ModbusRegisterType::INPUT_REGISTER:
|
||||
return ModbusFunctionCode::READ_INPUT_REGISTERS;
|
||||
case EntityType::COIL:
|
||||
return FunctionCode::READ_COILS;
|
||||
case EntityType::DISCRETE_INPUT:
|
||||
return FunctionCode::READ_DISCRETE_INPUTS;
|
||||
case EntityType::HOLDING:
|
||||
return FunctionCode::READ_HOLDING_REGISTERS;
|
||||
case EntityType::INPUT_REGISTER:
|
||||
return FunctionCode::READ_INPUT_REGISTERS;
|
||||
default:
|
||||
return ModbusFunctionCode::INVALID;
|
||||
return FunctionCode::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) {
|
||||
inline FunctionCode modbus_register_write_function(EntityType reg_type, bool multiple = false) {
|
||||
switch (reg_type) {
|
||||
case ModbusRegisterType::COIL:
|
||||
return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL;
|
||||
case ModbusRegisterType::HOLDING:
|
||||
return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER;
|
||||
case EntityType::COIL:
|
||||
return multiple ? FunctionCode::WRITE_MULTIPLE_COILS : FunctionCode::WRITE_SINGLE_COIL;
|
||||
case EntityType::HOLDING:
|
||||
return multiple ? FunctionCode::WRITE_MULTIPLE_REGISTERS : FunctionCode::WRITE_SINGLE_REGISTER;
|
||||
// These register types can't be written (per spec)
|
||||
case ModbusRegisterType::INPUT_REGISTER:
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
case EntityType::INPUT_REGISTER:
|
||||
case EntityType::DISCRETE_INPUT:
|
||||
default:
|
||||
return ModbusFunctionCode::INVALID;
|
||||
return FunctionCode::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +350,24 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
|
||||
*/
|
||||
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
|
||||
|
||||
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
|
||||
// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector,
|
||||
// right-sized per shape) can be swapped in one place without touching every signature.
|
||||
using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>;
|
||||
using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>;
|
||||
using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>;
|
||||
|
||||
/** Create a modbus read request PDU.
|
||||
* @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS
|
||||
* @param start_address coil/register/input starting address
|
||||
* @param number_of_entities number of coils/registers/inputs to read
|
||||
* @return PDU (function code + data, no address, no CRC); empty on invalid input
|
||||
*/
|
||||
ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities);
|
||||
|
||||
/** Create a modbus client pdu for reading/writing single/multiple coils/register/inputs.
|
||||
* Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
|
||||
* create_write_registers_pdu(), create_write_single_register_pdu(), create_write_coils_pdu(),
|
||||
* create_write_single_coil_pdu()) which bound their inputs per spec.
|
||||
* @param function_code the modbus function code to use. One of:
|
||||
* READ_COILS
|
||||
* READ_DISCRETE_INPUTS
|
||||
@@ -324,11 +383,59 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
|
||||
* @param values_len length of values array
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
|
||||
uint16_t number_of_entities, const uint8_t *values = nullptr,
|
||||
size_t values_len = 0);
|
||||
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities,
|
||||
const uint8_t *values = nullptr, size_t values_len = 0);
|
||||
|
||||
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
|
||||
/** Create modbus write multiple registers command
|
||||
* Function 0x10 Write Multiple Registers
|
||||
* @param start_address modbus address of the first register to write
|
||||
* @param values register values to write; the register count is values.size() (at most
|
||||
* MAX_NUM_OF_REGISTERS_TO_WRITE, an over-long set is rejected and an empty PDU is returned).
|
||||
* Any contiguous uint16_t container converts (std::vector, std::array).
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
|
||||
|
||||
/** Create modbus write single register command
|
||||
* Function 0x06 Write Single Register
|
||||
* @param start_address modbus address of the register to write
|
||||
* @param value uint16_t value to write
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value);
|
||||
|
||||
/** Create modbus write single coil command
|
||||
* Function 0x05 Write Single Coil
|
||||
* @param address modbus address of the coil to write
|
||||
* @param value coil value to write
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value);
|
||||
|
||||
/** Create modbus write multiple coils command
|
||||
* Function 0x0F Write Multiple Coils
|
||||
* @param start_address modbus address of the first coil to write
|
||||
* @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an
|
||||
* over-long set is rejected and an empty PDU is returned). Note std::vector<bool> is bit-packed and
|
||||
* does not convert to a span; pass a std::array<bool, N> or other contiguous bool container.
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values);
|
||||
|
||||
/** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire.
|
||||
* @param start_address modbus address of the first coil to write
|
||||
* @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid
|
||||
* input returns an empty PDU
|
||||
* @return PDU (function code + data, no address, no CRC)
|
||||
*/
|
||||
PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits);
|
||||
|
||||
/** Append a float converted to register words to any push_back container (heap-free with StaticVector).
|
||||
* @param data container the register words are appended to
|
||||
* @param value value to convert
|
||||
* @param value_type defines if 16/32/64 bits or FP32 is used
|
||||
*/
|
||||
template<typename Container> void float_to_payload(Container &data, float value, SensorValueType value_type) {
|
||||
int64_t val;
|
||||
|
||||
if (value_type_is_float(value_type)) {
|
||||
@@ -337,8 +444,14 @@ inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value
|
||||
val = llroundf(value);
|
||||
}
|
||||
|
||||
std::vector<uint16_t> data;
|
||||
number_to_payload(data, val, value_type);
|
||||
}
|
||||
|
||||
// Remove before 2027.2.0
|
||||
ESPDEPRECATED("Use the container overload of float_to_payload() instead. Removed in 2027.2.0", "2026.8.0")
|
||||
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
|
||||
std::vector<uint16_t> data;
|
||||
float_to_payload(data, value, value_type);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from esphome.components import modbus
|
||||
from esphome.components.modbus.helpers import (
|
||||
MODBUS_REGISTER_TYPE,
|
||||
TYPE_REGISTER_MAP,
|
||||
ModbusRegisterType,
|
||||
EntityType,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET
|
||||
@@ -217,13 +217,13 @@ async def register_modbus_device(var, config):
|
||||
|
||||
def function_code_to_register(function_code):
|
||||
FUNCTION_CODE_TYPE_MAP = {
|
||||
"read_coils": ModbusRegisterType.COIL,
|
||||
"read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT,
|
||||
"read_holding_registers": ModbusRegisterType.HOLDING,
|
||||
"read_input_registers": ModbusRegisterType.INPUT_REGISTER,
|
||||
"write_single_coil": ModbusRegisterType.COIL,
|
||||
"write_single_register": ModbusRegisterType.HOLDING,
|
||||
"write_multiple_coils": ModbusRegisterType.COIL,
|
||||
"write_multiple_registers": ModbusRegisterType.HOLDING,
|
||||
"read_coils": EntityType.COIL,
|
||||
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
|
||||
"read_holding_registers": EntityType.HOLDING,
|
||||
"read_input_registers": EntityType.INPUT_REGISTER,
|
||||
"write_single_coil": EntityType.COIL,
|
||||
"write_single_register": EntityType.HOLDING,
|
||||
"write_multiple_coils": EntityType.COIL,
|
||||
"write_multiple_registers": EntityType.HOLDING,
|
||||
}
|
||||
return FUNCTION_CODE_TYPE_MAP[function_code]
|
||||
|
||||
@@ -11,8 +11,8 @@ void ModbusBinarySensor::parse_and_publish(const std::vector<uint8_t> &data) {
|
||||
bool value;
|
||||
|
||||
switch (this->register_type) {
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
case ModbusRegisterType::COIL:
|
||||
case modbus::EntityType::DISCRETE_INPUT:
|
||||
case modbus::EntityType::COIL:
|
||||
// offset for coil is the actual number of the coil not the byte offset
|
||||
value = modbus::helpers::bit_from_packed(this->offset, data);
|
||||
break;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem {
|
||||
public:
|
||||
ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
ModbusBinarySensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
uint16_t skip_updates, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->start_address = start_address;
|
||||
@@ -20,7 +20,7 @@ class ModbusBinarySensor final : public Component, public binary_sensor::BinaryS
|
||||
this->skip_updates = skip_updates;
|
||||
this->force_new_range = force_new_range;
|
||||
|
||||
if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) {
|
||||
if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) {
|
||||
this->register_count = offset + 1;
|
||||
} else {
|
||||
this->register_count = 1;
|
||||
|
||||
@@ -57,7 +57,7 @@ bool ModbusController::send_next_command_() {
|
||||
}
|
||||
|
||||
// Queue incoming response
|
||||
void ModbusController::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
void ModbusController::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
if (this->command_queue_.empty()) {
|
||||
ESP_LOGW(TAG, "Received modbus data but command queue is empty");
|
||||
return;
|
||||
@@ -78,8 +78,10 @@ void ModbusController::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
this->online_callback_.call((int) current_command->function_code, current_command->register_address);
|
||||
}
|
||||
|
||||
// Move the commandItem to the response queue
|
||||
current_command->payload = data;
|
||||
// Move the commandItem to the response queue. The span points into the hub's receive buffer, so
|
||||
// copy the payload into the command for deferred processing in loop().
|
||||
auto data = modbus::helpers::server_pdu_payload(response_pdu);
|
||||
current_command->payload.assign(data.begin(), data.end());
|
||||
this->incoming_queue_.push(std::move(current_command));
|
||||
ESP_LOGV(TAG, "Modbus response queued");
|
||||
this->command_queue_.pop_front();
|
||||
@@ -93,8 +95,11 @@ void ModbusController::process_modbus_data_(const ModbusCommandItem *response) {
|
||||
response->on_data_func(response->register_type, response->register_address, response->payload);
|
||||
}
|
||||
|
||||
void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_code) {
|
||||
ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, exception_code);
|
||||
void ModbusController::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
||||
// The request function code (request_pdu[0]) already carries what the log needs; the exception bit only
|
||||
// ever appears on the response, so no masking is needed here.
|
||||
const uint8_t function_code = request_pdu.empty() ? 0 : request_pdu[0];
|
||||
ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, static_cast<uint8_t>(exception_code));
|
||||
if (this->command_queue_.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +116,7 @@ void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_
|
||||
}
|
||||
}
|
||||
|
||||
SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const {
|
||||
SensorSet ModbusController::find_sensors_(modbus::EntityType register_type, uint16_t start_address) const {
|
||||
auto reg_it = std::find_if(
|
||||
std::begin(this->register_ranges_), std::end(this->register_ranges_),
|
||||
[=](RegisterRange const &r) { return (r.start_address == start_address && r.register_type == register_type); });
|
||||
@@ -125,7 +130,7 @@ SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint
|
||||
// not found
|
||||
return {};
|
||||
}
|
||||
void ModbusController::on_register_data(ModbusRegisterType register_type, uint16_t start_address,
|
||||
void ModbusController::on_register_data(modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
ESP_LOGV(TAG, "data for register address : 0x%X : ", start_address);
|
||||
|
||||
@@ -159,18 +164,18 @@ void ModbusController::update_range_(RegisterRange &r) {
|
||||
r.skip_updates_counter);
|
||||
if (r.skip_updates_counter == 0) {
|
||||
// if a custom command is used the user supplied custom_data is only available in the SensorItem.
|
||||
if (r.register_type == ModbusRegisterType::CUSTOM) {
|
||||
if (r.register_type == modbus::EntityType::CUSTOM) {
|
||||
auto sensors = this->find_sensors_(r.register_type, r.start_address);
|
||||
if (!sensors.empty()) {
|
||||
auto sensor = sensors.cbegin();
|
||||
auto command_item = ModbusCommandItem::create_custom_command(
|
||||
this, (*sensor)->custom_data,
|
||||
[this](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
this->on_register_data(ModbusRegisterType::CUSTOM, start_address, data);
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
this->on_register_data(modbus::EntityType::CUSTOM, start_address, data);
|
||||
});
|
||||
command_item.register_address = (*sensor)->start_address;
|
||||
command_item.register_count = (*sensor)->register_count;
|
||||
command_item.function_code = ModbusFunctionCode::CUSTOM;
|
||||
command_item.function_code = FunctionCode::CUSTOM;
|
||||
queue_command(command_item);
|
||||
}
|
||||
} else {
|
||||
@@ -232,7 +237,7 @@ size_t ModbusController::create_register_ranges_() {
|
||||
// this is not the first register in range so it might be possible
|
||||
// to reuse the last register or extend the current range
|
||||
if (!curr->force_new_range && r.register_type == curr->register_type &&
|
||||
curr->register_type != ModbusRegisterType::CUSTOM) {
|
||||
curr->register_type != modbus::EntityType::CUSTOM) {
|
||||
if (curr->start_address == (r.start_address + r.register_count - prev->register_count) &&
|
||||
curr->register_count == prev->register_count && curr->get_register_size() == prev->get_register_size()) {
|
||||
// this register can re-use the data from the previous register
|
||||
@@ -342,7 +347,7 @@ void ModbusController::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void ModbusController::on_write_register_response(ModbusRegisterType register_type, uint16_t start_address,
|
||||
void ModbusController::on_write_register_response(modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data<uint16_t>(data, 0),
|
||||
modbus::helpers::get_data<int16_t>(data, 1));
|
||||
@@ -357,8 +362,8 @@ void ModbusController::dump_sensors_() {
|
||||
}
|
||||
|
||||
ModbusCommandItem ModbusCommandItem::create_read_command(
|
||||
ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count,
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
@@ -371,7 +376,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command(
|
||||
}
|
||||
|
||||
ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbusdevice,
|
||||
ModbusRegisterType register_type, uint16_t start_address,
|
||||
modbus::EntityType register_type, uint16_t start_address,
|
||||
uint16_t register_count) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
@@ -379,7 +384,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbu
|
||||
cmd.function_code = modbus::helpers::modbus_register_read_function(register_type);
|
||||
cmd.register_address = start_address;
|
||||
cmd.register_count = register_count;
|
||||
cmd.on_data_func = [modbusdevice](ModbusRegisterType register_type, uint16_t start_address,
|
||||
cmd.on_data_func = [modbusdevice](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
modbusdevice->on_register_data(register_type, start_address, data);
|
||||
};
|
||||
@@ -391,11 +396,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_command(ModbusControl
|
||||
const std::vector<uint16_t> &values) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.register_type = ModbusRegisterType::HOLDING;
|
||||
cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS;
|
||||
cmd.register_type = modbus::EntityType::HOLDING;
|
||||
cmd.function_code = FunctionCode::WRITE_MULTIPLE_REGISTERS;
|
||||
cmd.register_address = start_address;
|
||||
cmd.register_count = register_count;
|
||||
cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address,
|
||||
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
|
||||
};
|
||||
@@ -411,11 +416,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_coil(ModbusController *
|
||||
bool value) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.register_type = ModbusRegisterType::COIL;
|
||||
cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_COIL;
|
||||
cmd.register_type = modbus::EntityType::COIL;
|
||||
cmd.function_code = FunctionCode::WRITE_SINGLE_COIL;
|
||||
cmd.register_address = address;
|
||||
cmd.register_count = 1;
|
||||
cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address,
|
||||
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
|
||||
};
|
||||
@@ -428,11 +433,11 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusControlle
|
||||
const std::vector<bool> &values) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.register_type = ModbusRegisterType::COIL;
|
||||
cmd.function_code = ModbusFunctionCode::WRITE_MULTIPLE_COILS;
|
||||
cmd.register_type = modbus::EntityType::COIL;
|
||||
cmd.function_code = FunctionCode::WRITE_MULTIPLE_COILS;
|
||||
cmd.register_address = start_address;
|
||||
cmd.register_count = values.size();
|
||||
cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address,
|
||||
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
|
||||
};
|
||||
@@ -460,11 +465,11 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle
|
||||
uint16_t value) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.register_type = ModbusRegisterType::HOLDING;
|
||||
cmd.function_code = ModbusFunctionCode::WRITE_SINGLE_REGISTER;
|
||||
cmd.register_type = modbus::EntityType::HOLDING;
|
||||
cmd.function_code = FunctionCode::WRITE_SINGLE_REGISTER;
|
||||
cmd.register_address = start_address;
|
||||
cmd.register_count = 1; // not used here anyways
|
||||
cmd.on_data_func = [modbusdevice, cmd](ModbusRegisterType register_type, uint16_t start_address,
|
||||
cmd.on_data_func = [modbusdevice, cmd](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
modbusdevice->on_write_register_response(cmd.register_type, start_address, data);
|
||||
};
|
||||
@@ -477,13 +482,13 @@ ModbusCommandItem ModbusCommandItem::create_write_single_command(ModbusControlle
|
||||
|
||||
ModbusCommandItem ModbusCommandItem::create_custom_command(
|
||||
ModbusController *modbusdevice, const std::vector<uint8_t> &values,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler) {
|
||||
ModbusCommandItem cmd;
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.function_code = ModbusFunctionCode::CUSTOM;
|
||||
cmd.function_code = FunctionCode::CUSTOM;
|
||||
if (handler == nullptr) {
|
||||
cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
ESP_LOGI(TAG, "Custom Command sent");
|
||||
};
|
||||
} else {
|
||||
@@ -496,13 +501,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
|
||||
|
||||
ModbusCommandItem ModbusCommandItem::create_custom_command(
|
||||
ModbusController *modbusdevice, const std::vector<uint16_t> &values,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler) {
|
||||
ModbusCommandItem cmd = {};
|
||||
cmd.modbusdevice = modbusdevice;
|
||||
cmd.function_code = ModbusFunctionCode::CUSTOM;
|
||||
cmd.function_code = FunctionCode::CUSTOM;
|
||||
if (handler == nullptr) {
|
||||
cmd.on_data_func = [](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
cmd.on_data_func = [](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
ESP_LOGI(TAG, "Custom Command sent");
|
||||
};
|
||||
} else {
|
||||
@@ -517,9 +522,10 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
|
||||
}
|
||||
|
||||
bool ModbusCommandItem::send() {
|
||||
if (this->function_code != ModbusFunctionCode::CUSTOM) {
|
||||
modbusdevice->send(uint8_t(this->function_code), this->register_address, this->register_count, this->payload.size(),
|
||||
this->payload.empty() ? nullptr : &this->payload[0]);
|
||||
if (this->function_code != FunctionCode::CUSTOM) {
|
||||
modbusdevice->send_pdu(
|
||||
modbus::helpers::create_client_pdu(this->function_code, this->register_address, this->register_count,
|
||||
this->payload.empty() ? nullptr : &this->payload[0], this->payload.size()));
|
||||
} else {
|
||||
modbusdevice->send_raw(this->payload);
|
||||
}
|
||||
@@ -532,7 +538,7 @@ bool ModbusCommandItem::send() {
|
||||
bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) {
|
||||
// for custom commands we have to check for identical payloads, since
|
||||
// address/count/type fields will be set to zero
|
||||
return this->function_code == ModbusFunctionCode::CUSTOM
|
||||
return this->function_code == FunctionCode::CUSTOM
|
||||
? this->payload == other.payload
|
||||
: other.register_address == this->register_address && other.register_count == this->register_count &&
|
||||
other.register_type == this->register_type && other.function_code == this->function_code;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <list>
|
||||
#include <queue>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -16,22 +17,29 @@ namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusController;
|
||||
|
||||
using modbus::ExceptionCode;
|
||||
using modbus::FunctionCode;
|
||||
using modbus::helpers::SensorValueType;
|
||||
|
||||
// Remove before 2027.2.0 - deprecated names re-exported so external components keep their warning window
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
using modbus::ModbusExceptionCode;
|
||||
using modbus::ModbusFunctionCode;
|
||||
using modbus::ModbusRegisterType;
|
||||
using modbus::ModbusExceptionCode;
|
||||
using modbus::helpers::SensorValueType;
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
// Remove before 2026.10.0 — these helpers have moved to modbus::helpers
|
||||
ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); }
|
||||
|
||||
ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) {
|
||||
inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) {
|
||||
return modbus::helpers::modbus_register_read_function(reg_type);
|
||||
}
|
||||
|
||||
ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) {
|
||||
inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) {
|
||||
return modbus::helpers::modbus_register_write_function(reg_type);
|
||||
}
|
||||
|
||||
@@ -90,7 +98,9 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
|
||||
|
||||
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
|
||||
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
|
||||
return modbus::helpers::float_to_payload(value, value_type);
|
||||
std::vector<uint16_t> data;
|
||||
modbus::helpers::float_to_payload(data, value, value_type);
|
||||
return data;
|
||||
}
|
||||
|
||||
class ModbusController;
|
||||
@@ -101,7 +111,7 @@ class SensorItem {
|
||||
|
||||
void set_custom_data(const std::vector<uint8_t> &data) { custom_data = data; }
|
||||
size_t virtual get_register_size() const {
|
||||
if (register_type == ModbusRegisterType::COIL || register_type == ModbusRegisterType::DISCRETE_INPUT) {
|
||||
if (register_type == modbus::EntityType::COIL || register_type == modbus::EntityType::DISCRETE_INPUT) {
|
||||
return 1;
|
||||
} else { // if CONF_RESPONSE_BYTES is used override the default
|
||||
return response_bytes > 0 ? response_bytes : register_count * 2;
|
||||
@@ -109,7 +119,7 @@ class SensorItem {
|
||||
}
|
||||
// Override register size for modbus devices not using 1 register for one dword
|
||||
void set_register_size(uint8_t register_size) { response_bytes = register_size; }
|
||||
ModbusRegisterType register_type{ModbusRegisterType::CUSTOM};
|
||||
modbus::EntityType register_type{modbus::EntityType::CUSTOM};
|
||||
SensorValueType sensor_value_type{SensorValueType::RAW};
|
||||
uint16_t start_address{0};
|
||||
uint32_t bitmask{0};
|
||||
@@ -156,7 +166,7 @@ using SensorSet = std::set<SensorItem *, SensorItemsComparator>;
|
||||
|
||||
struct RegisterRange {
|
||||
uint16_t start_address;
|
||||
ModbusRegisterType register_type;
|
||||
modbus::EntityType register_type;
|
||||
uint8_t register_count;
|
||||
uint16_t skip_updates; // the config value
|
||||
SensorSet sensors; // all sensors of this range
|
||||
@@ -169,9 +179,9 @@ class ModbusCommandItem {
|
||||
ModbusController *modbusdevice{nullptr};
|
||||
uint16_t register_address{0};
|
||||
uint16_t register_count{0};
|
||||
ModbusFunctionCode function_code{ModbusFunctionCode::CUSTOM};
|
||||
ModbusRegisterType register_type{ModbusRegisterType::CUSTOM};
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
FunctionCode function_code{FunctionCode::CUSTOM};
|
||||
modbus::EntityType register_type{modbus::EntityType::CUSTOM};
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
on_data_func;
|
||||
std::vector<uint8_t> payload = {};
|
||||
bool send();
|
||||
@@ -189,8 +199,8 @@ class ModbusCommandItem {
|
||||
* @return ModbusCommandItem with the prepared command
|
||||
*/
|
||||
static ModbusCommandItem create_read_command(
|
||||
ModbusController *modbusdevice, ModbusRegisterType register_type, uint16_t start_address, uint16_t register_count,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
ModbusController *modbusdevice, modbus::EntityType register_type, uint16_t start_address, uint16_t register_count,
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler);
|
||||
/** Create modbus read command
|
||||
* Function code 02-04
|
||||
@@ -200,7 +210,7 @@ class ModbusCommandItem {
|
||||
* @param register_count number of registers to read
|
||||
* @return ModbusCommandItem with the prepared command
|
||||
*/
|
||||
static ModbusCommandItem create_read_command(ModbusController *modbusdevice, ModbusRegisterType register_type,
|
||||
static ModbusCommandItem create_read_command(ModbusController *modbusdevice, modbus::EntityType register_type,
|
||||
uint16_t start_address, uint16_t register_count);
|
||||
/** Create modbus read command
|
||||
* Function code 02-04
|
||||
@@ -250,7 +260,7 @@ class ModbusCommandItem {
|
||||
*/
|
||||
static ModbusCommandItem create_custom_command(
|
||||
ModbusController *modbusdevice, const std::vector<uint8_t> &values,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler = nullptr);
|
||||
|
||||
/** Create custom modbus command
|
||||
@@ -262,7 +272,7 @@ class ModbusCommandItem {
|
||||
*/
|
||||
static ModbusCommandItem create_custom_command(
|
||||
ModbusController *modbusdevice, const std::vector<uint16_t> &values,
|
||||
std::function<void(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
std::function<void(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data)>
|
||||
&&handler = nullptr);
|
||||
|
||||
bool is_equal(const ModbusCommandItem &other);
|
||||
@@ -290,17 +300,28 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
|
||||
|
||||
/// queues a modbus command in the send queue
|
||||
void queue_command(const ModbusCommandItem &command);
|
||||
/// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller.
|
||||
/// The payload carries its own address byte, which may differ from this controller's address.
|
||||
/// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics:
|
||||
/// controller-level raw sends stay supported until the command machinery is replaced.
|
||||
void send_raw(const std::vector<uint8_t> &payload) {
|
||||
if (payload.empty()) {
|
||||
this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled
|
||||
return;
|
||||
}
|
||||
this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
|
||||
}
|
||||
/// Registers a sensor with the controller. Called by esphomes code generator
|
||||
void add_sensor_item(SensorItem *item) { sensorset_.insert(item); }
|
||||
/// called when a modbus response was parsed without errors
|
||||
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;
|
||||
/// called when a modbus error response was received
|
||||
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override;
|
||||
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
|
||||
/// default delegate called by process_modbus_data when a response has retrieved from the incoming queue
|
||||
void on_register_data(ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data);
|
||||
void on_register_data(modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data);
|
||||
/// default delegate called by process_modbus_data when a response for a write response has retrieved from the
|
||||
/// incoming queue
|
||||
void on_write_register_response(ModbusRegisterType register_type, uint16_t start_address,
|
||||
void on_write_register_response(modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data);
|
||||
/// Allow a duplicate command to be sent
|
||||
void set_allow_duplicate_commands(bool allow_duplicate_commands) {
|
||||
@@ -337,7 +358,7 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
|
||||
/// parse sensormap_ and create range of sequential addresses
|
||||
size_t create_register_ranges_();
|
||||
// find register in sensormap. Returns iterator with all registers having the same start address
|
||||
SensorSet find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const;
|
||||
SensorSet find_sensors_(modbus::EntityType register_type, uint16_t start_address) const;
|
||||
/// submit the read command for the address range to the send queue
|
||||
void update_range_(RegisterRange &r);
|
||||
/// parse incoming modbus data
|
||||
|
||||
@@ -57,11 +57,12 @@ void ModbusNumber::control(float value) {
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
write_cmd = ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this, write_cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
[this, write_cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data);
|
||||
});
|
||||
} else {
|
||||
data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type);
|
||||
std::vector<uint16_t> payload;
|
||||
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
@@ -71,13 +72,13 @@ void ModbusNumber::control(float value) {
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
// since offset is in bytes and a register is 16 bits we get the start by adding offset/2
|
||||
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2,
|
||||
data[0]);
|
||||
payload[0]);
|
||||
} else {
|
||||
write_cmd = ModbusCommandItem::create_write_multiple_command(
|
||||
this->parent_, this->start_address + this->offset / 2, this->register_count, data);
|
||||
this->parent_, this->start_address + this->offset / 2, this->register_count, payload);
|
||||
}
|
||||
// publish new value
|
||||
write_cmd.on_data_func = [this, write_cmd, value](ModbusRegisterType register_type, uint16_t start_address,
|
||||
write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address,
|
||||
const std::vector<uint8_t> &data) {
|
||||
// gets called when the write command is ack'd from the device
|
||||
this->parent_->on_write_register_response(write_cmd.register_type, start_address, data);
|
||||
|
||||
@@ -12,7 +12,7 @@ using value_to_data_t = std::function<float>(float);
|
||||
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem {
|
||||
public:
|
||||
ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->start_address = start_address;
|
||||
|
||||
@@ -33,12 +33,30 @@ void ModbusFloatOutput::write_state(float value) {
|
||||
}
|
||||
// lambda didn't set payload
|
||||
if (data.empty()) {
|
||||
data = modbus::helpers::float_to_payload(value, this->sensor_value_type);
|
||||
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
this->start_address, this->register_count, value, original_value);
|
||||
|
||||
// The command declares register_count registers, so the payload must be exactly that many words;
|
||||
// anything else would put a byte count on the wire that disagrees with the quantity field.
|
||||
// number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0].
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating output");
|
||||
return;
|
||||
}
|
||||
|
||||
// register_count declares the READ range width - it may pull neighboring registers into one poll -
|
||||
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
|
||||
// never from register_count (padding to it would zero registers the user only declared for reading).
|
||||
// A payload wider than the declared range means the config and the lambda disagree - drop it.
|
||||
if (data.size() > this->register_count) {
|
||||
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
|
||||
this->register_count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and send the write command
|
||||
ModbusCommandItem write_cmd;
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
@@ -46,7 +64,7 @@ void ModbusFloatOutput::write_state(float value) {
|
||||
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]);
|
||||
} else {
|
||||
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset,
|
||||
this->register_count, data);
|
||||
data.size(), data);
|
||||
}
|
||||
this->parent_->queue_command(write_cmd);
|
||||
}
|
||||
@@ -89,7 +107,7 @@ void ModbusBinaryOutput::write_state(bool state) {
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd = ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
[this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
this->parent_->on_write_register_response(cmd.register_type, this->start_address, data);
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace esphome::modbus_controller {
|
||||
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem {
|
||||
public:
|
||||
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
|
||||
this->register_type = ModbusRegisterType::HOLDING;
|
||||
this->register_type = modbus::EntityType::HOLDING;
|
||||
this->start_address = start_address;
|
||||
this->offset = offset;
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
@@ -44,7 +44,7 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem {
|
||||
public:
|
||||
ModbusBinaryOutput(uint16_t start_address, uint8_t offset) {
|
||||
this->register_type = ModbusRegisterType::COIL;
|
||||
this->register_type = modbus::EntityType::COIL;
|
||||
this->start_address = start_address;
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
|
||||
@@ -72,13 +72,26 @@ void ModbusSelect::control(size_t index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The command declares register_count registers, so the payload must be exactly that many words:
|
||||
// a value type narrower than the declared width is zero-padded (the config deliberately allows
|
||||
// register_count larger than the value type). Anything else would put a byte count on the wire
|
||||
// that disagrees with the quantity field, which conformant devices reject.
|
||||
// register_count declares the READ range width - it may pull neighboring registers into one poll -
|
||||
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
|
||||
// never from register_count (padding to it would zero registers the user only declared for reading).
|
||||
// A payload wider than the declared range means the config and the lambda disagree - drop it.
|
||||
if (data.size() > this->register_count) {
|
||||
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
|
||||
this->register_count);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t write_address = this->start_address + this->offset / 2;
|
||||
ModbusCommandItem write_cmd;
|
||||
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
|
||||
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]);
|
||||
} else {
|
||||
write_cmd =
|
||||
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, this->register_count, data);
|
||||
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data);
|
||||
}
|
||||
|
||||
this->parent_->queue_command(write_cmd);
|
||||
|
||||
@@ -13,7 +13,7 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
public:
|
||||
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates,
|
||||
bool force_new_range, std::vector<int64_t> mapping) {
|
||||
this->register_type = ModbusRegisterType::HOLDING; // not configurable
|
||||
this->register_type = modbus::EntityType::HOLDING; // not configurable
|
||||
this->sensor_value_type = sensor_value_type;
|
||||
this->start_address = start_address;
|
||||
this->offset = 0; // not configurable
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem {
|
||||
public:
|
||||
ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
ModbusSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->start_address = start_address;
|
||||
|
||||
@@ -30,8 +30,8 @@ bool ModbusSwitch::assumed_state() { return this->assumed_state_; }
|
||||
void ModbusSwitch::parse_and_publish(const std::vector<uint8_t> &data) {
|
||||
bool value = false;
|
||||
switch (this->register_type) {
|
||||
case ModbusRegisterType::DISCRETE_INPUT:
|
||||
case ModbusRegisterType::COIL:
|
||||
case modbus::EntityType::DISCRETE_INPUT:
|
||||
case modbus::EntityType::COIL:
|
||||
// offset for coil is the actual number of the coil not the byte offset
|
||||
value = modbus::helpers::bit_from_packed(this->offset, data);
|
||||
break;
|
||||
@@ -82,13 +82,13 @@ void ModbusSwitch::write_state(bool state) {
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd = ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this, cmd](ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
[this, cmd](modbus::EntityType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
|
||||
this->parent_->on_write_register_response(cmd.register_type, this->start_address, data);
|
||||
});
|
||||
} else {
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
if (this->register_type == ModbusRegisterType::COIL) {
|
||||
if (this->register_type == modbus::EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<bool> states{state};
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
|
||||
public:
|
||||
ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
uint16_t skip_updates, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->start_address = start_address;
|
||||
@@ -19,7 +19,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->skip_updates = skip_updates;
|
||||
this->register_count = 1;
|
||||
if (register_type == ModbusRegisterType::HOLDING || register_type == ModbusRegisterType::COIL) {
|
||||
if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) {
|
||||
this->start_address += offset;
|
||||
this->offset = 0;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 };
|
||||
|
||||
class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem {
|
||||
public:
|
||||
ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count,
|
||||
ModbusTextSensor(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count,
|
||||
uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) {
|
||||
this->register_type = register_type;
|
||||
this->start_address = start_address;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::modbus_server {
|
||||
using modbus::ModbusExceptionCode;
|
||||
using modbus::ExceptionCode;
|
||||
using modbus::helpers::registers_to_number;
|
||||
|
||||
static const char *const TAG = "modbus_server";
|
||||
@@ -50,13 +50,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u
|
||||
}
|
||||
ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.",
|
||||
static_cast<uint16_t>(current_address));
|
||||
return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
if (!server_register->read_lambda) {
|
||||
// Registered but not readable (write-only); don't mask it with the courtesy default.
|
||||
ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address);
|
||||
return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
// A multi-register value is normally atomic: the request must start at its first register and cover all of
|
||||
@@ -72,7 +72,7 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u
|
||||
"Read clips the multi-register value at 0x%04X, which does not allow partial reads. "
|
||||
"Sending exception response.",
|
||||
server_register->address);
|
||||
return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
int64_t value = server_register->read_lambda();
|
||||
@@ -89,7 +89,7 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u
|
||||
// The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault.
|
||||
ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address,
|
||||
server_register->register_count);
|
||||
return ModbusExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
for (uint16_t i = 0; i < take; i++) {
|
||||
registers.push_back(value_words[value_offset + i]);
|
||||
@@ -132,7 +132,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
// so we never apply a partial write before discovering a problem. The commit pass below re-runs
|
||||
// registers_to_number rather than caching the decoded values: using the same function for the check and
|
||||
// the write keeps a single source of truth for the decode bound, independent of how register_count was set.
|
||||
ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register
|
||||
ExceptionCode precheck = ExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register
|
||||
if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool {
|
||||
if (server_register->write_lambda == nullptr) {
|
||||
return false; // unwritable -> ILLEGAL_DATA_ADDRESS
|
||||
@@ -140,7 +140,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset,
|
||||
server_register->value_type)
|
||||
.has_value()) {
|
||||
precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value
|
||||
precheck = ExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -158,7 +158,7 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address,
|
||||
return server_register->write_lambda(number);
|
||||
})) {
|
||||
ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied.");
|
||||
return ModbusExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
return ExceptionCode::SERVICE_DEVICE_FAILURE;
|
||||
}
|
||||
|
||||
// Success: the caller builds the write response (an echo of the request header).
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
|
||||
from esphome.components.zephyr import zephyr_add_prj_conf
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ID,
|
||||
CONF_MIN_IPV6_ADDR_COUNT,
|
||||
CONF_PRIORITY,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
@@ -20,6 +27,48 @@ _LOGGER = logging.getLogger(__name__)
|
||||
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
|
||||
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
|
||||
|
||||
# Network priority tracking infrastructure
|
||||
# Components can query this to determine their relative setup priority.
|
||||
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts of the form
|
||||
# {"interface": "ethernet"}, in user-declared order.
|
||||
KEY_NETWORK_PRIORITY = "network_priority"
|
||||
|
||||
# Only interfaces whose component already calls get_network_priority() are
|
||||
# accepted in the priority list. openthread and modem will be added here when
|
||||
# they wire up their setup-priority consumer in their own to_code — see
|
||||
# NETWORK_PLAN.md for the full multi-interface roadmap.
|
||||
VALID_NETWORK_TYPES = ["ethernet", "wifi"]
|
||||
|
||||
# Setup priority base values — first in list gets the highest priority.
|
||||
#
|
||||
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
|
||||
# (250.0), so a single-entry priority list yields exactly the same setup order
|
||||
# as a config with no priority block. Subsequent entries step down by a small
|
||||
# amount to break ties without crossing other priority bands.
|
||||
#
|
||||
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
|
||||
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
|
||||
# interface could tie with NetworkComponent and run before esp_netif_init().
|
||||
NETWORK_PRIORITY_BASE = 250.0
|
||||
NETWORK_PRIORITY_STEP = 5.0
|
||||
|
||||
# Lower-bound guard. The lowest-priority entry gets
|
||||
# NETWORK_PRIORITY_BASE - (len - 1) * NETWORK_PRIORITY_STEP, which must stay
|
||||
# strictly above setup_priority::AFTER_WIFI (200.0, see esphome/core/component.h)
|
||||
# so a long priority list never drops an interface into the band used by
|
||||
# components that expect to run after the network is up. There is ample headroom
|
||||
# for the two types today; this check raises if a future expansion of
|
||||
# VALID_NETWORK_TYPES would silently cross that band. Uses an explicit raise
|
||||
# rather than a bare assert so the guard isn't stripped under python -O/-OO.
|
||||
_SETUP_PRIORITY_AFTER_WIFI = 200.0
|
||||
if (
|
||||
NETWORK_PRIORITY_BASE - (len(VALID_NETWORK_TYPES) - 1) * NETWORK_PRIORITY_STEP
|
||||
<= _SETUP_PRIORITY_AFTER_WIFI
|
||||
):
|
||||
raise ValueError(
|
||||
"network: priority: list is long enough to cross setup_priority::AFTER_WIFI"
|
||||
)
|
||||
|
||||
network_ns = cg.esphome_ns.namespace("network")
|
||||
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
|
||||
IPAddress = network_ns.class_("IPAddress")
|
||||
@@ -142,6 +191,83 @@ def validate_ipv6(value: bool) -> bool:
|
||||
return value
|
||||
|
||||
|
||||
def get_network_priority(iface: str) -> float | None:
|
||||
"""Get the setup priority for the given network interface type.
|
||||
|
||||
Returns the float setup priority for ``iface`` based on the order declared
|
||||
under ``network: priority:``. Interfaces listed first receive a higher
|
||||
setup priority so they are initialised before lower-priority ones.
|
||||
|
||||
If no ``network: priority:`` has been configured this returns ``None`` and
|
||||
the calling component should fall back to its own default setup priority.
|
||||
|
||||
Args:
|
||||
iface: Interface type string (case-insensitive). Currently ``"ethernet"``
|
||||
or ``"wifi"`` — the only types the priority-list validator
|
||||
accepts; ``"openthread"`` / ``"modem"`` are planned but not yet
|
||||
supported. An interface not present in the configured list
|
||||
returns ``None``.
|
||||
|
||||
Returns:
|
||||
float setup priority, or None if no priority list was configured.
|
||||
|
||||
Example usage inside a component's ``to_code``. Emit the override before
|
||||
``register_component`` so an explicit ``setup_priority:`` on the component
|
||||
still wins::
|
||||
|
||||
from esphome.components import network
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
prio = network.get_network_priority("ethernet")
|
||||
if prio is not None:
|
||||
cg.set_setup_priority(var, prio)
|
||||
|
||||
await cg.register_component(var, config)
|
||||
...
|
||||
"""
|
||||
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
|
||||
if priority_list is None:
|
||||
return None
|
||||
iface_lower = iface.lower()
|
||||
for idx, entry in enumerate(priority_list):
|
||||
if entry["interface"] == iface_lower:
|
||||
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
|
||||
return None
|
||||
|
||||
|
||||
def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str]:
|
||||
"""Return the set of interface names declared in ``network: priority:``.
|
||||
|
||||
Reads from the full validated config (``fv.full_config.get()``) and is
|
||||
intended for use inside ``FINAL_VALIDATE_SCHEMA`` hooks, before
|
||||
``to_code`` has run and ``CORE.data`` has been populated. Returns an
|
||||
empty set if no priority list was configured.
|
||||
"""
|
||||
return {
|
||||
entry["interface"]
|
||||
for entry in full_config.get("network", {}).get(CONF_PRIORITY, [])
|
||||
}
|
||||
|
||||
|
||||
def _validate_priority_list(value: Any) -> list[dict[str, str]]:
|
||||
"""Validate and normalize the priority list, rejecting duplicates.
|
||||
|
||||
Each entry is the name of one network interface (one of
|
||||
``VALID_NETWORK_TYPES``). Mixed-case input is accepted and normalized
|
||||
to lowercase. The normalized list is a list of dicts of the form
|
||||
``{"interface": "ethernet"}`` so that future per-entry options can be
|
||||
added without breaking call sites.
|
||||
"""
|
||||
raw = cv.ensure_list(cv.one_of(*VALID_NETWORK_TYPES, lower=True))(value)
|
||||
entries = [{"interface": iface} for iface in raw]
|
||||
interfaces = [e["interface"] for e in entries]
|
||||
if len(interfaces) != len(set(interfaces)):
|
||||
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
|
||||
return entries
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -174,17 +300,52 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(
|
||||
cv.boolean, cv.only_on_esp32
|
||||
),
|
||||
cv.Optional(CONF_PRIORITY): _validate_priority_list,
|
||||
}
|
||||
),
|
||||
_register_provisioning_source,
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Check that every interface named in 'priority' has a corresponding component block."""
|
||||
full = fv.full_config.get()
|
||||
for entry in config.get(CONF_PRIORITY, []):
|
||||
iface = entry["interface"]
|
||||
if iface not in full:
|
||||
raise cv.Invalid(
|
||||
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
|
||||
f"component is configured",
|
||||
[CONF_PRIORITY],
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.NETWORK)
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_NETWORK")
|
||||
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
|
||||
|
||||
# Store the user-declared network priority list in CORE.data so that ethernet,
|
||||
# wifi and other network components can query it via get_network_priority()
|
||||
# during their own to_code phase.
|
||||
if CONF_PRIORITY in config:
|
||||
priority_list = config[CONF_PRIORITY]
|
||||
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
|
||||
# network/util.cpp resolves the reported address (get_use_address_to,
|
||||
# get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority
|
||||
# list is the only case that deviates from it, so it is the only case that
|
||||
# needs a define. Runtime (active-interface) selection is a planned follow-up.
|
||||
if priority_list[0]["interface"] == "wifi":
|
||||
cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI")
|
||||
|
||||
_LOGGER.info(
|
||||
"Network interface priority: %s",
|
||||
" > ".join(entry["interface"] for entry in priority_list),
|
||||
)
|
||||
|
||||
# Apply high performance networking settings
|
||||
# Config can explicitly enable/disable, or default to component-driven behavior
|
||||
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
|
||||
|
||||
@@ -19,10 +19,37 @@
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include <arpa/inet.h>
|
||||
#if USE_NETWORK_IPV6
|
||||
using ip4_addr_t = struct in_addr;
|
||||
using ip6_addr_t = struct in6_addr;
|
||||
struct ip_addr_t {
|
||||
union {
|
||||
struct in6_addr ip6;
|
||||
struct in_addr ip4;
|
||||
} u_addr;
|
||||
uint8_t type;
|
||||
};
|
||||
enum : uint8_t { IPADDR_TYPE_V4 = 0, IPADDR_TYPE_V6 = 6 };
|
||||
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) {
|
||||
if (strchr(cp, ':') != nullptr) {
|
||||
if (inet_pton(AF_INET6, cp, &addr->u_addr.ip6) != 1) {
|
||||
return 0;
|
||||
}
|
||||
addr->type = IPADDR_TYPE_V6;
|
||||
return 1;
|
||||
}
|
||||
if (inet_aton(cp, &addr->u_addr.ip4) != 1) {
|
||||
return 0;
|
||||
}
|
||||
addr->type = IPADDR_TYPE_V4;
|
||||
return 1;
|
||||
}
|
||||
#else
|
||||
using ip_addr_t = in_addr;
|
||||
using ip4_addr_t = in_addr;
|
||||
#define ipaddr_aton(x, y) inet_aton((x), (y))
|
||||
#endif
|
||||
#endif // USE_NETWORK_IPV6
|
||||
#endif // USE_HOST
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
#include <zephyr/net/net_ip.h>
|
||||
@@ -80,6 +107,81 @@ struct IPAddress {
|
||||
bool operator!=(const IPAddress &other) const { return !net_ipv6_addr_cmp(&ip_addr_, &other.ip_addr_); }
|
||||
|
||||
#elif defined(USE_HOST)
|
||||
#if USE_NETWORK_IPV6
|
||||
IPAddress() { memset(&this->ip_addr_, 0, sizeof(this->ip_addr_)); }
|
||||
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
|
||||
memset(&this->ip_addr_, 0, sizeof(this->ip_addr_));
|
||||
this->ip_addr_.u_addr.ip4.s_addr =
|
||||
htonl(((uint32_t) first << 24) | ((uint32_t) second << 16) | ((uint32_t) third << 8) | fourth);
|
||||
this->ip_addr_.type = IPADDR_TYPE_V4;
|
||||
}
|
||||
IPAddress(const char *in_address) {
|
||||
memset(&this->ip_addr_, 0, sizeof(this->ip_addr_));
|
||||
ipaddr_aton(in_address, &this->ip_addr_);
|
||||
}
|
||||
IPAddress(const std::string &in_address) : IPAddress(in_address.c_str()) {}
|
||||
IPAddress(const ip_addr_t *other_ip) { memcpy(&this->ip_addr_, other_ip, sizeof(ip_addr_t)); }
|
||||
IPAddress(ip4_addr_t *other_ip) {
|
||||
memset(&this->ip_addr_, 0, sizeof(this->ip_addr_));
|
||||
this->ip_addr_.u_addr.ip4 = *other_ip;
|
||||
this->ip_addr_.type = IPADDR_TYPE_V4;
|
||||
}
|
||||
IPAddress(ip6_addr_t *other_ip) {
|
||||
memset(&this->ip_addr_, 0, sizeof(this->ip_addr_));
|
||||
this->ip_addr_.u_addr.ip6 = *other_ip;
|
||||
this->ip_addr_.type = IPADDR_TYPE_V6;
|
||||
}
|
||||
operator ip_addr_t() const { return this->ip_addr_; }
|
||||
bool is_set() const {
|
||||
if (this->ip_addr_.type == IPADDR_TYPE_V6) {
|
||||
static constexpr uint8_t zero[sizeof(struct in6_addr)] = {};
|
||||
return memcmp(this->ip_addr_.u_addr.ip6.s6_addr, zero, sizeof(zero)) != 0;
|
||||
}
|
||||
return this->ip_addr_.u_addr.ip4.s_addr != 0;
|
||||
}
|
||||
bool is_ip4() const { return this->ip_addr_.type == IPADDR_TYPE_V4; }
|
||||
bool is_ip6() const { return this->ip_addr_.type == IPADDR_TYPE_V6; }
|
||||
bool is_multicast() const {
|
||||
if (this->ip_addr_.type == IPADDR_TYPE_V6) {
|
||||
return this->ip_addr_.u_addr.ip6.s6_addr[0] == 0xff;
|
||||
}
|
||||
return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL;
|
||||
}
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED(
|
||||
"str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0",
|
||||
"2026.2.0")
|
||||
std::string str() const {
|
||||
char buf[IP_ADDRESS_BUFFER_SIZE];
|
||||
this->str_to(buf);
|
||||
return buf;
|
||||
}
|
||||
char *str_to(char *buf) const {
|
||||
if (this->ip_addr_.type == IPADDR_TYPE_V6) {
|
||||
inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE);
|
||||
} else {
|
||||
inet_ntop(AF_INET, &this->ip_addr_.u_addr.ip4, buf, IP_ADDRESS_BUFFER_SIZE);
|
||||
}
|
||||
lowercase_ip_str(buf);
|
||||
return buf;
|
||||
}
|
||||
bool operator==(const IPAddress &other) const {
|
||||
if (this->ip_addr_.type != other.ip_addr_.type) {
|
||||
return false;
|
||||
}
|
||||
if (this->ip_addr_.type == IPADDR_TYPE_V6) {
|
||||
return memcmp(&this->ip_addr_.u_addr.ip6, &other.ip_addr_.u_addr.ip6, sizeof(struct in6_addr)) == 0;
|
||||
}
|
||||
return this->ip_addr_.u_addr.ip4.s_addr == other.ip_addr_.u_addr.ip4.s_addr;
|
||||
}
|
||||
bool operator!=(const IPAddress &other) const { return !(*this == other); }
|
||||
IPAddress &operator+=(uint8_t increase) {
|
||||
if (this->ip_addr_.type == IPADDR_TYPE_V4) {
|
||||
(((uint8_t *) (&this->ip_addr_.u_addr.ip4))[3]) += increase;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
#else
|
||||
IPAddress() { ip_addr_.s_addr = 0; }
|
||||
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
|
||||
this->ip_addr_.s_addr = htonl((first << 24) | (second << 16) | (third << 8) | fourth);
|
||||
@@ -91,6 +193,7 @@ struct IPAddress {
|
||||
inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE);
|
||||
return buf; // IPv4 only, no hex letters to lowercase
|
||||
}
|
||||
#endif // USE_NETWORK_IPV6
|
||||
#else
|
||||
IPAddress() { ip_addr_set_zero(&ip_addr_); }
|
||||
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
|
||||
|
||||
@@ -23,9 +23,14 @@ bool is_disabled() {
|
||||
}
|
||||
|
||||
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
|
||||
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined
|
||||
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined.
|
||||
// A wifi-first network: priority: list sets USE_NETWORK_PRIMARY_INTERFACE_WIFI to lift
|
||||
// wifi ahead of the fixed ethernet-first order below; an ethernet-first list already
|
||||
// matches that order, so no define exists for it.
|
||||
const char *addr = nullptr;
|
||||
#if defined(USE_ETHERNET)
|
||||
#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
|
||||
addr = wifi::global_wifi_component->get_use_address();
|
||||
#elif defined(USE_ETHERNET)
|
||||
addr = ethernet::global_eth_component->get_use_address();
|
||||
#elif defined(USE_MODEM)
|
||||
addr = modem::global_modem_component->get_use_address();
|
||||
@@ -44,6 +49,19 @@ const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
|
||||
}
|
||||
|
||||
network::IPAddresses get_ip_addresses() {
|
||||
// With a wifi-first network: priority: list, prefer wifi while it has a valid IP;
|
||||
// otherwise fall through to the fixed ethernet-first order below. Selection based
|
||||
// on the runtime-active interface is a planned follow-up.
|
||||
#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
auto ips = wifi::global_wifi_component->get_ip_addresses();
|
||||
for (const auto &ip : ips) {
|
||||
if (ip.is_set())
|
||||
return ips;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr)
|
||||
return ethernet::global_eth_component->get_ip_addresses();
|
||||
|
||||
@@ -57,7 +57,8 @@ bool is_disabled();
|
||||
/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator
|
||||
static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70;
|
||||
/// Get the active network address for logging. Returns the explicitly configured
|
||||
/// use_address when one was set, otherwise formats "<name>.local" from the runtime
|
||||
/// use_address when one was set (from the highest-priority interface when
|
||||
/// network: priority: is configured), otherwise formats "<name>.local" from the runtime
|
||||
/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix).
|
||||
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf);
|
||||
IPAddresses get_ip_addresses();
|
||||
|
||||
@@ -283,6 +283,13 @@ def _validate_mcumgr(config):
|
||||
|
||||
def _final_validate(config):
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if CORE.using_toolchain_platformio:
|
||||
_LOGGER.warning(
|
||||
"The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. "
|
||||
"Please use 'toolchain: sdk-nrf' instead."
|
||||
)
|
||||
|
||||
if CONF_DFU in config:
|
||||
_validate_mcumgr(config)
|
||||
if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT:
|
||||
|
||||
@@ -91,7 +91,7 @@ SENSORS: dict[str, SensorSchema] = {
|
||||
),
|
||||
"dhw_flow_rate": SensorSchema(
|
||||
description="Water flow rate in DHW circuit",
|
||||
unit_of_measurement="l/min",
|
||||
unit_of_measurement="L/min",
|
||||
accuracy_decimals=2,
|
||||
icon="mdi:waves-arrow-right",
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
|
||||
@@ -5,11 +5,11 @@ namespace esphome::pzemac {
|
||||
|
||||
static const char *const TAG = "pzemac";
|
||||
|
||||
static const uint8_t PZEM_CMD_READ_IN_REGISTERS = 0x04;
|
||||
static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42;
|
||||
static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers
|
||||
|
||||
void PZEMAC::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
void PZEMAC::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() < 20) {
|
||||
ESP_LOGW(TAG, "Invalid size for PZEM AC!");
|
||||
return;
|
||||
@@ -61,7 +61,7 @@ void PZEMAC::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
this->power_factor_sensor_->publish_state(power_factor);
|
||||
}
|
||||
|
||||
void PZEMAC::update() { this->send(PZEM_CMD_READ_IN_REGISTERS, 0, PZEM_REGISTER_COUNT); }
|
||||
void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); }
|
||||
void PZEMAC::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"PZEMAC:\n"
|
||||
@@ -76,10 +76,8 @@ void PZEMAC::dump_config() {
|
||||
}
|
||||
|
||||
void PZEMAC::reset_energy_() {
|
||||
std::vector<uint8_t> cmd;
|
||||
cmd.push_back(this->address_);
|
||||
cmd.push_back(PZEM_CMD_RESET_ENERGY);
|
||||
this->send_raw(cmd);
|
||||
const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY};
|
||||
this->send_pdu(pdu);
|
||||
}
|
||||
|
||||
} // namespace esphome::pzemac
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user