mirror of
https://github.com/esphome/esphome.git
synced 2026-09-06 13:06:06 +00:00
Compare commits
402
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421c5e5d32 | ||
|
|
b8480b8424 | ||
|
|
8bef5b22e1 | ||
|
|
379e077b5f | ||
|
|
3f68930001 | ||
|
|
0aff9e1c54 | ||
|
|
f9824ee83f | ||
|
|
68ffd5a773 | ||
|
|
fe3788ff47 | ||
|
|
d6758377d1 | ||
|
|
5dbc8ffe4c | ||
|
|
0f982f03b2 | ||
|
|
afb0022dd0 | ||
|
|
081ef3d30d | ||
|
|
ab59822c1f | ||
|
|
ca44db107c | ||
|
|
d437869373 | ||
|
|
c20b619684 | ||
|
|
ebe6c2d049 | ||
|
|
78beb75b3b | ||
|
|
fc9afcb201 | ||
|
|
7a784d1135 | ||
|
|
24af3fd834 | ||
|
|
61e37cfc86 | ||
|
|
fa5180bd51 | ||
|
|
8582bf194a | ||
|
|
328e83ad64 | ||
|
|
6877513195 | ||
|
|
03104b894b | ||
|
|
26a308a82d | ||
|
|
ea1e0c6f68 | ||
|
|
938f598709 | ||
|
|
1ba1aebfa1 | ||
|
|
5d56517e14 | ||
|
|
ad69718ecc | ||
|
|
06d477bea7 | ||
|
|
bbe806f1e6 | ||
|
|
813c000684 | ||
|
|
2890afe0e5 | ||
|
|
5e58b312e2 | ||
|
|
575a540c9f | ||
|
|
404da26a37 | ||
|
|
be54a5c403 | ||
|
|
7b97c87390 | ||
|
|
fbe306f00b | ||
|
|
fc977b7b5e | ||
|
|
2f1d3f8299 | ||
|
|
cb54e57d84 | ||
|
|
fb65096ea3 | ||
|
|
0607c228f5 | ||
|
|
b9eda644cd | ||
|
|
a811aa840c | ||
|
|
cd28a8a03e | ||
|
|
6957576867 | ||
|
|
3fea080ed8 | ||
|
|
06bc3d70c2 | ||
|
|
2576a0e340 | ||
|
|
ce163b8258 | ||
|
|
bcec1d6cb8 | ||
|
|
1c44cec343 | ||
|
|
c9848d8fa6 | ||
|
|
0dc0cf83de | ||
|
|
ed3429d372 | ||
|
|
dfac9e1f11 | ||
|
|
7255315ce2 | ||
|
|
346c250901 | ||
|
|
5fc9bff371 | ||
|
|
97f643574c | ||
|
|
e8d76b735b | ||
|
|
03147bc3b1 | ||
|
|
d4348335dd | ||
|
|
246670e22b | ||
|
|
2b4a196cf8 | ||
|
|
0dce7f4845 | ||
|
|
1623fe0852 | ||
|
|
4333870590 | ||
|
|
cc41fd0beb | ||
|
|
59397b4e28 | ||
|
|
768ab5b672 | ||
|
|
8db07d0de5 | ||
|
|
a1515ec662 | ||
|
|
2031be0c23 | ||
|
|
708b7bfb53 | ||
|
|
7782bc11c6 | ||
|
|
a9a66baeba | ||
|
|
16d0fa1165 | ||
|
|
3f65f5c12c | ||
|
|
7cc892ea14 | ||
|
|
9e4bec7e49 | ||
|
|
25d5cd3e14 | ||
|
|
9598449b6b | ||
|
|
a8c7279b3b | ||
|
|
c3c69c730c | ||
|
|
a8c8827a5b | ||
|
|
18b7002604 | ||
|
|
51105a25db | ||
|
|
fb132b4421 | ||
|
|
20b38ac8b0 | ||
|
|
cdd892a526 | ||
|
|
b99e7f5ae2 | ||
|
|
e55a8aeabe | ||
|
|
a8094ed548 | ||
|
|
2104096f02 | ||
|
|
e53870085b | ||
|
|
b62305793d | ||
|
|
a1b29b0d0b | ||
|
|
e458a38f89 | ||
|
|
39177402dd | ||
|
|
3361d031de | ||
|
|
5df1c7f1d3 | ||
|
|
9508165797 | ||
|
|
5878406918 | ||
|
|
fedd46e648 | ||
|
|
d28b17c619 | ||
|
|
f17ef133f3 | ||
|
|
34e7453677 | ||
|
|
8a89f3075f | ||
|
|
5328813814 | ||
|
|
1cbe3a49b2 | ||
|
|
5c79c92c06 | ||
|
|
7b4894da03 | ||
|
|
4eb85a24c2 | ||
|
|
1e7c48e2cf | ||
|
|
6ef13cc8de | ||
|
|
e37a540fb7 | ||
|
|
9baff76520 | ||
|
|
612d58ec37 | ||
|
|
cb981c2930 | ||
|
|
8b8de0c9c6 | ||
|
|
9e4c52989c | ||
|
|
7ff56c62f9 | ||
|
|
370e8fffed | ||
|
|
c272c4c1a6 | ||
|
|
150f75d8f6 | ||
|
|
cf5ec2d277 | ||
|
|
145373ed13 | ||
|
|
8749c3d18d | ||
|
|
688e833413 | ||
|
|
19b434f99c | ||
|
|
0b78800e3d | ||
|
|
6a86ef0ee0 | ||
|
|
4eba9ee41a | ||
|
|
b579751bdf | ||
|
|
bc8d0840eb | ||
|
|
3f615f87cf | ||
|
|
c6d423159c | ||
|
|
791590c8a3 | ||
|
|
036e5cda7e | ||
|
|
44860ff512 | ||
|
|
90927ba788 | ||
|
|
169c055011 | ||
|
|
8623b6836d | ||
|
|
b3d9aa2d84 | ||
|
|
acd3c4f156 | ||
|
|
37b78ad46a | ||
|
|
a15fae6a1e | ||
|
|
c866add320 | ||
|
|
2a05372a51 | ||
|
|
19866c60ee | ||
|
|
3f0ec0b157 | ||
|
|
7c5105b16e | ||
|
|
b97c877992 | ||
|
|
ca96365143 | ||
|
|
3b6c6e3dad | ||
|
|
ad508d79af | ||
|
|
32a0a5c55c | ||
|
|
b103df1bb1 | ||
|
|
c2a153a946 | ||
|
|
49fc4be861 | ||
|
|
b8408e14b9 | ||
|
|
b11a34af5c | ||
|
|
5ed20805ce | ||
|
|
7674477dd0 | ||
|
|
2b11fbe164 | ||
|
|
5f6a910e2d | ||
|
|
9974ad97fa | ||
|
|
c8ebe461d1 | ||
|
|
de1361ac0e | ||
|
|
d877fb021c | ||
|
|
3c56b9e66e | ||
|
|
1ceaf699c2 | ||
|
|
d75f85b089 | ||
|
|
34c1a009b6 | ||
|
|
c9e91be35f | ||
|
|
31afd94a30 | ||
|
|
6f7b9a1482 | ||
|
|
6c23ad661d | ||
|
|
41171e61d1 | ||
|
|
bdf8dec028 | ||
|
|
a872626fba | ||
|
|
00bdbf8ed1 | ||
|
|
25fb3983cb | ||
|
|
4510f10491 | ||
|
|
0ecc7045fa | ||
|
|
5cc1f001fa | ||
|
|
76caadc594 | ||
|
|
7d34efecf2 | ||
|
|
98f138f6b2 | ||
|
|
9179fe26ce | ||
|
|
e6c49e2bd9 | ||
|
|
d84293931b | ||
|
|
2c32ac2221 | ||
|
|
dde6906f98 | ||
|
|
4efd308345 | ||
|
|
f741c274d5 | ||
|
|
cf31c08a5c | ||
|
|
e7574a574b | ||
|
|
33484108a9 | ||
|
|
e697a40fda | ||
|
|
d1f065671e | ||
|
|
02da5c6484 | ||
|
|
b2440cb655 | ||
|
|
160d8b8f0c | ||
|
|
8899713ef9 | ||
|
|
f3cdefce21 | ||
|
|
b115813fbe | ||
|
|
ab45ab316a | ||
|
|
5b3a6c05bf | ||
|
|
cd53681787 | ||
|
|
f0651e5c9b | ||
|
|
f24b731f95 | ||
|
|
01ad424d12 | ||
|
|
435d522683 | ||
|
|
a63c3bc0c7 | ||
|
|
b83ce91528 | ||
|
|
a30238aab6 | ||
|
|
47156c9a5b | ||
|
|
78240c9a46 | ||
|
|
5a9f06e584 | ||
|
|
ecb007da70 | ||
|
|
ad1a4fca36 | ||
|
|
832a738588 | ||
|
|
fedb3ac5c1 | ||
|
|
cb4e55e444 | ||
|
|
3ef5a8e6a4 | ||
|
|
0e915e9b8b | ||
|
|
dba3b287dd | ||
|
|
ce019f508d | ||
|
|
5c2286cc4a | ||
|
|
efc0a94112 | ||
|
|
c60062c418 | ||
|
|
763a1d9371 | ||
|
|
4db1666024 | ||
|
|
0dc69aab1e | ||
|
|
a282cb095e | ||
|
|
259e7182a3 | ||
|
|
74bdf275d2 | ||
|
|
14499223fd | ||
|
|
6aab523dd9 | ||
|
|
1f31e51446 | ||
|
|
c062d0c717 | ||
|
|
dcabaedff1 | ||
|
|
d119ad6c60 | ||
|
|
74fc2e367a | ||
|
|
ea10f94376 | ||
|
|
ef1d77885d | ||
|
|
dccf55eadc | ||
|
|
65704e881f | ||
|
|
a30e82459f | ||
|
|
5a300e92f1 | ||
|
|
8e9fb0f93c | ||
|
|
11ea819bc7 | ||
|
|
abc9098bd8 | ||
|
|
00cffa09a2 | ||
|
|
aa944456e0 | ||
|
|
409d74a48d | ||
|
|
7957808f00 | ||
|
|
52bfc0efb1 | ||
|
|
46f90d0c54 | ||
|
|
5177972c04 | ||
|
|
ece90ee97b | ||
|
|
4cfa4893ef | ||
|
|
edd4a86d14 | ||
|
|
44dcd82d78 | ||
|
|
12da2140cf | ||
|
|
c006e9804a | ||
|
|
6343c11873 | ||
|
|
2ab09e1a77 | ||
|
|
a3ea77c2f1 | ||
|
|
7fe4399b94 | ||
|
|
545f762568 | ||
|
|
e83439eaae | ||
|
|
b6a9761dae | ||
|
|
fbe4b39a16 | ||
|
|
ecca240eef | ||
|
|
347a6155f8 | ||
|
|
a8e721abeb | ||
|
|
2d62ea78d2 | ||
|
|
8ef0f38f4e | ||
|
|
3c47ab42d6 | ||
|
|
aafeca5859 | ||
|
|
132f494195 | ||
|
|
5e9de7c94b | ||
|
|
b7d0b676fc | ||
|
|
55b45fc6fb | ||
|
|
29404a782c | ||
|
|
185f12266a | ||
|
|
0b1065feee | ||
|
|
47da743d11 | ||
|
|
470226ca03 | ||
|
|
7b7107556f | ||
|
|
f90b776071 | ||
|
|
e26237e57d | ||
|
|
d2c3f749ab | ||
|
|
b7cc271219 | ||
|
|
17eed7055b | ||
|
|
b7121940c8 | ||
|
|
07fa16e2e7 | ||
|
|
8aa7db15e5 | ||
|
|
8b637b339b | ||
|
|
4f866c563b | ||
|
|
7a999f9a48 | ||
|
|
0a88c81d95 | ||
|
|
804e8fb856 | ||
|
|
43939a9199 | ||
|
|
5c9d050ebe | ||
|
|
285a508e09 | ||
|
|
92f55f721f | ||
|
|
476d540065 | ||
|
|
ae730d6357 | ||
|
|
9823205ef3 | ||
|
|
fffa902a1a | ||
|
|
463e3833da | ||
|
|
4416aacebb | ||
|
|
15a626bcf3 | ||
|
|
a347a2e879 | ||
|
|
7be4566b41 | ||
|
|
96e26c6a5f | ||
|
|
7362c01c67 | ||
|
|
e45b4e4938 | ||
|
|
e32329cc11 | ||
|
|
199e368fe2 | ||
|
|
55726120db | ||
|
|
346ba7e831 | ||
|
|
9518006724 | ||
|
|
3d5f6f692f | ||
|
|
4712c15c75 | ||
|
|
9bc72529a6 | ||
|
|
6d20943a9c | ||
|
|
a3af82867b | ||
|
|
27483a4101 | ||
|
|
6d20ebc66b | ||
|
|
031a038b49 | ||
|
|
f6c7434b2a | ||
|
|
3d9fecb562 | ||
|
|
ebe93e2c68 | ||
|
|
1f4fcead38 | ||
|
|
1f000ba668 | ||
|
|
e0d28d7f5c | ||
|
|
47a58dd799 | ||
|
|
37bea1c153 | ||
|
|
d1a7b8df8b | ||
|
|
c01f24553c | ||
|
|
07e8b303b9 | ||
|
|
ebb0923362 | ||
|
|
e1c279718f | ||
|
|
cf764740cf | ||
|
|
58d549ed4c | ||
|
|
6b22d8068d | ||
|
|
3f01f9895f | ||
|
|
801a1817b5 | ||
|
|
32c76ae828 | ||
|
|
c664f5fc95 | ||
|
|
2bc4681fd6 | ||
|
|
646501b0ef | ||
|
|
de3e657d8b | ||
|
|
1add726892 | ||
|
|
5a000cf5e4 | ||
|
|
6ed676fe32 | ||
|
|
7cceddb8a3 | ||
|
|
039b897e7b | ||
|
|
b178f74e5d | ||
|
|
e5224e22ae | ||
|
|
be66e8b99c | ||
|
|
4b91c8aff4 | ||
|
|
617e2ec1e0 | ||
|
|
1afac0312d | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
8a1aa5753d |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
|
||||||
|
"statusMessage": "Setting up dev environment (script/setup)...",
|
||||||
|
"timeout": 900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Cache clang-tidy idedata
|
||||||
|
description: >
|
||||||
|
Cache the clang-tidy idedata and the headers it references under .temp
|
||||||
|
(headers only, about 30MB per env). Run after restore-python and cache-esp-idf.
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'clang-tidy environment (e.g. esp32-idf-tidy).'
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Compute cache key
|
||||||
|
id: key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
. venv/bin/activate
|
||||||
|
[ -n "${{ inputs.environment }}" ] || { echo "::error::cache-clang-tidy-idedata: 'environment' input is empty"; exit 1; }
|
||||||
|
hash=$(python -c 'import sys; sys.path.insert(0, "script"); from clang_tidy_hash import idedata_cache_hash; print(idedata_cache_hash("${{ inputs.environment }}"))')
|
||||||
|
pyver=$(python -c 'import platform; print(platform.python_version())')
|
||||||
|
# Generating idedata is what installs ESP-IDF; never skip it over a missing
|
||||||
|
# install. This also skips the save, so a dev run that installs ESP-IDF
|
||||||
|
# warms the idedata cache on the next run.
|
||||||
|
if [ -d ~/.esphome-idf/frameworks ]; then
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ESP-IDF install missing, not using the clang-tidy idedata cache"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "key=${{ runner.os }}-tidy-idedata-${{ inputs.environment }}-$hash-py$pyver" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo "path<<EOF"
|
||||||
|
printf '%s\n' '.temp/idedata-*.json' '.temp/idedata-*.hash'
|
||||||
|
printf '.temp/**/*.%s\n' h hpp hh hxx inc inl ipp tpp
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
# Mirror cache-esp-idf: write on dev, restore-only on PRs. The post-step
|
||||||
|
# save only runs when the job succeeded, so a failed generation is never saved.
|
||||||
|
# Extend the extension list if a component ships extensionless headers.
|
||||||
|
- name: Cache clang-tidy idedata (write on dev)
|
||||||
|
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && steps.key.outputs.skip != 'true'
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ steps.key.outputs.path }}
|
||||||
|
key: ${{ steps.key.outputs.key }}
|
||||||
|
- name: Cache clang-tidy idedata (restore-only off dev)
|
||||||
|
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') && steps.key.outputs.skip != 'true'
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ steps.key.outputs.path }}
|
||||||
|
key: ${{ steps.key.outputs.key }}
|
||||||
@@ -26,6 +26,9 @@ runs:
|
|||||||
# The native-IDF version is pinned in code, not in any file that feeds the
|
# The native-IDF version is pinned in code, not in any file that feeds the
|
||||||
# other cache keys, so resolve it explicitly. Keying on it means the cache
|
# other cache keys, so resolve it explicitly. Keying on it means the cache
|
||||||
# invalidates on a version bump (actions/cache never overwrites a key).
|
# invalidates on a version bump (actions/cache never overwrites a key).
|
||||||
|
# Also key on the Python version: the cached IDF venv links to the
|
||||||
|
# runner's toolcache interpreter and is reinstalled every run after a
|
||||||
|
# runner image bump.
|
||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -36,19 +39,32 @@ runs:
|
|||||||
version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
|
version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
|
||||||
fi
|
fi
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "python-version=$(python -c 'import platform; print(platform.python_version())')" >> "$GITHUB_OUTPUT"
|
||||||
# Mirror the adjacent PlatformIO cache: only dev-branch runs write the
|
# Mirror the adjacent PlatformIO cache: only dev-branch runs write the
|
||||||
# shared cache (so it lives in the default-branch scope readable by all
|
# shared cache (so it lives in the default-branch scope readable by all
|
||||||
# PRs), and PRs are restore-only -- they never push multi-GB artifacts into
|
# PRs), and PRs are restore-only -- they never push multi-GB artifacts into
|
||||||
# their own scope / the repo quota (e.g. on a version-bump PR).
|
# their own scope / the repo quota (e.g. on a version-bump PR). The
|
||||||
|
# ci-cache-write label lets a PR write into its own scope to test the hit path;
|
||||||
|
# that costs about 1GB of the repo cache quota per run, so remove it when done.
|
||||||
|
# -slim: bump when prune-esp-idf changes what it removes; a key is never overwritten.
|
||||||
- name: Cache ESP-IDF install (write on dev)
|
- name: Cache ESP-IDF install (write on dev)
|
||||||
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
|
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && inputs.restore-only != 'true'
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.esphome-idf
|
path: ~/.esphome-idf
|
||||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
|
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
|
||||||
- name: Cache ESP-IDF install (restore-only off dev)
|
- name: Cache ESP-IDF install (restore-only off dev)
|
||||||
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
|
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') || inputs.restore-only == 'true'
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.esphome-idf
|
path: ~/.esphome-idf
|
||||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
|
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
|
||||||
|
# Install explicitly so the prune below sees the toolchains on a cache miss
|
||||||
|
# too, instead of the install happening inside the first build step.
|
||||||
|
- name: Install ESP-IDF
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
. venv/bin/activate
|
||||||
|
python -c 'from esphome.espidf.framework import check_esp_idf_install; check_esp_idf_install("${{ steps.version.outputs.version }}")'
|
||||||
|
- name: Prune ESP-IDF install
|
||||||
|
uses: ./.github/actions/prune-esp-idf
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Prune ESP-IDF install
|
||||||
|
description: >
|
||||||
|
Remove the picolibc sysroots (1.1GB of the 3.9GB install) from the native
|
||||||
|
ESP-IDF toolchains; IDF 5.x links newlib. Skipped when an IDF 6 install is
|
||||||
|
present, which links picolibc (see esp32/__init__.py).
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Prune picolibc
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
prefix="${ESPHOME_ESP_IDF_PREFIX:-$HOME/.esphome-idf}"
|
||||||
|
prefix="${prefix/#\~/$HOME}"
|
||||||
|
for fw in "$prefix"/frameworks/*/; do
|
||||||
|
case "$(basename "$fw")" in
|
||||||
|
[6-9].*) echo "IDF $(basename "$fw") installed, keeping picolibc"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
n=0
|
||||||
|
for dir in "$prefix"/tools/*-esp-elf/*/*-esp-elf/picolibc; do
|
||||||
|
echo "Removing $dir ($(du -sh "$dir" | cut -f1))"
|
||||||
|
rm -rf "$dir"
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
# The marker rides along in the cache entry so a restored slim tree stays quiet.
|
||||||
|
if [ "$n" -gt 0 ]; then
|
||||||
|
touch "$prefix/.picolibc-pruned"
|
||||||
|
elif [ -d "$prefix/tools" ] && [ ! -f "$prefix/.picolibc-pruned" ]; then
|
||||||
|
echo "::warning::no picolibc sysroots matched under $prefix/tools"
|
||||||
|
fi
|
||||||
|
if [ -d "$prefix" ]; then
|
||||||
|
du -sh "$prefix"
|
||||||
|
fi
|
||||||
@@ -32,7 +32,7 @@ runs:
|
|||||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||||
# downstream jobs rely on is preserved.
|
# downstream jobs rely on is preserved.
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pull request saves land in per-PR scopes nothing else can
|
# Pull request saves land in per-PR scopes nothing else can
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
|
|||||||
async function detectMergeBranch(github, context) {
|
async function detectMergeBranch(github, context) {
|
||||||
const labels = new Set();
|
const labels = new Set();
|
||||||
const baseRef = context.payload.pull_request.base.ref;
|
const baseRef = context.payload.pull_request.base.ref;
|
||||||
|
const defaultBranch = context.payload.repository.default_branch;
|
||||||
|
|
||||||
if (baseRef === 'release') {
|
if (baseRef === 'release') {
|
||||||
labels.add('merging-to-release');
|
labels.add('merging-to-release');
|
||||||
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
|
|||||||
} else if (await isStackedPr(github, context)) {
|
} else if (await isStackedPr(github, context)) {
|
||||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||||
labels.add('stacked-pr');
|
labels.add('stacked-pr');
|
||||||
} else if (baseRef !== 'dev') {
|
} else if (baseRef !== defaultBranch) {
|
||||||
// A chain built by hand: it must not merge until its base branch does.
|
// A chain built by hand: it must not merge until its base branch does.
|
||||||
labels.add('chained-pr');
|
labels.add('chained-pr');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
|||||||
|
|
||||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||||
function makeMergeContext(baseRef, { stack } = {}) {
|
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||||
if (stack !== undefined) {
|
if (stack !== undefined) {
|
||||||
pull_request.stack = stack;
|
pull_request.stack = stack;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
repo: { owner: 'esphome', repo: 'esphome' },
|
repo: { owner: 'esphome', repo: 'esphome' },
|
||||||
payload: { pull_request }
|
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
|
|||||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||||
assert.equal(state.calls, 1);
|
assert.equal(state.calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('base ref matches default branch adds no labels', async () => {
|
||||||
|
const { github } = makeStackGithub({ stack: null });
|
||||||
|
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||||
|
const labels = await detectMergeBranch(github, context);
|
||||||
|
assert.deepEqual(Array.from(labels).sort(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||||
|
const { github } = makeStackGithub({ stack: null });
|
||||||
|
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||||
|
const labels = await detectMergeBranch(github, context);
|
||||||
|
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
# ``--system`` (below) installs into the setup-python interpreter;
|
# ``--system`` (below) installs into the setup-python interpreter;
|
||||||
# no venv is created or restored by this workflow.
|
# no venv is created or restored by this workflow.
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pull-request-only workflow: a save could never be shared and
|
# Pull-request-only workflow: a save could never be shared and
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
|
||||||
- name: Determine tag and whether to push
|
- name: Determine tag and whether to push
|
||||||
id: tag
|
id: tag
|
||||||
@@ -119,16 +119,22 @@ jobs:
|
|||||||
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
|
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
|
||||||
- name: Export image for compile-test
|
- name: Export image for compile-test
|
||||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
|
# zstd over gzip: docker save is on the critical path for every
|
||||||
|
# compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
|
||||||
|
# docker load auto-detects the format; its time is layer extraction,
|
||||||
|
# not decompression, so it is unchanged. shell: bash adds pipefail so
|
||||||
|
# a failed docker save cannot upload a truncated artifact.
|
||||||
|
shell: bash
|
||||||
|
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
|
||||||
|
|
||||||
- name: Upload compile-test image artifact
|
- name: Upload compile-test image artifact
|
||||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
# The tar is already gzipped, so upload it as-is. archive: false skips
|
# The tar is already compressed, so upload it as-is. archive: false
|
||||||
# the redundant zip and makes the file name the artifact name (the
|
# skips the redundant zip and makes the file name the artifact name
|
||||||
# `name` input is ignored in that mode).
|
# (the `name` input is ignored in that mode).
|
||||||
path: compile-test-image.tar.gz
|
path: compile-test-image.tar.zst
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
archive: false
|
archive: false
|
||||||
|
|
||||||
@@ -153,7 +159,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
|
||||||
- name: Log in to the GitHub container registry
|
- name: Log in to the GitHub container registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
@@ -182,8 +188,6 @@ jobs:
|
|||||||
contents: read # actions/checkout to load the test configs
|
contents: read # actions/checkout to load the test configs
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# Modest cap so this smoke test leaves room on the shared runner pool.
|
|
||||||
max-parallel: 8
|
|
||||||
matrix:
|
matrix:
|
||||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||||
@@ -208,9 +212,9 @@ jobs:
|
|||||||
- name: Download image artifact
|
- name: Download image artifact
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: compile-test-image.tar.gz
|
name: compile-test-image.tar.zst
|
||||||
- name: Load image
|
- name: Load image
|
||||||
run: docker load --input compile-test-image.tar.gz
|
run: docker load --input compile-test-image.tar.zst
|
||||||
- name: Compile ${{ matrix.id }}
|
- name: Compile ${{ matrix.id }}
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
|||||||
+115
-10
@@ -49,7 +49,7 @@ jobs:
|
|||||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||||
# that ``. venv/bin/activate`` see an identical layout.
|
# that ``. venv/bin/activate`` see an identical layout.
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pull request saves land in per-PR scopes nothing else can
|
# Pull request saves land in per-PR scopes nothing else can
|
||||||
@@ -92,6 +92,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||||
|
integration-run-all: ${{ steps.determine.outputs.integration-run-all }}
|
||||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||||
@@ -112,6 +113,12 @@ jobs:
|
|||||||
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
|
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
|
||||||
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
|
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
|
||||||
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
||||||
|
# "true" when this run is a pull request into one of the release
|
||||||
|
# branches. Those pull requests are batches of changes already tested on
|
||||||
|
# their original dev pull requests, so several jobs below trade coverage
|
||||||
|
# for turnaround time on them. Matched exactly, not by prefix, so an
|
||||||
|
# ordinary branch named e.g. "release-notes" is not caught by it.
|
||||||
|
release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -146,6 +153,9 @@ jobs:
|
|||||||
# Extract individual fields
|
# Extract individual fields
|
||||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||||
|
# A missing key must fail here, not silently disable the junit upload
|
||||||
|
run_all=$(echo "$output" | jq -r 'if has("integration_run_all") then .integration_run_all else error("integration_run_all missing") end')
|
||||||
|
echo "integration-run-all=${run_all}" >> $GITHUB_OUTPUT
|
||||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||||
@@ -230,7 +240,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- determine-jobs
|
- determine-jobs
|
||||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -349,6 +359,15 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
||||||
|
env:
|
||||||
|
# What the cache steps persist; libdeps is excluded (keyed per xdist
|
||||||
|
# worker and env, it never crosses runs).
|
||||||
|
INTEGRATION_PIO_CACHE_PATH: |
|
||||||
|
~/.esphome-integration-tests/platformio/platforms
|
||||||
|
~/.esphome-integration-tests/platformio/packages
|
||||||
|
~/.esphome-integration-tests/platformio/appstate.json
|
||||||
|
~/.esphome-integration-tests/platformio/.cache
|
||||||
|
~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -367,6 +386,14 @@ jobs:
|
|||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
- name: Restore integration PlatformIO cache
|
||||||
|
# Native platform + toolchain installed by shared_platformio_cache in
|
||||||
|
# tests/integration/conftest.py; a miss self-heals, so no restore-keys.
|
||||||
|
id: pio-cache
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||||
|
key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
@@ -376,7 +403,7 @@ jobs:
|
|||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
# Only needed on cache miss to populate the venv.
|
# Only needed on cache miss to populate the venv.
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pull request saves land in per-PR scopes nothing else can
|
# Pull request saves land in per-PR scopes nothing else can
|
||||||
@@ -404,12 +431,36 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
|
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
|
||||||
|
if [ "${#test_files[@]}" -eq 0 ]; then
|
||||||
|
echo "::error::Empty integration test bucket; pytest would collect the whole tree"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||||
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
|
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \
|
||||||
|
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||||
|
- name: Upload junit timings
|
||||||
|
# Consumed by sync-integration-durations.yml through
|
||||||
|
# script/update_integration_test_durations.py; only full matrix dev
|
||||||
|
# runs produce usable data.
|
||||||
|
if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: junit-integration-${{ strategy.job-index }}
|
||||||
|
path: junit-integration.xml
|
||||||
|
if-no-files-found: error
|
||||||
|
# A full cron period of margin for the weekly refresh
|
||||||
|
retention-days: 14
|
||||||
- name: Print ccache statistics
|
- name: Print ccache statistics
|
||||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
||||||
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
||||||
|
- name: Save integration PlatformIO cache
|
||||||
|
# Bucket 0 only; the others would race the same immutable key.
|
||||||
|
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||||
|
key: ${{ steps.pio-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
import-time:
|
import-time:
|
||||||
name: Check import esphome.__main__ time
|
name: Check import esphome.__main__ time
|
||||||
@@ -447,8 +498,23 @@ jobs:
|
|||||||
- common
|
- common
|
||||||
- determine-jobs
|
- determine-jobs
|
||||||
if: >-
|
if: >-
|
||||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
github.repository == 'esphome/esphome' && (
|
||||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
needs.determine-jobs.outputs.release-pr == 'false' &&
|
||||||
|
needs.determine-jobs.outputs.benchmarks == 'true'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||||
|
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||||
|
#
|
||||||
|
# Pull requests into beta and release are skipped as well. CodSpeed compares a
|
||||||
|
# pull request against the newest commit of its base branch that has a benchmark
|
||||||
|
# run of its own, and only dev is benchmarked. A release pull request therefore
|
||||||
|
# falls back to dev's latest run, so every speed-up merged into dev since the
|
||||||
|
# release branched is reported as a regression in the release. The changes there
|
||||||
|
# have already been benchmarked on their original dev pull requests.
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -515,7 +581,7 @@ jobs:
|
|||||||
apt-get install -y libc6-dbg
|
apt-get install -y libc6-dbg
|
||||||
|
|
||||||
- name: Run CodSpeed benchmarks
|
- name: Run CodSpeed benchmarks
|
||||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||||
with:
|
with:
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
@@ -628,6 +694,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
framework: arduino
|
framework: arduino
|
||||||
|
|
||||||
|
- name: Cache clang-tidy idedata
|
||||||
|
if: matrix.cache_idf
|
||||||
|
uses: ./.github/actions/cache-clang-tidy-idedata
|
||||||
|
with:
|
||||||
|
environment: esp32-arduino-tidy
|
||||||
|
|
||||||
- name: Cache nRF Connect SDK install
|
- name: Cache nRF Connect SDK install
|
||||||
if: matrix.cache_sdk_nrf
|
if: matrix.cache_sdk_nrf
|
||||||
uses: ./.github/actions/cache-sdk-nrf
|
uses: ./.github/actions/cache-sdk-nrf
|
||||||
@@ -677,6 +749,10 @@ jobs:
|
|||||||
# Also cache libdeps, store them in a ~/.platformio subfolder
|
# Also cache libdeps, store them in a ~/.platformio subfolder
|
||||||
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
||||||
|
|
||||||
|
- name: Prune ESP-IDF install before cache save
|
||||||
|
if: matrix.cache_idf && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
|
||||||
|
uses: ./.github/actions/prune-esp-idf
|
||||||
|
|
||||||
- name: Suggested changes
|
- name: Suggested changes
|
||||||
run: script/ci-suggest-changes ${{ matrix.ignore_errors && '|| true' || '' }}
|
run: script/ci-suggest-changes ${{ matrix.ignore_errors && '|| true' || '' }}
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
@@ -709,6 +785,11 @@ jobs:
|
|||||||
- name: Cache ESP-IDF install
|
- name: Cache ESP-IDF install
|
||||||
uses: ./.github/actions/cache-esp-idf
|
uses: ./.github/actions/cache-esp-idf
|
||||||
|
|
||||||
|
- name: Cache clang-tidy idedata
|
||||||
|
uses: ./.github/actions/cache-clang-tidy-idedata
|
||||||
|
with:
|
||||||
|
environment: esp32-idf-tidy
|
||||||
|
|
||||||
- name: Register problem matchers
|
- name: Register problem matchers
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
||||||
@@ -743,6 +824,10 @@ jobs:
|
|||||||
# Also cache libdeps, store them in a ~/.platformio subfolder
|
# Also cache libdeps, store them in a ~/.platformio subfolder
|
||||||
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
||||||
|
|
||||||
|
- name: Prune ESP-IDF install before cache save
|
||||||
|
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
|
||||||
|
uses: ./.github/actions/prune-esp-idf
|
||||||
|
|
||||||
- name: Suggested changes
|
- name: Suggested changes
|
||||||
run: script/ci-suggest-changes
|
run: script/ci-suggest-changes
|
||||||
if: always()
|
if: always()
|
||||||
@@ -788,6 +873,11 @@ jobs:
|
|||||||
- name: Cache ESP-IDF install
|
- name: Cache ESP-IDF install
|
||||||
uses: ./.github/actions/cache-esp-idf
|
uses: ./.github/actions/cache-esp-idf
|
||||||
|
|
||||||
|
- name: Cache clang-tidy idedata
|
||||||
|
uses: ./.github/actions/cache-clang-tidy-idedata
|
||||||
|
with:
|
||||||
|
environment: esp32-idf-tidy
|
||||||
|
|
||||||
- name: Register problem matchers
|
- name: Register problem matchers
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
||||||
@@ -822,6 +912,10 @@ jobs:
|
|||||||
# Also cache libdeps, store them in a ~/.platformio subfolder
|
# Also cache libdeps, store them in a ~/.platformio subfolder
|
||||||
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps
|
||||||
|
|
||||||
|
- name: Prune ESP-IDF install before cache save
|
||||||
|
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
|
||||||
|
uses: ./.github/actions/prune-esp-idf
|
||||||
|
|
||||||
- name: Suggested changes
|
- name: Suggested changes
|
||||||
run: script/ci-suggest-changes
|
run: script/ci-suggest-changes
|
||||||
if: always()
|
if: always()
|
||||||
@@ -845,16 +939,19 @@ jobs:
|
|||||||
name: Run script/clang-tidy for ESP32 S3
|
name: Run script/clang-tidy for ESP32 S3
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
|
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
|
||||||
|
tidy_environment: esp32s3-idf-tidy
|
||||||
- id: clang-tidy
|
- id: clang-tidy
|
||||||
name: Run script/clang-tidy for ESP32 P4
|
name: Run script/clang-tidy for ESP32 P4
|
||||||
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
|
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
|
||||||
# so their code paths differ -- lint them under the P4 build too.
|
# so their code paths differ -- lint them under the P4 build too.
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
||||||
|
tidy_environment: esp32p4-idf-tidy
|
||||||
- id: clang-tidy
|
- id: clang-tidy
|
||||||
name: Run script/clang-tidy for ESP32 C6
|
name: Run script/clang-tidy for ESP32 C6
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
|
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
|
||||||
|
tidy_environment: esp32c6-idf-tidy
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
@@ -872,6 +969,11 @@ jobs:
|
|||||||
- name: Cache ESP-IDF install
|
- name: Cache ESP-IDF install
|
||||||
uses: ./.github/actions/cache-esp-idf
|
uses: ./.github/actions/cache-esp-idf
|
||||||
|
|
||||||
|
- name: Cache clang-tidy idedata
|
||||||
|
uses: ./.github/actions/cache-clang-tidy-idedata
|
||||||
|
with:
|
||||||
|
environment: ${{ matrix.tidy_environment }}
|
||||||
|
|
||||||
- name: Register problem matchers
|
- name: Register problem matchers
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
echo "::add-matcher::.github/workflows/matchers/gcc.json"
|
||||||
@@ -905,6 +1007,10 @@ jobs:
|
|||||||
script/clang-tidy --fix --changed ${{ matrix.options }}
|
script/clang-tidy --fix --changed ${{ matrix.options }}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Prune ESP-IDF install before cache save
|
||||||
|
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write'))
|
||||||
|
uses: ./.github/actions/prune-esp-idf
|
||||||
|
|
||||||
- name: Suggested changes
|
- name: Suggested changes
|
||||||
run: script/ci-suggest-changes
|
run: script/ci-suggest-changes
|
||||||
if: always()
|
if: always()
|
||||||
@@ -925,7 +1031,6 @@ jobs:
|
|||||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
|
|
||||||
matrix:
|
matrix:
|
||||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||||
steps:
|
steps:
|
||||||
@@ -1012,7 +1117,7 @@ jobs:
|
|||||||
# - This catches pin conflicts and other issues in directly changed code
|
# - This catches pin conflicts and other issues in directly changed code
|
||||||
# - Grouped tests use --testing-mode to allow config merging (disables some checks)
|
# - Grouped tests use --testing-mode to allow config merging (disables some checks)
|
||||||
# - Dependencies are safe to group since they weren't modified in this PR
|
# - Dependencies are safe to group since they weren't modified in this PR
|
||||||
if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
|
if [[ "${{ needs.determine-jobs.outputs.release-pr }}" == "true" ]]; then
|
||||||
directly_changed_csv=""
|
directly_changed_csv=""
|
||||||
echo "Testing components: $components_csv"
|
echo "Testing components: $components_csv"
|
||||||
echo "Target branch: ${{ github.base_ref }} - grouping all components"
|
echo "Target branch: ${{ github.base_ref }} - grouping all components"
|
||||||
@@ -1149,7 +1254,7 @@ jobs:
|
|||||||
# install step (order-of-magnitude faster on cold boots,
|
# install step (order-of-magnitude faster on cold boots,
|
||||||
# with its own wheel cache). actions/setup-python still
|
# with its own wheel cache). actions/setup-python still
|
||||||
# provides the interpreter.
|
# provides the interpreter.
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pull request saves land in per-PR scopes nothing else can
|
# Pull request saves land in per-PR scopes nothing else can
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
@@ -84,6 +84,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
issues: write # issues.lock on closed issues
|
issues: write # issues.lock on closed issues
|
||||||
pull-requests: write # issues.lock on closed pull requests
|
pull-requests: write # issues.lock on closed pull requests
|
||||||
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
|
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
|
||||||
- name: Log in to docker hub
|
- name: Log in to docker hub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
@@ -202,7 +202,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
|
||||||
- name: Log in to docker hub
|
- name: Log in to docker hub
|
||||||
if: matrix.registry == 'dockerhub'
|
if: matrix.registry == 'dockerhub'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||||
# GitHub App token so the labels, comments and closures come from
|
# GitHub App token so the labels, comments and closures come from
|
||||||
# esphome[bot] instead of github-actions[bot].
|
# esphome[bot] instead of github-actions[bot].
|
||||||
uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
|
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
|
||||||
secrets:
|
secrets:
|
||||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
# setup-python interpreter so subsequent ``prek`` /
|
# setup-python interpreter so subsequent ``prek`` /
|
||||||
# ``script/run-in-env.py`` steps find the deps without a
|
# ``script/run-in-env.py`` steps find the deps without a
|
||||||
# ``uv run`` prefix.
|
# ``uv run`` prefix.
|
||||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
# Pin uv version so the action does not have to fetch the
|
# Pin uv version so the action does not have to fetch the
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: Refresh integration test durations
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "45 5 * * 1"
|
||||||
|
|
||||||
|
# Repo writes (branch push, PR open) happen via the App token minted below,
|
||||||
|
# so the workflow's GITHUB_TOKEN does not need any write scopes.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read # gh api / gh run download for the CI junit artifacts
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
name: Refresh integration test durations
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'esphome/esphome'
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
|
with:
|
||||||
|
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||||
|
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||||
|
permission-contents: write # push the sync branch
|
||||||
|
permission-pull-requests: write # open or refresh the sync PR
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Refresh from the newest usable dev run
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# Only full matrix dev runs upload junit-integration-* artifacts
|
||||||
|
# (see the integration-tests job); the merge script re-checks
|
||||||
|
# coverage regardless.
|
||||||
|
# Newest-first candidates via their bucket-0 artifact. Fork PRs run
|
||||||
|
# their own ci.yml, so name and branch are spoofable; require
|
||||||
|
# same-repo. Assignment failures trip set -e and fail loudly.
|
||||||
|
candidates=$(
|
||||||
|
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=junit-integration-0&per_page=100" \
|
||||||
|
--jq '.artifacts[] | select(.expired | not) | .workflow_run
|
||||||
|
| select(.head_branch == "dev" and .head_repository_id != null
|
||||||
|
and .head_repository_id == .repository_id)
|
||||||
|
| .id'
|
||||||
|
)
|
||||||
|
# Green runs first, then the rest newest first; a run missing a
|
||||||
|
# bucket fails the coverage check and the next one is tried
|
||||||
|
green=""
|
||||||
|
rest=""
|
||||||
|
for id in ${candidates}; do
|
||||||
|
conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${id}" --jq '.conclusion // ""')
|
||||||
|
if [ "${conclusion}" = "success" ]; then
|
||||||
|
green="${green} ${id}"
|
||||||
|
elif [ -n "${conclusion}" ]; then
|
||||||
|
rest="${rest} ${id}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# helpers.py imports colorama; the script needs nothing else
|
||||||
|
pip install colorama
|
||||||
|
for id in ${green} ${rest}; do
|
||||||
|
rm -rf /tmp/junit
|
||||||
|
if ! gh run download "${id}" --repo "${GITHUB_REPOSITORY}" -p "junit-integration-*" -D /tmp/junit; then
|
||||||
|
echo "::warning::Could not download artifacts for run ${id}; trying the next"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
status=0
|
||||||
|
python script/update_integration_test_durations.py /tmp/junit || status=$?
|
||||||
|
if [ "${status}" -eq 0 ]; then
|
||||||
|
echo "Refreshed from run ${id}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Only EXIT_LOW_COVERAGE (3) from the script advances to the next run
|
||||||
|
[ "${status}" -eq 3 ] || exit 1
|
||||||
|
echo "::warning::Run ${id} covers too few test files; trying the next"
|
||||||
|
done
|
||||||
|
echo "::error::No dev CI run with usable junit artifacts in range; the feed is starved"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
|
with:
|
||||||
|
commit-message: "[ci] Refresh integration test durations"
|
||||||
|
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||||
|
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||||
|
branch: sync/integration-durations
|
||||||
|
delete-branch: true
|
||||||
|
title: "[ci] Refresh integration test durations"
|
||||||
|
body-path: .github/PULL_REQUEST_TEMPLATE.md
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
@@ -11,7 +11,7 @@ ci:
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.16.0
|
rev: v0.16.3
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter.
|
# Run the linter.
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
|||||||
@@ -763,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code
|
|||||||
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
|
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
|
||||||
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
|
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
|
||||||
audience, including non-native English speakers.
|
audience, including non-native English speakers.
|
||||||
|
|
||||||
|
## 10. Code Comments
|
||||||
|
|
||||||
|
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
|
||||||
|
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
|
||||||
|
|
||||||
|
Function and method comment blocks may include more detail as required to make
|
||||||
|
calling contracts clear and document parameter usage, but should still be kept concise.
|
||||||
|
|
||||||
|
Avoid redundancy and repetition; comments should never simply restate what the code already says.
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ esphome/components/cst816/* @clydebarrow
|
|||||||
esphome/components/cst9220/* @clydebarrow
|
esphome/components/cst9220/* @clydebarrow
|
||||||
esphome/components/ct_clamp/* @jesserockz
|
esphome/components/ct_clamp/* @jesserockz
|
||||||
esphome/components/current_based/* @djwmarcx
|
esphome/components/current_based/* @djwmarcx
|
||||||
|
esphome/components/d01/* @ch604
|
||||||
esphome/components/dac7678/* @NickB1
|
esphome/components/dac7678/* @NickB1
|
||||||
esphome/components/daikin_arc/* @MagicBear
|
esphome/components/daikin_arc/* @MagicBear
|
||||||
esphome/components/daikin_brc/* @hagak
|
esphome/components/daikin_brc/* @hagak
|
||||||
@@ -148,6 +149,7 @@ esphome/components/display_menu_base/* @numo68
|
|||||||
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||||
esphome/components/dps310/* @kbx81
|
esphome/components/dps310/* @kbx81
|
||||||
esphome/components/ds1307/* @badbadc0ffee
|
esphome/components/ds1307/* @badbadc0ffee
|
||||||
|
esphome/components/ds1603l/* @JakeLC15
|
||||||
esphome/components/ds2484/* @mrk-its
|
esphome/components/ds2484/* @mrk-its
|
||||||
esphome/components/ds248x/* @tomwellnitz
|
esphome/components/ds248x/* @tomwellnitz
|
||||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||||
@@ -350,6 +352,7 @@ esphome/components/mipi_spi/* @clydebarrow
|
|||||||
esphome/components/mitsubishi/* @RubyBailey
|
esphome/components/mitsubishi/* @RubyBailey
|
||||||
esphome/components/mitsubishi_cn105/* @crnjan
|
esphome/components/mitsubishi_cn105/* @crnjan
|
||||||
esphome/components/mixer/speaker/* @kahrendt
|
esphome/components/mixer/speaker/* @kahrendt
|
||||||
|
esphome/components/mk2pvrouter/* @FredM67
|
||||||
esphome/components/mlx90393/* @functionpointer
|
esphome/components/mlx90393/* @functionpointer
|
||||||
esphome/components/mlx90614/* @jesserockz
|
esphome/components/mlx90614/* @jesserockz
|
||||||
esphome/components/mmc5603/* @benhoff
|
esphome/components/mmc5603/* @benhoff
|
||||||
@@ -381,6 +384,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
|
|||||||
esphome/components/nextion/text_sensor/* @senexcrenshaw
|
esphome/components/nextion/text_sensor/* @senexcrenshaw
|
||||||
esphome/components/nfc/* @jesserockz @kbx81
|
esphome/components/nfc/* @jesserockz @kbx81
|
||||||
esphome/components/noblex/* @AGalfra
|
esphome/components/noblex/* @AGalfra
|
||||||
|
esphome/components/noise/* @esphome/core
|
||||||
esphome/components/npi19/* @bakerkj
|
esphome/components/npi19/* @bakerkj
|
||||||
esphome/components/nrf52/* @tomaszduda23
|
esphome/components/nrf52/* @tomaszduda23
|
||||||
esphome/components/number/* @esphome/core
|
esphome/components/number/* @esphome/core
|
||||||
@@ -475,6 +479,7 @@ esphome/components/sensirion_common/* @martgras
|
|||||||
esphome/components/sensor/* @esphome/core
|
esphome/components/sensor/* @esphome/core
|
||||||
esphome/components/serial_proxy/* @kbx81
|
esphome/components/serial_proxy/* @kbx81
|
||||||
esphome/components/sfa30/* @ghsensdev
|
esphome/components/sfa30/* @ghsensdev
|
||||||
|
esphome/components/sfa40/* @NoQuarrel
|
||||||
esphome/components/sgp40/* @SenexCrenshaw
|
esphome/components/sgp40/* @SenexCrenshaw
|
||||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||||
esphome/components/sha256/* @esphome/core
|
esphome/components/sha256/* @esphome/core
|
||||||
@@ -575,6 +580,7 @@ esphome/components/tuya/select/* @bearpawmaxim
|
|||||||
esphome/components/tuya/sensor/* @jesserockz
|
esphome/components/tuya/sensor/* @jesserockz
|
||||||
esphome/components/tuya/switch/* @jesserockz
|
esphome/components/tuya/switch/* @jesserockz
|
||||||
esphome/components/tuya/text_sensor/* @dentra
|
esphome/components/tuya/text_sensor/* @dentra
|
||||||
|
esphome/components/tuya/water_heater/* @iago-veiga
|
||||||
esphome/components/uart/* @esphome/core
|
esphome/components/uart/* @esphome/core
|
||||||
esphome/components/uart/button/* @ssieb
|
esphome/components/uart/button/* @ssieb
|
||||||
esphome/components/uart/event/* @eoasmxd
|
esphome/components/uart/event/* @eoasmxd
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
|||||||
# could be handy for archiving the generated documentation or if some version
|
# could be handy for archiving the generated documentation or if some version
|
||||||
# control system is used.
|
# control system is used.
|
||||||
|
|
||||||
PROJECT_NUMBER = 2026.8.2
|
PROJECT_NUMBER = 2026.9.0-dev
|
||||||
|
|
||||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||||
# for a project that appears at the top of each page and should give viewer a
|
# for a project that appears at the top of each page and should give viewer a
|
||||||
|
|||||||
+35
-3
@@ -23,7 +23,8 @@ For this repository there are two trusted inputs by design:
|
|||||||
1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
|
1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
|
||||||
(see below).
|
(see below).
|
||||||
2. **Authenticated peers of a running device** — clients holding the device's
|
2. **Authenticated peers of a running device** — clients holding the device's
|
||||||
API encryption key / password, OTA password, or web server credentials.
|
API/OTA encryption key, API password, OTA password, or web server
|
||||||
|
credentials.
|
||||||
|
|
||||||
The security boundary is therefore **unauthenticated network traffic vs. those
|
The security boundary is therefore **unauthenticated network traffic vs. those
|
||||||
trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
|
trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
|
||||||
@@ -76,8 +77,8 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
|||||||
captive portal, etc.) **without** valid credentials.
|
captive portal, etc.) **without** valid credentials.
|
||||||
- Authentication or encryption bypass on the device — reaching API calls, OTA
|
- Authentication or encryption bypass on the device — reaching API calls, OTA
|
||||||
updates, or the web server without the configured key/password.
|
updates, or the web server without the configured key/password.
|
||||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
- Flaws that weaken the device's API or OTA encryption (Noise), OTA auth, or
|
||||||
below their documented guarantees.
|
web server auth below their documented guarantees.
|
||||||
|
|
||||||
## The web server is an open HTTP API by design
|
## The web server is an open HTTP API by design
|
||||||
|
|
||||||
@@ -121,6 +122,37 @@ and any memory-safety or protocol bug in the server reachable without credential
|
|||||||
This section documents the current design and scope; it is not a judgment that the
|
This section documents the current design and scope; it is not a judgment that the
|
||||||
design is optimal or that it will not change.
|
design is optimal or that it will not change.
|
||||||
|
|
||||||
|
## OTA update encryption
|
||||||
|
|
||||||
|
The `esphome` OTA platform optionally encrypts updates with the same Noise
|
||||||
|
`NNpsk0` pattern the native API uses; one key protects the device. With an
|
||||||
|
`encryption:` block configured the guarantees are: the firmware image is
|
||||||
|
confidential in transit, the uploader is authenticated by the pre-shared key,
|
||||||
|
and the plaintext negotiation preceding the handshake is bound into the
|
||||||
|
handshake prologue, so stripping or tampering with it fails the first MAC.
|
||||||
|
Both ends fail closed with no override: a device built with a key refuses
|
||||||
|
plaintext uploads, and the CLI refuses to send plaintext when a key is
|
||||||
|
configured.
|
||||||
|
|
||||||
|
Defeating any of that without the key is in scope: a keyed device accepting a
|
||||||
|
plaintext or downgraded upload, getting past the MAC, or recovering image
|
||||||
|
contents from captured traffic.
|
||||||
|
|
||||||
|
The following are **not** vulnerabilities, by design:
|
||||||
|
|
||||||
|
- Plaintext OTA on a device with no `encryption:` block. That is the
|
||||||
|
documented default, authenticated (if at all) by the OTA password.
|
||||||
|
- The enablement window: turning encryption on takes one last upload of the
|
||||||
|
encryption-enabled firmware over the existing plaintext channel, with the
|
||||||
|
pre-existing plaintext exposure.
|
||||||
|
- The web OTA `/update` endpoint alongside encryption. The `web_server`
|
||||||
|
component keeps it always reachable, and `captive_portal:` auto-loads it
|
||||||
|
for the fallback AP window; validation warns about both combinations, and
|
||||||
|
the operator keeps the recovery path.
|
||||||
|
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
|
||||||
|
a fresh handshake with fresh ephemerals, so retrying does not weaken
|
||||||
|
authentication.
|
||||||
|
|
||||||
## Explicitly out of scope
|
## Explicitly out of scope
|
||||||
|
|
||||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
CHANNEL_DEV = "dev"
|
CHANNEL_DEV = "dev"
|
||||||
@@ -64,7 +65,10 @@ def main():
|
|||||||
|
|
||||||
suffix = f"-{args.suffix}" if args.suffix else ""
|
suffix = f"-{args.suffix}" if args.suffix else ""
|
||||||
|
|
||||||
image_name = f"esphome/esphome{suffix}"
|
repository = (
|
||||||
|
(os.environ.get("GITHUB_REPOSITORY") or "esphome/esphome").strip().lower()
|
||||||
|
)
|
||||||
|
image_name = f"{repository}{suffix}"
|
||||||
|
|
||||||
print(f"channel={channel}")
|
print(f"channel={channel}")
|
||||||
|
|
||||||
|
|||||||
+77
-37
@@ -26,7 +26,9 @@ from esphome.const import (
|
|||||||
CONF_DEASSERT_RTS_DTR,
|
CONF_DEASSERT_RTS_DTR,
|
||||||
CONF_DISABLED,
|
CONF_DISABLED,
|
||||||
CONF_DISCOVER_IP,
|
CONF_DISCOVER_IP,
|
||||||
|
CONF_ENCRYPTION,
|
||||||
CONF_ESPHOME,
|
CONF_ESPHOME,
|
||||||
|
CONF_KEY,
|
||||||
CONF_LEVEL,
|
CONF_LEVEL,
|
||||||
CONF_LOG,
|
CONF_LOG,
|
||||||
CONF_LOG_TOPIC,
|
CONF_LOG_TOPIC,
|
||||||
@@ -857,7 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
toolchain.create_factory_bin()
|
toolchain.create_factory_bin()
|
||||||
toolchain.create_ota_bin()
|
toolchain.create_ota_bin()
|
||||||
toolchain.create_elf_copy()
|
toolchain.create_elf_copy()
|
||||||
toolchain.get_idedata()
|
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||||
|
|
||||||
|
try:
|
||||||
|
if toolchain.get_idedata() is None:
|
||||||
|
_LOGGER.warning("No idedata was generated for this build")
|
||||||
|
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||||
|
# The firmware already built; an idedata failure must not fail
|
||||||
|
# a successful build.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||||
|
"memory-analysis data will be unavailable for this build)",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -1323,6 +1338,19 @@ def _upload_via_native_api(
|
|||||||
|
|
||||||
remote_port = int(ota_conf[CONF_PORT])
|
remote_port = int(ota_conf[CONF_PORT])
|
||||||
password = ota_conf.get(CONF_PASSWORD)
|
password = ota_conf.get(CONF_PASSWORD)
|
||||||
|
# Fail closed: an encryption block whose key did not resolve must never
|
||||||
|
# fall back to a plaintext upload
|
||||||
|
noise_psk = None
|
||||||
|
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||||
|
noise_psk = encryption_conf.get(CONF_KEY)
|
||||||
|
if not noise_psk:
|
||||||
|
raise EsphomeError(
|
||||||
|
"OTA encryption is configured but no key was resolved; "
|
||||||
|
"set the key under 'ota: encryption:' or 'api: encryption:'"
|
||||||
|
)
|
||||||
|
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||||
|
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||||
|
noise_psk = str(noise_psk)
|
||||||
|
|
||||||
def check_partition_access(option_string: str) -> None:
|
def check_partition_access(option_string: str) -> None:
|
||||||
if not ota_conf.get("allow_partition_access"):
|
if not ota_conf.get("allow_partition_access"):
|
||||||
@@ -1353,7 +1381,9 @@ def _upload_via_native_api(
|
|||||||
if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
|
if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
|
||||||
_validate_bootloader_binary(binary)
|
_validate_bootloader_binary(binary)
|
||||||
|
|
||||||
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
|
return espota2.run_ota(
|
||||||
|
network_devices, remote_port, password, binary, ota_type, noise_psk
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _upload_via_web_server(
|
def _upload_via_web_server(
|
||||||
@@ -1362,6 +1392,16 @@ def _upload_via_web_server(
|
|||||||
from esphome import web_server_ota
|
from esphome import web_server_ota
|
||||||
from esphome.web_server_helpers import get_web_server_connection
|
from esphome.web_server_helpers import get_web_server_connection
|
||||||
|
|
||||||
|
if any(
|
||||||
|
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||||
|
and ota_item.get(CONF_ENCRYPTION) is not None
|
||||||
|
for ota_item in config.get(CONF_OTA, [])
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"This config has OTA encryption, but the web_server OTA path sends "
|
||||||
|
"the image over plaintext HTTP; use the esphome OTA platform to "
|
||||||
|
"keep it confidential"
|
||||||
|
)
|
||||||
remote_port, username, password = get_web_server_connection(config)
|
remote_port, username, password = get_web_server_connection(config)
|
||||||
return web_server_ota.run_ota(
|
return web_server_ota.run_ota(
|
||||||
network_devices, remote_port, username, password, binary
|
network_devices, remote_port, username, password, binary
|
||||||
@@ -1657,20 +1697,26 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
|||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
return exit_code
|
return exit_code
|
||||||
if CORE.is_host:
|
if CORE.is_host:
|
||||||
if CORE.using_toolchain_esp_idf:
|
_LOGGER.info(
|
||||||
from esphome.espidf import toolchain
|
"Successfully compiled program to path '%s'", _host_program_path(config)
|
||||||
|
)
|
||||||
program_path = str(toolchain.get_elf_path())
|
|
||||||
else:
|
|
||||||
from esphome.platformio.toolchain import get_idedata
|
|
||||||
|
|
||||||
program_path = str(get_idedata(config).firmware_elf_path)
|
|
||||||
_LOGGER.info("Successfully compiled program to path '%s'", program_path)
|
|
||||||
else:
|
else:
|
||||||
_LOGGER.info("Successfully compiled program.")
|
_LOGGER.info("Successfully compiled program.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _host_program_path(config: ConfigType) -> str:
|
||||||
|
"""Return the compiled host ELF path."""
|
||||||
|
if CORE.using_toolchain_esp_idf:
|
||||||
|
from esphome.espidf import toolchain
|
||||||
|
|
||||||
|
return str(toolchain.get_elf_path())
|
||||||
|
from esphome.platformio.toolchain import get_idedata
|
||||||
|
|
||||||
|
# Memoized by compile_program's own call; this is a dict lookup
|
||||||
|
return str(get_idedata(config).firmware_elf_path)
|
||||||
|
|
||||||
|
|
||||||
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||||
# Get devices, resolving special identifiers like OTA
|
# Get devices, resolving special identifiers like OTA
|
||||||
devices = choose_upload_log_host(
|
devices = choose_upload_log_host(
|
||||||
@@ -1715,14 +1761,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
|||||||
return exit_code
|
return exit_code
|
||||||
_LOGGER.info("Successfully compiled program.")
|
_LOGGER.info("Successfully compiled program.")
|
||||||
if CORE.is_host:
|
if CORE.is_host:
|
||||||
if CORE.using_toolchain_esp_idf:
|
program_path = _host_program_path(config)
|
||||||
from esphome.espidf import toolchain
|
|
||||||
|
|
||||||
program_path = str(toolchain.get_elf_path())
|
|
||||||
else:
|
|
||||||
from esphome.platformio.toolchain import get_idedata
|
|
||||||
|
|
||||||
program_path = str(get_idedata(config).firmware_elf_path)
|
|
||||||
_LOGGER.info("Running program from path '%s'", program_path)
|
_LOGGER.info("Running program from path '%s'", program_path)
|
||||||
return run_external_process(program_path)
|
return run_external_process(program_path)
|
||||||
|
|
||||||
@@ -2721,10 +2760,14 @@ def run_esphome(argv):
|
|||||||
# Skipped when -s overrides are passed, since the cache was written
|
# Skipped when -s overrides are passed, since the cache was written
|
||||||
# against the previous substitution set.
|
# against the previous substitution set.
|
||||||
config: ConfigType | None = None
|
config: ConfigType | None = None
|
||||||
cache_eligible = (
|
cache_write_eligible = (
|
||||||
args.command in ("upload", "logs") and not command_line_substitutions
|
args.command in ("upload", "logs") and not command_line_substitutions
|
||||||
)
|
)
|
||||||
if cache_eligible:
|
# An explicit --toolchain must re-run the per-platform validators, so
|
||||||
|
# gate only the cache read; the refresh below saves the result unless
|
||||||
|
# the sidecar records a different toolchain.
|
||||||
|
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||||
|
if cache_read_eligible:
|
||||||
from esphome.compiled_config import load_compiled_config
|
from esphome.compiled_config import load_compiled_config
|
||||||
|
|
||||||
config = load_compiled_config(conf_path)
|
config = load_compiled_config(conf_path)
|
||||||
@@ -2734,7 +2777,8 @@ def run_esphome(argv):
|
|||||||
conf_path.name,
|
conf_path.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if config is None:
|
cache_missed = config is None
|
||||||
|
if cache_missed:
|
||||||
from esphome.config import read_config
|
from esphome.config import read_config
|
||||||
|
|
||||||
config = read_config(
|
config = read_config(
|
||||||
@@ -2743,26 +2787,22 @@ def run_esphome(argv):
|
|||||||
# Snapshot only needed by `esphome config --no-defaults`.
|
# Snapshot only needed by `esphome config --no-defaults`.
|
||||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||||
)
|
)
|
||||||
# Refresh the cache so the next upload/logs hits the fast path
|
if config is None:
|
||||||
# instead of re-running read_config. Skip when the storage
|
return 2
|
||||||
# sidecar is absent (no compile has run): the cache would
|
|
||||||
# never be loaded back, so writing secrets to disk is wasted.
|
|
||||||
if cache_eligible and config is not None:
|
|
||||||
from esphome.compiled_config import save_compiled_config
|
|
||||||
from esphome.storage_json import ext_storage_path
|
|
||||||
|
|
||||||
if ext_storage_path(conf_path.name).exists():
|
|
||||||
save_compiled_config(config)
|
|
||||||
if config is None:
|
|
||||||
return 2
|
|
||||||
CORE.config = config
|
CORE.config = config
|
||||||
|
|
||||||
# Fallback for platforms whose validators didn't set the toolchain
|
# The cache fast path skips validation, and legacy sidecars lack the
|
||||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
# toolchain field. Must run before the cache refresh below.
|
||||||
# other platforms only support PlatformIO today.
|
|
||||||
if CORE.toolchain is None:
|
if CORE.toolchain is None:
|
||||||
CORE.toolchain = Toolchain.PLATFORMIO
|
CORE.toolchain = Toolchain.PLATFORMIO
|
||||||
|
|
||||||
|
# Refresh the cache so the next upload/logs hits the fast path
|
||||||
|
# instead of re-running read_config.
|
||||||
|
if cache_write_eligible and cache_missed:
|
||||||
|
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||||
|
|
||||||
|
save_compiled_config_and_sidecar(config)
|
||||||
|
|
||||||
if args.command not in POST_CONFIG_ACTIONS:
|
if args.command not in POST_CONFIG_ACTIONS:
|
||||||
safe_print(f"Unknown command {args.command}")
|
safe_print(f"Unknown command {args.command}")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"""Arduino-core backend for the shared PlatformIO library converter.
|
||||||
|
|
||||||
|
Bundled names build straight from the framework tree; everything else goes
|
||||||
|
through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each
|
||||||
|
library builds its own archive; all include dirs join one global path.
|
||||||
|
|
||||||
|
Deviations from PlatformIO: flat-layout libraries get the recursive default
|
||||||
|
source filter; ``dot_a_linkage`` is honored; bundled libraries never run a
|
||||||
|
manifest ``extraScript``; manifest ``-I`` flags join the global include path;
|
||||||
|
``precompiled``/``ldflags`` properties are refused by name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from esphome.core import CORE, EsphomeError, Library
|
||||||
|
from esphome.helpers import walk_files
|
||||||
|
from esphome.platformio.extra_script import apply_extra_script
|
||||||
|
from esphome.platformio.library import (
|
||||||
|
DEFAULT_BUILD_INCLUDE_DIR,
|
||||||
|
DEFAULT_BUILD_SRC_FILTER,
|
||||||
|
ESPHOME_DATA_KEY,
|
||||||
|
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||||
|
LIBRARY_HEADER_SUFFIXES,
|
||||||
|
SRC_FILE_EXTENSIONS,
|
||||||
|
ConvertedLibrary,
|
||||||
|
IncompatiblePlatform,
|
||||||
|
InvalidLibrary,
|
||||||
|
LibraryBackend,
|
||||||
|
_url_or_none,
|
||||||
|
check_library_data,
|
||||||
|
collect_filtered_files,
|
||||||
|
convert_libraries,
|
||||||
|
ensure_list,
|
||||||
|
is_lib_ignored,
|
||||||
|
lex_build_flags,
|
||||||
|
lib_ignore_set,
|
||||||
|
normalize_dependencies,
|
||||||
|
parse_library_json,
|
||||||
|
parse_library_properties,
|
||||||
|
warn_properties_depends,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArduinoLibrary:
|
||||||
|
"""One resolved library, ready for the ninja generator."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
sources: list[Path] = field(default_factory=list)
|
||||||
|
include_dirs: list[Path] = field(default_factory=list)
|
||||||
|
# Extra compile flags private to this library's own sources
|
||||||
|
flags: list[str] = field(default_factory=list)
|
||||||
|
# PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the
|
||||||
|
# objects go to the linker directly (symbols nothing references survive)
|
||||||
|
lib_archive: bool = True
|
||||||
|
# Link inputs the library contributes (-L dirs / -l libs, e.g. from
|
||||||
|
# precompiled vendor blobs) and -Wl, options for the firmware link
|
||||||
|
link_dirs: list[Path] = field(default_factory=list)
|
||||||
|
link_libs: list[str] = field(default_factory=list)
|
||||||
|
link_flags: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Source-like suffixes the case-sensitive suffix map rejects
|
||||||
|
_UNMAPPED_SOURCE_SUFFIXES = frozenset(
|
||||||
|
{s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filename-plain names: an allowlist excludes separators, drive colons,
|
||||||
|
# and dot-only names by shape
|
||||||
|
_SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_library_name(name: object) -> bool:
|
||||||
|
"""Whether a name may be joined under the framework's libraries dir."""
|
||||||
|
return isinstance(name, str) and _SAFE_LIBRARY_NAME_RE.fullmatch(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_build(name: str, data: object) -> dict:
|
||||||
|
"""The manifest's ``build`` section; malformed manifests fail by name."""
|
||||||
|
build = data.get("build", {}) if isinstance(data, dict) else None
|
||||||
|
if not isinstance(build, dict):
|
||||||
|
raise EsphomeError(f"Library {name} has a malformed manifest")
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str:
|
||||||
|
"""Resolve PIO's source dir: manifest srcDir, else src/Src, else the root."""
|
||||||
|
if "srcDir" not in build:
|
||||||
|
return next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".")
|
||||||
|
# A declared srcDir (falsy included) that does not resolve is a manifest error
|
||||||
|
src_dir = build["srcDir"]
|
||||||
|
if not (isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()):
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Library {name} declares srcDir {src_dir!r} which does not exist"
|
||||||
|
)
|
||||||
|
return src_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unsupported_link_fields(name: str, data: dict) -> None:
|
||||||
|
# PIO honors these; ignoring them would fail at link with no stated
|
||||||
|
# cause. Property values are strings, so "false" is not a declaration.
|
||||||
|
precompiled = data.get("precompiled")
|
||||||
|
if precompiled and str(precompiled).strip().lower() != "false":
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Library {name} declares precompiled, which this backend does not support"
|
||||||
|
)
|
||||||
|
if data.get("ldflags"):
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Library {name} declares ldflags, which this backend does not support"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool:
|
||||||
|
"""build.libArchive, else dot_a_linkage (an Arduino IDE property PIO
|
||||||
|
ignores; a deliberate extra), else archive."""
|
||||||
|
|
||||||
|
# Strict parse: bool("false") is True
|
||||||
|
def _parse(key: str, raw: object) -> bool:
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
value = str(raw).strip().lower()
|
||||||
|
if value in ("true", "false"):
|
||||||
|
return value == "true"
|
||||||
|
raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}")
|
||||||
|
|
||||||
|
if "libArchive" in build:
|
||||||
|
return _parse("libArchive", build["libArchive"])
|
||||||
|
if "dot_a_linkage" in data:
|
||||||
|
return _parse("dot_a_linkage", data["dot_a_linkage"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_build_flags(
|
||||||
|
name: str, read_path: Path, lib: ArduinoLibrary, flag_tokens: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Route the lexed build.flags into the library's flag lists.
|
||||||
|
|
||||||
|
Returns the ``-I`` arguments for the include-dir resolution.
|
||||||
|
"""
|
||||||
|
include_flags: list[str] = []
|
||||||
|
for tok in flag_tokens:
|
||||||
|
if tok.startswith("-I"):
|
||||||
|
include_flags.append(tok[2:])
|
||||||
|
elif tok.startswith("-L"):
|
||||||
|
link_dir = (read_path / tok[2:]).resolve()
|
||||||
|
if not link_dir.is_dir():
|
||||||
|
# Kept (the linker ignores missing -L dirs); the warning
|
||||||
|
# names the culprit before a bare "cannot find -lfoo"
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Library %s declares library dir %s which does not exist",
|
||||||
|
name,
|
||||||
|
tok[2:],
|
||||||
|
)
|
||||||
|
lib.link_dirs.append(link_dir)
|
||||||
|
elif tok.startswith("-l"):
|
||||||
|
lib.link_libs.append(tok[2:])
|
||||||
|
elif tok.startswith("-Wl,"):
|
||||||
|
lib.link_flags.append(tok)
|
||||||
|
else:
|
||||||
|
lib.flags.append(tok)
|
||||||
|
return include_flags
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_include_dirs(
|
||||||
|
name: str,
|
||||||
|
read_path: Path,
|
||||||
|
lib: ArduinoLibrary,
|
||||||
|
build: dict,
|
||||||
|
src_dir: str,
|
||||||
|
include_flags: list[str],
|
||||||
|
) -> None:
|
||||||
|
include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
|
||||||
|
if not isinstance(include_dir, str):
|
||||||
|
raise EsphomeError(f"Library {name} has a malformed includeDir")
|
||||||
|
for d, explicit in [
|
||||||
|
(include_dir, "includeDir" in build),
|
||||||
|
(src_dir, False), # _resolve_src_dir already validated it
|
||||||
|
*((flag, True) for flag in include_flags),
|
||||||
|
]:
|
||||||
|
if (path := (read_path / d)).is_dir():
|
||||||
|
lib.include_dirs.append(path.resolve())
|
||||||
|
elif explicit:
|
||||||
|
# Warn-and-drop (unlike srcDir): a missing include dir is
|
||||||
|
# harmless until a header is needed, and the compile names it
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Library %s declares include dir %s which does not exist", name, d
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_lib_sources(
|
||||||
|
name: str,
|
||||||
|
read_path: Path,
|
||||||
|
lib: ArduinoLibrary,
|
||||||
|
src_dir: str,
|
||||||
|
src_filter: list[str],
|
||||||
|
) -> None:
|
||||||
|
sources: list[Path] = []
|
||||||
|
dropped: list[str] = []
|
||||||
|
saw_header = False
|
||||||
|
for f in collect_filtered_files(read_path / src_dir, src_filter):
|
||||||
|
path = Path(f)
|
||||||
|
suffix = path.suffix
|
||||||
|
if suffix in SRC_FILE_EXTENSIONS:
|
||||||
|
# resolve() per file: srcFilter patterns may escape src_dir
|
||||||
|
sources.append(path.resolve())
|
||||||
|
elif suffix.lower() in _UNMAPPED_SOURCE_SUFFIXES:
|
||||||
|
# A source-like suffix the case-sensitive map rejects (.CPP,
|
||||||
|
# .ino) is a dropped compilation unit; headers fall through
|
||||||
|
dropped.append(path.name)
|
||||||
|
elif suffix.lower() in LIBRARY_HEADER_SUFFIXES:
|
||||||
|
saw_header = True
|
||||||
|
lib.sources = sorted(sources)
|
||||||
|
if dropped:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Library %s: %d file(s) with unmapped source suffixes are not compiled: %s",
|
||||||
|
name,
|
||||||
|
len(dropped),
|
||||||
|
", ".join(sorted(dropped)),
|
||||||
|
)
|
||||||
|
if not lib.sources and not saw_header:
|
||||||
|
# Matched headers mean header-only; a filter matching nothing is
|
||||||
|
# a manifest/tree problem (a truly empty tree raises elsewhere)
|
||||||
|
_LOGGER.warning("Library %s: no source files matched", name)
|
||||||
|
|
||||||
|
|
||||||
|
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
|
||||||
|
"""Resolve one library's sources, include dirs, and flags (PIO semantics)."""
|
||||||
|
build = _manifest_build(name, data)
|
||||||
|
_reject_unsupported_link_fields(name, data)
|
||||||
|
src_dir = _resolve_src_dir(name, read_path, build)
|
||||||
|
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
|
||||||
|
if not all(isinstance(entry, str) for entry in src_filter):
|
||||||
|
raise EsphomeError(f"Library {name} has a malformed srcFilter")
|
||||||
|
lib = ArduinoLibrary(name=name, lib_archive=_resolve_lib_archive(name, data, build))
|
||||||
|
# PlatformIO shell-lexes each build.flags entry
|
||||||
|
include_flags = _classify_build_flags(
|
||||||
|
name, read_path, lib, lex_build_flags(build.get("flags", []), f"library {name}")
|
||||||
|
)
|
||||||
|
_resolve_include_dirs(name, read_path, lib, build, src_dir, include_flags)
|
||||||
|
_collect_lib_sources(name, read_path, lib, src_dir, src_filter)
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||||
|
"""A library bundled with the Arduino core, read from the framework tree.
|
||||||
|
|
||||||
|
``library.json`` wins over ``library.properties`` when both exist, as in
|
||||||
|
PlatformIO's LibBuilderFactory; only the JSON manifest can carry a
|
||||||
|
``build`` section (srcDir, srcFilter, flags).
|
||||||
|
"""
|
||||||
|
lib_dir = framework_path / "libraries" / name
|
||||||
|
manifest_json = lib_dir / "library.json"
|
||||||
|
if manifest_json.is_file():
|
||||||
|
try:
|
||||||
|
data = parse_library_json(manifest_json)
|
||||||
|
except ValueError as err: # JSONDecodeError
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Bundled library {name} has a corrupt library.json ({err}); "
|
||||||
|
"the framework install may be incomplete (run 'esphome clean-all')"
|
||||||
|
) from err
|
||||||
|
elif (manifest := lib_dir / "library.properties").is_file():
|
||||||
|
data = parse_library_properties(manifest)
|
||||||
|
else:
|
||||||
|
# Debug, not warning: the legacy manifest-less layout is legal and
|
||||||
|
# the 3.1.2 core ships one such library (FSTools), so a warning
|
||||||
|
# would be unactionable noise on every build using it
|
||||||
|
_LOGGER.debug("Bundled library %s has no manifest; using defaults", name)
|
||||||
|
data = {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Bundled manifest deps are never walked; make the skip visible
|
||||||
|
if data.get("dependencies"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Bundled library %s declares dependencies, which are not "
|
||||||
|
"resolved automatically; add them with add_library() if needed",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
warn_properties_depends(name, data)
|
||||||
|
build = data.get("build")
|
||||||
|
if isinstance(build, dict) and build.get("extraScript"):
|
||||||
|
# Scripts only run on the converted path; building without
|
||||||
|
# the script's flags would miscompile
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Bundled library {name} declares an extraScript, which is "
|
||||||
|
"not run for bundled libraries"
|
||||||
|
)
|
||||||
|
lib = _library_info(name, lib_dir, data)
|
||||||
|
_assert_tree_has_code(
|
||||||
|
name,
|
||||||
|
lib_dir,
|
||||||
|
"the framework install may be incomplete (run 'esphome clean-all')",
|
||||||
|
)
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_tree_has_code(name: str, root: Path, hint: str) -> None:
|
||||||
|
"""An empty or half-extracted tree can never link; fail by name (a
|
||||||
|
warning would scroll away and resurface as undefined symbols)."""
|
||||||
|
if not any(
|
||||||
|
Path(p).suffix in SRC_FILE_EXTENSIONS
|
||||||
|
or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES
|
||||||
|
for p in walk_files(root)
|
||||||
|
):
|
||||||
|
raise EsphomeError(f"Library {name} has no sources or headers; {hint}")
|
||||||
|
|
||||||
|
|
||||||
|
def _external_short_name(name: str) -> str:
|
||||||
|
"""The short library name of a requested spec.
|
||||||
|
|
||||||
|
"owner/Name" and plain names take the last path segment; "Name=<url>"
|
||||||
|
takes the declared name. Git tails (".git", "#ref") are stripped like
|
||||||
|
the walk's URL normalization; the comparand is a manifest dependency
|
||||||
|
name, never a spec.
|
||||||
|
"""
|
||||||
|
head, sep, tail = name.partition("=")
|
||||||
|
if sep and "://" in tail:
|
||||||
|
return head
|
||||||
|
short = name.rsplit("/", maxsplit=1)[-1]
|
||||||
|
return short.partition("#")[0].removesuffix(".git")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_unfulfilled_provides(
|
||||||
|
provided_requests: set[str], satisfied: set[str], still_requested: set[str]
|
||||||
|
) -> None:
|
||||||
|
"""Fail by name when a walk-skipped dependency was never added.
|
||||||
|
|
||||||
|
An unfulfilled provides() promise only surfaces as undefined symbols
|
||||||
|
at link. The walk records across re-resolutions, so a name no final
|
||||||
|
manifest still requests is stale state, never a failure.
|
||||||
|
"""
|
||||||
|
if missing := sorted((provided_requests & still_requested) - satisfied):
|
||||||
|
raise EsphomeError(
|
||||||
|
"provides() skipped these dependencies but nothing added them: "
|
||||||
|
f"{', '.join(missing)}; the build is missing libraries"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_libraries(
|
||||||
|
framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str
|
||||||
|
) -> list[ArduinoLibrary]:
|
||||||
|
"""Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`.
|
||||||
|
|
||||||
|
``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would
|
||||||
|
for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the
|
||||||
|
shared converter's download cache.
|
||||||
|
|
||||||
|
The returned list is not topologically sorted, so the caller must link
|
||||||
|
the archives inside one ``--start-group``/``--end-group`` pair (the
|
||||||
|
bundled-first grouping is incidental).
|
||||||
|
"""
|
||||||
|
bundled: list[ArduinoLibrary] = []
|
||||||
|
external: list[Library] = []
|
||||||
|
# PlatformIO's lib_ignore covers framework-bundled libraries too; the
|
||||||
|
# shared converter only filters the registry/git ones.
|
||||||
|
lib_ignore = lib_ignore_set()
|
||||||
|
# Exact directory names keep membership case-sensitive everywhere
|
||||||
|
# (an is_dir() probe would match "wire" on macOS/Windows and build
|
||||||
|
# the bundled Wire twice)
|
||||||
|
libraries_dir = framework_path / "libraries"
|
||||||
|
if not libraries_dir.is_dir():
|
||||||
|
# A registry fallback would fail later with a misleading
|
||||||
|
# package-not-found error per bundled name
|
||||||
|
raise EsphomeError(
|
||||||
|
f"{libraries_dir} is missing; the framework install may be "
|
||||||
|
"incomplete (run 'esphome clean-all')"
|
||||||
|
)
|
||||||
|
bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
def _provided(name: object) -> bool:
|
||||||
|
return _is_safe_library_name(name) and name in bundled_dir_names
|
||||||
|
|
||||||
|
for library in CORE.platformio_libraries.values():
|
||||||
|
if is_lib_ignored(library.name, lib_ignore):
|
||||||
|
continue
|
||||||
|
# Bundled only for a bare name with a matching framework dir; pinned
|
||||||
|
# or unmatched names resolve from the registry, as under PlatformIO.
|
||||||
|
if not library.repository and not library.version and _provided(library.name):
|
||||||
|
# Bundled manifest deps are not walked; _bundled_library warns
|
||||||
|
bundled.append(_bundled_library(framework_path, library.name))
|
||||||
|
else:
|
||||||
|
external.append(library)
|
||||||
|
|
||||||
|
converted: list[ArduinoLibrary] = []
|
||||||
|
bundled_names = {lib.name for lib in bundled}
|
||||||
|
converted_manifest_names: set[str] = set()
|
||||||
|
# Bundled candidates skipped on purpose (platform filter); the
|
||||||
|
# provides() reconciliation must count them as satisfied
|
||||||
|
knowingly_skipped: set[str] = set()
|
||||||
|
# Dependency names of the manifests actually emitted; a walk recording
|
||||||
|
# for a since-re-resolved manifest must not fail the reconciliation
|
||||||
|
final_dep_names: set[str] = set()
|
||||||
|
# Ordered set of bundled dependency names to add once conversion is done
|
||||||
|
pending_bundled: dict[str, None] = {}
|
||||||
|
# Deps matching a separately-requested external are already in the build
|
||||||
|
# (a duplicate archive means duplicate-symbol link errors)
|
||||||
|
external_short_names = {
|
||||||
|
_external_short_name(lib.name) for lib in external if lib.name
|
||||||
|
}
|
||||||
|
|
||||||
|
def _add_bundled_dependencies(component: ConvertedLibrary) -> None:
|
||||||
|
# A version-less bare name ("Hash") is a core-bundled library the
|
||||||
|
# shared converter cannot resolve from the registry
|
||||||
|
for dep in normalize_dependencies(
|
||||||
|
component.data.get("dependencies"), component.name
|
||||||
|
):
|
||||||
|
# normalize_dependencies guarantees a non-empty str name
|
||||||
|
name = dep["name"]
|
||||||
|
final_dep_names.add(name)
|
||||||
|
if "/" in name:
|
||||||
|
owner, _, pkg = name.partition("/")
|
||||||
|
if _is_safe_library_name(owner) and _is_safe_library_name(pkg):
|
||||||
|
# Owner-qualified; the converter resolves it from the registry
|
||||||
|
continue
|
||||||
|
if not _is_safe_library_name(name):
|
||||||
|
# The name becomes a path component; never join a traversal
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Ignoring malformed dependency entry %r of library %s",
|
||||||
|
dep,
|
||||||
|
component.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if name in external_short_names:
|
||||||
|
if _provided(name):
|
||||||
|
# A bundled copy is suppressed; a coincidental name
|
||||||
|
# collision would surface as link errors
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Dependency %s of %s is assumed satisfied by a "
|
||||||
|
"requested external library; the bundled copy is "
|
||||||
|
"not added",
|
||||||
|
name,
|
||||||
|
component.name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Dependency %s of %s assumed satisfied by a requested "
|
||||||
|
"external library",
|
||||||
|
name,
|
||||||
|
component.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if name in bundled_names or is_lib_ignored(name, lib_ignore):
|
||||||
|
continue
|
||||||
|
if _url_or_none(dep.get("version")) is not None:
|
||||||
|
# A URL names one specific source; never add the bundled copy
|
||||||
|
continue
|
||||||
|
if dep.get("owner") or not _provided(name):
|
||||||
|
# Only owner-less framework-tree names take the bundled
|
||||||
|
# copy (PIO's process_dependencies); the walk reports drops
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# framework=None: the walk already warned for non-platform
|
||||||
|
# causes; debug keeps one fault from warning twice (pinned
|
||||||
|
# by test_nonplatform_rejection_warns_once_through_real_converter)
|
||||||
|
check_library_data(dep, pio_platform, None)
|
||||||
|
except IncompatiblePlatform as err:
|
||||||
|
# A knowing skip (platform filter), not a broken promise
|
||||||
|
knowingly_skipped.add(name)
|
||||||
|
_LOGGER.debug("Skip bundled candidate %s: %s", name, err)
|
||||||
|
continue
|
||||||
|
except InvalidLibrary as err:
|
||||||
|
# Malformed manifest data never counts as satisfied; the
|
||||||
|
# walk owns the warning (see the warns-once test above)
|
||||||
|
_LOGGER.debug("Skip malformed bundled candidate %s: %s", name, err)
|
||||||
|
continue
|
||||||
|
# Deferred: a later manifest name may satisfy this
|
||||||
|
pending_bundled.setdefault(name)
|
||||||
|
|
||||||
|
def _emit(component: ConvertedLibrary) -> None:
|
||||||
|
apply_extra_script(
|
||||||
|
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
|
||||||
|
)
|
||||||
|
_assert_tree_has_code(
|
||||||
|
component.get_require_name(),
|
||||||
|
component.source_dir,
|
||||||
|
"the download may be incomplete (run 'esphome clean-all')",
|
||||||
|
)
|
||||||
|
if isinstance(manifest_name := component.data.get("name"), str):
|
||||||
|
converted_manifest_names.add(manifest_name)
|
||||||
|
lib = _library_info(
|
||||||
|
component.get_require_name(), component.source_dir, component.data
|
||||||
|
)
|
||||||
|
# Extra-script LINKFLAGS travel outside build.flags; dropping
|
||||||
|
# them would link wrong with no stated cause
|
||||||
|
lib.link_flags.extend(
|
||||||
|
component.data.get(ESPHOME_DATA_KEY, {}).get(
|
||||||
|
ESPHOME_DATA_LINK_FLAGS_KEY, []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
converted.append(lib)
|
||||||
|
_add_bundled_dependencies(component)
|
||||||
|
|
||||||
|
backend = LibraryBackend(
|
||||||
|
platform=pio_platform,
|
||||||
|
framework="arduino",
|
||||||
|
emit=_emit,
|
||||||
|
cache_key=cache_key,
|
||||||
|
# The walk must not resolve bundled names from the registry;
|
||||||
|
# _add_bundled_dependencies adds them after emit
|
||||||
|
provides=_provided,
|
||||||
|
)
|
||||||
|
if external:
|
||||||
|
convert_libraries(external, backend)
|
||||||
|
for name in pending_bundled:
|
||||||
|
if name in converted_manifest_names:
|
||||||
|
# The converted library is this one; the bundled copy would
|
||||||
|
# double the archive. Warn like the external_short_names twin.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Dependency %s is assumed satisfied by a converted library's "
|
||||||
|
"manifest name; the bundled copy is not added",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bundled_names.add(name)
|
||||||
|
bundled.append(_bundled_library(framework_path, name))
|
||||||
|
|
||||||
|
_check_unfulfilled_provides(
|
||||||
|
backend.provided_requests,
|
||||||
|
bundled_names
|
||||||
|
| converted_manifest_names
|
||||||
|
| external_short_names
|
||||||
|
| knowingly_skipped,
|
||||||
|
final_dep_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
return bundled + converted
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||||
|
|
||||||
|
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||||
|
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||||
|
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||||
|
|
||||||
|
Deliberately importable without the esp8266 component to avoid circular
|
||||||
|
imports; the component wires these modules in via lazy imports.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||||
|
|
||||||
|
Artifacts land in a machine-global cache (shared across projects, like the
|
||||||
|
ESP-IDF install in ``esphome.espidf.framework``):
|
||||||
|
|
||||||
|
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||||
|
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||||
|
|
||||||
|
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||||
|
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||||
|
from PATH or the ninja PyPI wheel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||||
|
from esphome.build_helpers.ninja import find_ninja
|
||||||
|
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||||
|
from esphome.core import EsphomeError, Version
|
||||||
|
from esphome.framework_helpers import str_to_lst_of_str
|
||||||
|
from esphome.platformio.registry import install_package, prefetch_packages
|
||||||
|
|
||||||
|
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||||
|
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||||
|
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||||
|
# generator's compile flags are tuned to it.
|
||||||
|
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||||
|
|
||||||
|
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||||
|
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||||
|
)
|
||||||
|
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||||
|
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_arduino8266_tools_path() -> Path:
|
||||||
|
# Machine-global so all projects share one install; see
|
||||||
|
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||||
|
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||||
|
|
||||||
|
|
||||||
|
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||||
|
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||||
|
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def framework_package_version(ver: Version) -> str:
|
||||||
|
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||||
|
3.30102.0; the leading 3 is the package major).
|
||||||
|
|
||||||
|
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||||
|
at MIN_FRAMEWORK_VERSION.
|
||||||
|
"""
|
||||||
|
if ver.major > 3:
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Arduino core {ver} is not supported yet; "
|
||||||
|
"the newest known core series is 3.x"
|
||||||
|
)
|
||||||
|
if ver <= Version(2, 6, 2):
|
||||||
|
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||||
|
# boundary as _format_framework_arduino_version's era guard)
|
||||||
|
raise EsphomeError(
|
||||||
|
f"Arduino core {ver} uses an older package encoding than this "
|
||||||
|
"helper implements (newer than 2.6.2)"
|
||||||
|
)
|
||||||
|
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||||
|
|
||||||
|
|
||||||
|
def get_framework_path(package_version: str) -> Path:
|
||||||
|
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||||
|
|
||||||
|
|
||||||
|
def get_toolchain_path() -> Path:
|
||||||
|
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledPaths(NamedTuple):
|
||||||
|
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||||
|
|
||||||
|
framework: Path
|
||||||
|
toolchain: Path
|
||||||
|
ninja: Path
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||||
|
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||||
|
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||||
|
# Config validation enforces this too; keep the module honest when
|
||||||
|
# called directly.
|
||||||
|
raise EsphomeError(
|
||||||
|
f"The native toolchain requires the Arduino core "
|
||||||
|
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||||
|
)
|
||||||
|
# Probe the cheap local dependency before ~110 MB of downloads
|
||||||
|
ninja_path = find_ninja()
|
||||||
|
package_version = framework_package_version(framework_version)
|
||||||
|
framework_path = get_framework_path(package_version)
|
||||||
|
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||||
|
toolchain_path = get_toolchain_path()
|
||||||
|
# One spec per package: the prefetch and the installs must agree
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
FRAMEWORK_PACKAGE,
|
||||||
|
package_version,
|
||||||
|
framework_path,
|
||||||
|
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||||
|
("cores/esp8266", "tools/sdk", "libraries"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TOOLCHAIN_PACKAGE,
|
||||||
|
TOOLCHAIN_VERSION,
|
||||||
|
toolchain_path,
|
||||||
|
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||||
|
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||||
|
("bin", "xtensa-lx106-elf"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Fetch both archives at once; the installs below verify and extract
|
||||||
|
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||||
|
for name, version, dest, mirrors, expect in specs:
|
||||||
|
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||||
|
return InstalledPaths(
|
||||||
|
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||||
|
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||||
|
|
||||||
|
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||||
|
Windows suffix, so a toolchain package bump touches one spot.
|
||||||
|
"""
|
||||||
|
suffix = ".exe" if os.name == "nt" else ""
|
||||||
|
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Drop empty entries: a trailing separator from an absent PATH would
|
||||||
|
# make the shell search the current directory for tools
|
||||||
|
parts = [
|
||||||
|
str(toolchain_path / "bin"),
|
||||||
|
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||||
|
]
|
||||||
|
env["PATH"] = os.pathsep.join(parts)
|
||||||
|
env.update(ccache_env(ccache))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||||
|
"""Return ccache settings for the build subprocess (not os.environ).
|
||||||
|
|
||||||
|
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||||
|
when disabled. Values the user already set in the environment are
|
||||||
|
respected.
|
||||||
|
"""
|
||||||
|
if ccache is None:
|
||||||
|
return {}
|
||||||
|
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tiny cross-platform build steps invoked from the generated ninja file.
|
||||||
|
|
||||||
|
Plain script (not ``python -m``): it runs from ninja with whatever Python
|
||||||
|
started esphome and must not depend on the package being importable.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||||
|
copy <src> <dst> copy a file
|
||||||
|
|
||||||
|
The ar rspfile carries one object path per line (the generating rule must
|
||||||
|
use ``$in_newline``, never ``$in``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _read_rspfile(rspfile: str) -> list[str]:
|
||||||
|
r"""The object paths listed in ``rspfile``, unquoted.
|
||||||
|
|
||||||
|
GNU ar treats backslashes in response files as escapes (corrupts
|
||||||
|
Windows paths), so the caller expands the list into argv; strip the
|
||||||
|
simple surrounding quote ninja adds to special paths, then undo
|
||||||
|
ninja's POSIX escape for an embedded quote ('a'\\''b.o' -> a'b.o).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
line[1:-1].replace("'\\''", "'")
|
||||||
|
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\""
|
||||||
|
else line
|
||||||
|
for line in Path(rspfile).read_text(encoding="utf-8").splitlines()
|
||||||
|
if line
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ar(ar: str, archive: str, rspfile: str) -> int:
|
||||||
|
# Remove first: ``ar rcs`` replaces members but never drops ones whose
|
||||||
|
# source was removed from the build, which would leak stale objects.
|
||||||
|
Path(archive).unlink(missing_ok=True)
|
||||||
|
objects = _read_rspfile(rspfile)
|
||||||
|
if not objects:
|
||||||
|
# An empty archive would "succeed" here and fail far away at link
|
||||||
|
print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# Batch by argv length: expanding the rspfile gives back the Windows
|
||||||
|
# 32767-char command-line limit it existed to avoid. "rcs" creates,
|
||||||
|
# "qs" appends; the s keeps the symbol index explicit on every ar.
|
||||||
|
op = "rcs"
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
while objects:
|
||||||
|
batch = [objects.pop(0)]
|
||||||
|
batch_len = len(batch[0])
|
||||||
|
while objects and batch_len + len(objects[0]) < 25000:
|
||||||
|
batch_len += len(objects[0]) + 1
|
||||||
|
batch.append(objects.pop(0))
|
||||||
|
rc = subprocess.run(
|
||||||
|
[ar, op, archive, *batch], check=False, close_fds=False
|
||||||
|
).returncode
|
||||||
|
if rc != 0:
|
||||||
|
return rc
|
||||||
|
op = "qs"
|
||||||
|
ok = True
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
if not ok:
|
||||||
|
# Any failure (bad exit, missing ar binary, interrupt) must not
|
||||||
|
# leave a truncated archive behind
|
||||||
|
Path(archive).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_copy(src: str, dst: str) -> int:
|
||||||
|
try:
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
except OSError as err:
|
||||||
|
# Never leave a partially written output (e.g. a firmware image);
|
||||||
|
# SameFileError means dst IS src, where unlinking destroys the input
|
||||||
|
if not isinstance(err, shutil.SameFileError):
|
||||||
|
Path(dst).unlink(missing_ok=True)
|
||||||
|
print(f"copy: {src} -> {dst} failed: {err}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# mode -> (handler, expected operand count); surplus argv means a
|
||||||
|
# mis-specified ninja rule and must error, not silently drop operands
|
||||||
|
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
if entry := _MODES.get(mode):
|
||||||
|
handler, argc = entry
|
||||||
|
args = sys.argv[2:]
|
||||||
|
if len(args) != argc:
|
||||||
|
print(
|
||||||
|
f"build_tool {mode}: expected {argc} arguments, got {len(args)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return handler(*args)
|
||||||
|
print(f"unknown build_tool mode: {mode}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
+80
-34
@@ -1,11 +1,18 @@
|
|||||||
"""ESP-IDF direct build generator for ESPHome."""
|
"""ESP-IDF direct build generator for ESPHome."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
from esphome.components.esp32 import (
|
||||||
|
get_esp32_variant,
|
||||||
|
get_excluded_builtin_components,
|
||||||
|
get_managed_component_require_names,
|
||||||
|
idf_version,
|
||||||
|
)
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
from esphome.espidf import variant_to_idf_target
|
||||||
from esphome.framework_helpers import (
|
from esphome.framework_helpers import (
|
||||||
get_project_compile_flags,
|
get_project_compile_flags,
|
||||||
get_project_cxx_compile_flags,
|
get_project_cxx_compile_flags,
|
||||||
@@ -13,6 +20,8 @@ from esphome.framework_helpers import (
|
|||||||
)
|
)
|
||||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
||||||
# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
|
# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
|
||||||
# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
|
# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
|
||||||
@@ -26,11 +35,12 @@ idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""
|
|||||||
|
|
||||||
|
|
||||||
def get_available_components() -> list[str] | None:
|
def get_available_components() -> list[str] | None:
|
||||||
"""Get list of built-in ESP-IDF components from project_description.json.
|
"""List the built-in ESP-IDF components from ``project_description.json``.
|
||||||
|
|
||||||
Excludes ``src``, IDF-managed components (``managed_components/``), and
|
Only components below its ``idf_path/components`` count, which leaves out
|
||||||
converted PIO libs (``pio_components/``). Returns ``None`` if the build
|
``src``, IDF-managed components, converted PIO libs and project local
|
||||||
dir or ``project_description.json`` isn't ready yet.
|
ones such as the Arduino ``component_stubs``. Returns ``None`` if the
|
||||||
|
build dir or ``project_description.json`` isn't ready yet.
|
||||||
"""
|
"""
|
||||||
if CORE.build_path is None:
|
if CORE.build_path is None:
|
||||||
return None
|
return None
|
||||||
@@ -41,41 +51,44 @@ def get_available_components() -> list[str] | None:
|
|||||||
try:
|
try:
|
||||||
with project_desc.open(encoding="utf-8") as f:
|
with project_desc.open(encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
root = (Path(data["idf_path"]) / "components").resolve()
|
||||||
component_info = data.get("build_component_info", {})
|
result = [
|
||||||
|
name
|
||||||
result = []
|
for name, info in data.get("build_component_info", {}).items()
|
||||||
for name, info in component_info.items():
|
if (comp_dir := info.get("dir"))
|
||||||
# Exclude our own src component
|
and Path(comp_dir).resolve().is_relative_to(root)
|
||||||
if name == "src":
|
]
|
||||||
continue
|
except (json.JSONDecodeError, KeyError, OSError) as err:
|
||||||
|
_LOGGER.debug("Could not read %s: %s", project_desc, err)
|
||||||
# Exclude IDF-managed and converted-PIO components (external).
|
|
||||||
comp_dir = info.get("dir", "")
|
|
||||||
if "managed_components" in comp_dir or "pio_components" in comp_dir:
|
|
||||||
continue
|
|
||||||
|
|
||||||
result.append(name)
|
|
||||||
|
|
||||||
return result
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return None
|
return None
|
||||||
|
if not result:
|
||||||
|
_LOGGER.warning("No ESP-IDF components found under %s", root)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def has_discovered_components() -> bool:
|
def has_discovered_components() -> bool:
|
||||||
"""Check if we have discovered components from a previous configure."""
|
"""Check if a previous configure discovered any built-in components."""
|
||||||
return get_available_components() is not None
|
return bool(get_available_components())
|
||||||
|
|
||||||
|
|
||||||
def get_project_cmakelists(minimal: bool = False) -> str:
|
def _cmake_quote(value: str) -> str:
|
||||||
|
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||||
|
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||||
|
escaped = value.replace("\\", "\\\\")
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_cmakelists(
|
||||||
|
minimal: bool = False, builtin_components: list[str] | None = None
|
||||||
|
) -> str:
|
||||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
||||||
|
|
||||||
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
||||||
since ``project_description.json`` may be stale on the first write.
|
since ``project_description.json`` may be stale on the first write.
|
||||||
|
``builtin_components`` supplies the discovered list (from the cache)
|
||||||
|
instead of reading it from ``project_description.json``.
|
||||||
"""
|
"""
|
||||||
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
|
idf_target = variant_to_idf_target(get_esp32_variant())
|
||||||
variant = get_esp32_variant()
|
|
||||||
idf_target = variant.lower().replace("-", "")
|
|
||||||
|
|
||||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||||
@@ -109,6 +122,15 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CMake variables registered via cg.add_cmake_arg(). Emitted before
|
||||||
|
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
|
||||||
|
# set when project.cmake seeds the component list, and on minimal
|
||||||
|
# (discovery) writes too so excluded components never register.
|
||||||
|
cmake_args = "\n".join(
|
||||||
|
f"set({name} {_cmake_quote(value)})"
|
||||||
|
for name, value in sorted(CORE.cmake_args.items())
|
||||||
|
)
|
||||||
|
|
||||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||||
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
||||||
# project-specific names into their cached CMakeLists.
|
# project-specific names into their cached CMakeLists.
|
||||||
@@ -119,8 +141,6 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
|||||||
# runs as a separate CMake script invocation that doesn't load the
|
# runs as a separate CMake script invocation that doesn't load the
|
||||||
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
||||||
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
||||||
from esphome.components.esp32 import get_managed_component_require_names
|
|
||||||
|
|
||||||
managed_components_property = "\n".join(
|
managed_components_property = "\n".join(
|
||||||
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
||||||
for name in get_managed_component_require_names()
|
for name in get_managed_component_require_names()
|
||||||
@@ -131,12 +151,24 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
|||||||
# component's REQUIRES including real IDF components). Referenced by
|
# component's REQUIRES including real IDF components). Referenced by
|
||||||
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
||||||
# on minimal writes because project_description.json may be stale.
|
# on minimal writes because project_description.json may be stale.
|
||||||
|
# Excluded components are dropped here as well: a stale
|
||||||
|
# project_description.json from a build without exclusions may still
|
||||||
|
# list them, and requiring an excluded component pulls it back into
|
||||||
|
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
|
||||||
|
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
|
||||||
|
# two can never disagree within one generated file.
|
||||||
builtin_components_property = (
|
builtin_components_property = (
|
||||||
""
|
""
|
||||||
if minimal
|
if minimal
|
||||||
else "\n".join(
|
else "\n".join(
|
||||||
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
||||||
for name in sorted(get_available_components() or [])
|
for name in sorted(
|
||||||
|
set(
|
||||||
|
builtin_components
|
||||||
|
if builtin_components is not None
|
||||||
|
else get_available_components() or []
|
||||||
|
).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";"))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,6 +195,8 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
|
|||||||
set(IDF_TARGET {idf_target})
|
set(IDF_TARGET {idf_target})
|
||||||
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||||
|
|
||||||
|
{cmake_args}
|
||||||
|
|
||||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
{cpp_standard_options}
|
{cpp_standard_options}
|
||||||
@@ -248,7 +282,9 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def write_project(minimal: bool = False) -> None:
|
def write_project(
|
||||||
|
minimal: bool = False, builtin_components: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
"""Write ESP-IDF project files."""
|
"""Write ESP-IDF project files."""
|
||||||
mkdir_p(CORE.build_path)
|
mkdir_p(CORE.build_path)
|
||||||
mkdir_p(CORE.relative_src_path())
|
mkdir_p(CORE.relative_src_path())
|
||||||
@@ -256,7 +292,7 @@ def write_project(minimal: bool = False) -> None:
|
|||||||
# Write top-level CMakeLists.txt
|
# Write top-level CMakeLists.txt
|
||||||
write_file_if_changed(
|
write_file_if_changed(
|
||||||
CORE.relative_build_path("CMakeLists.txt"),
|
CORE.relative_build_path("CMakeLists.txt"),
|
||||||
get_project_cmakelists(minimal=minimal),
|
get_project_cmakelists(minimal=minimal, builtin_components=builtin_components),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write component CMakeLists.txt in src/
|
# Write component CMakeLists.txt in src/
|
||||||
@@ -264,3 +300,13 @@ def write_project(minimal: bool = False) -> None:
|
|||||||
CORE.relative_src_path("CMakeLists.txt"),
|
CORE.relative_src_path("CMakeLists.txt"),
|
||||||
get_component_cmakelists(),
|
get_component_cmakelists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||||
|
# discovery reconfigure when it changes. Excluded components never
|
||||||
|
# register in project_description.json, so re-including one (e.g. a
|
||||||
|
# config gains mqtt) requires a fresh discovery pass before the
|
||||||
|
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
|
||||||
|
write_file_if_changed(
|
||||||
|
CORE.relative_build_path("exclude_components.esphomeinternal"),
|
||||||
|
";".join(get_excluded_builtin_components()),
|
||||||
|
)
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ def get_ini_content():
|
|||||||
# Add extra script for C++ flags
|
# Add extra script for C++ flags
|
||||||
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
|
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
|
||||||
|
|
||||||
|
# Add CMake args. A user-supplied value (str or list) is deliberately
|
||||||
|
# replaced; this option was always overwritten at FINAL priority.
|
||||||
|
if CORE.cmake_args:
|
||||||
|
CORE.add_platformio_option(
|
||||||
|
"board_build.cmake_extra_args",
|
||||||
|
" ".join(
|
||||||
|
f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
|
||||||
|
),
|
||||||
|
replace=True,
|
||||||
|
)
|
||||||
|
|
||||||
content = "[platformio]\n"
|
content = "[platformio]\n"
|
||||||
content += f"description = ESPHome {__version__}\n"
|
content += f"description = ESPHome {__version__}\n"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Build helpers shared by the native (non-PlatformIO) toolchains."""
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||||
|
resolution, and default ``CCACHE_*`` values."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||||
|
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ccache_runs(ccache: str) -> bool:
|
||||||
|
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||||
|
return tool_version_runs(
|
||||||
|
ccache,
|
||||||
|
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_enable_env(name: str) -> bool | None:
|
||||||
|
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||||
|
|
||||||
|
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||||
|
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||||
|
as unset so the caller's default policy applies.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
lowered = raw.strip().lower()
|
||||||
|
if not lowered:
|
||||||
|
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||||
|
return False
|
||||||
|
if lowered in TRUTHY_ENV_STRINGS:
|
||||||
|
return True
|
||||||
|
if lowered in FALSY_ENV_STRINGS:
|
||||||
|
return False
|
||||||
|
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ccache_path() -> str | None:
|
||||||
|
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||||
|
|
||||||
|
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||||
|
Windows extended-length prefix is stripped before probing (#18399).
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||||
|
if explicit is False:
|
||||||
|
return None
|
||||||
|
ccache = shutil.which("ccache")
|
||||||
|
if ccache is None:
|
||||||
|
if explicit:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||||
|
"compiling without ccache"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
ccache = strip_win_long_path_prefix(ccache)
|
||||||
|
if not explicit and not _ccache_runs(ccache):
|
||||||
|
return None
|
||||||
|
return ccache
|
||||||
|
|
||||||
|
|
||||||
|
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||||
|
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||||
|
|
||||||
|
Values the user already set in the environment are respected. Depend
|
||||||
|
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||||
|
keeps cache-miss overhead low.
|
||||||
|
"""
|
||||||
|
from esphome.core import CORE
|
||||||
|
|
||||||
|
# An unset build_path means the env was built before preload; fail loudly
|
||||||
|
# rather than silently drop CCACHE_BASEDIR.
|
||||||
|
if CORE.build_path is None:
|
||||||
|
raise ValueError(
|
||||||
|
"CORE.build_path must be set before constructing the build environment"
|
||||||
|
)
|
||||||
|
defaults = {
|
||||||
|
"CCACHE_DIR": str(cache_dir),
|
||||||
|
"CCACHE_NOHASHDIR": "true",
|
||||||
|
"CCACHE_DEPEND": "1",
|
||||||
|
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||||
|
}
|
||||||
|
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``.
|
"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
|
||||||
|
|
||||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF
|
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
|
||||||
toolchain has no such command, but its CMake build emits
|
toolchains have no such command, but each build produces a
|
||||||
``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module
|
``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
|
||||||
turns that file into the same fields consumers (IDE integration, clang-tidy)
|
compdb tool otherwise). This module turns that file into the same fields
|
||||||
expect:
|
consumers (IDE integration, clang-tidy) expect:
|
||||||
|
|
||||||
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||||
"""
|
"""
|
||||||
@@ -18,6 +18,19 @@ from pathlib import Path
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from esphome.core import EsphomeError
|
||||||
|
from esphome.helpers import write_file
|
||||||
|
|
||||||
|
# Everything idedata generation may raise after a successful link; idedata
|
||||||
|
# is a bonus artifact, so consumers warn instead of failing the build
|
||||||
|
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||||
|
EsphomeError,
|
||||||
|
LookupError,
|
||||||
|
OSError,
|
||||||
|
RuntimeError,
|
||||||
|
ValueError,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||||
@@ -30,12 +43,8 @@ _ESPHOME_SRC_MARKER = "/src/esphome/"
|
|||||||
|
|
||||||
|
|
||||||
def _is_esphome_src(file: str) -> bool:
|
def _is_esphome_src(file: str) -> bool:
|
||||||
"""Whether ``file`` is an ESPHome C++ translation unit.
|
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||||
|
``/`` first since Windows compile DBs use backslashes."""
|
||||||
``compile_commands.json`` ``file`` paths use the OS-native separator, so on
|
|
||||||
Windows they contain backslashes; normalize to ``/`` before testing the
|
|
||||||
marker, otherwise no source matches and the build-include union is empty.
|
|
||||||
"""
|
|
||||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||||
_CXX_SUFFIXES
|
_CXX_SUFFIXES
|
||||||
)
|
)
|
||||||
@@ -106,11 +115,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _pick_entry(entries: list[dict]) -> dict:
|
def _pick_entry(entries: list[dict]) -> dict:
|
||||||
"""Pick a representative ESPHome C++ translation unit.
|
"""Pick a representative ESPHome C++ TU; all share the same component
|
||||||
|
flags/defines."""
|
||||||
All ESPHome sources share the same component flags/defines, so any one of
|
|
||||||
them yields the cxx_path / cxx_flags / defines we need.
|
|
||||||
"""
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if _is_esphome_src(entry["file"]):
|
if _is_esphome_src(entry["file"]):
|
||||||
return entry
|
return entry
|
||||||
@@ -120,25 +126,46 @@ def _pick_entry(entries: list[dict]) -> dict:
|
|||||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||||
|
|
||||||
|
|
||||||
def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
|
# Compiler launchers that may prefix a compile command; a closed launcher
|
||||||
|
# denylist beats enumerating compiler names, an open set.
|
||||||
|
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_launcher(token: str) -> bool:
|
||||||
|
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||||
|
|
||||||
|
|
||||||
|
def parse_entry(
|
||||||
|
entry: dict, launcher: str | None = None
|
||||||
|
) -> tuple[str, list[str], list[str], list[str]]:
|
||||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||||
directory = Path(entry["directory"])
|
directory = Path(entry["directory"])
|
||||||
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||||
|
|
||||||
def _include(raw: str) -> str:
|
def _include(raw: str) -> str:
|
||||||
# Include paths in compile_commands are interpreted relative to the
|
# Resolve against the entry's ``directory`` so cached idedata works
|
||||||
# entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them
|
# from any cwd; emit forward slashes to match the JSON's own entries
|
||||||
# so the cached idedata is usable regardless of the consumer's cwd.
|
|
||||||
# Emit forward slashes (``normpath`` yields ``\`` on Windows) so the
|
|
||||||
# paths match the absolute, already-forward-slash entries in the JSON.
|
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw and not Path(raw).is_absolute():
|
if raw and not Path(raw).is_absolute():
|
||||||
raw = os.path.normpath(directory / raw)
|
raw = os.path.normpath(directory / raw)
|
||||||
return raw.replace("\\", "/")
|
return raw.replace("\\", "/")
|
||||||
|
|
||||||
|
# A launcher-wrapped command ("ccache g++ ...") names the compiler second
|
||||||
|
if launcher is not None and tokens[:1] == [launcher]:
|
||||||
|
tokens = tokens[1:]
|
||||||
|
if not tokens:
|
||||||
|
# An empty command, or one that was only the launcher; fail by name
|
||||||
|
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||||
|
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||||
|
# Stale DB built with a launcher this run no longer configures; the
|
||||||
|
# real compiler is the next token
|
||||||
|
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||||
|
tokens = tokens[1:]
|
||||||
# token0 is the compiler path; the rest of the command already uses forward
|
# token0 is the compiler path; the rest of the command already uses forward
|
||||||
# slashes on Windows, so normalize it too for a consistent idedata file.
|
# slashes on Windows, so normalize it too for a consistent idedata file.
|
||||||
cxx_path = tokens[0].replace("\\", "/")
|
cxx_path = tokens[0].replace("\\", "/")
|
||||||
|
# Enforced here so no caller can record ccache as the compiler
|
||||||
|
reject_launcher_compiler(cxx_path)
|
||||||
defines: list[str] = []
|
defines: list[str] = []
|
||||||
includes: list[str] = []
|
includes: list[str] = []
|
||||||
cxx_flags: list[str] = []
|
cxx_flags: list[str] = []
|
||||||
@@ -168,7 +195,7 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
|
|||||||
return cxx_path, defines, includes, cxx_flags
|
return cxx_path, defines, includes, cxx_flags
|
||||||
|
|
||||||
|
|
||||||
def _get_toolchain_includes(cxx_path: str) -> list[str]:
|
def get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||||
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
||||||
@@ -219,26 +246,128 @@ def _cc_path_from_cxx(cxx_path: str) -> str:
|
|||||||
return f"{stem}{suffix}"
|
return f"{stem}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def idedata_from_build(compile_commands: Path) -> dict:
|
def _cache_usable(cached: object) -> bool:
|
||||||
|
"""Check a cached idedata dict against the guarantees of the write path.
|
||||||
|
|
||||||
|
Caches written by older versions predate the launcher rejection and the
|
||||||
|
include-union shape; serving one would bypass both. The dict check also
|
||||||
|
keeps "in" from substring-matching a bare JSON string.
|
||||||
|
"""
|
||||||
|
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||||
|
return False
|
||||||
|
cxx_path = cached.get("cxx_path")
|
||||||
|
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||||
|
return False
|
||||||
|
includes = cached.get("includes")
|
||||||
|
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_build_idedata(
|
||||||
|
compile_commands: Path,
|
||||||
|
elf_path: Path,
|
||||||
|
cache: Path,
|
||||||
|
launcher: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||||
|
|
||||||
|
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||||
|
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||||
|
is the compiler-launcher path (ccache) the build was generated with, if
|
||||||
|
any; commands in the compile DB are prefixed with it.
|
||||||
|
"""
|
||||||
|
if not compile_commands.is_file():
|
||||||
|
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
|
||||||
|
try:
|
||||||
|
cached = json.loads(cache.read_text(encoding="utf-8"))
|
||||||
|
except (ValueError, OSError) as err:
|
||||||
|
# A recurring cause (interrupted write, disk full) would otherwise
|
||||||
|
# look like unexplained slow builds
|
||||||
|
_LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
|
||||||
|
else:
|
||||||
|
if _cache_usable(cached):
|
||||||
|
# Re-stamp so a relocated build dir cannot serve a stale ELF path
|
||||||
|
cached["prog_path"] = str(elf_path)
|
||||||
|
return cached
|
||||||
|
_LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
|
||||||
|
|
||||||
|
data = idedata_from_build(compile_commands, launcher)
|
||||||
|
data["prog_path"] = str(elf_path)
|
||||||
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Atomic so a crash mid-write cannot leave a truncated cache
|
||||||
|
write_file(cache, json.dumps(data, indent=2) + "\n")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||||
|
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||||
|
must never be probed, cached, or consumed."""
|
||||||
|
if _is_launcher(cxx_path):
|
||||||
|
raise EsphomeError(
|
||||||
|
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||||
|
"compiler; the compile database is unusable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
|
||||||
"""Parse compile_commands.json into the idedata fields consumers expect.
|
"""Parse compile_commands.json into the idedata fields consumers expect.
|
||||||
|
|
||||||
A single ESP-IDF compile entry only carries its own component's REQUIRES
|
A single compile entry only carries the include set its own translation
|
||||||
include set, but consumers (clang-tidy) analyze ESPHome headers that
|
unit was built with (per-component under ESP-IDF), but consumers
|
||||||
transitively pull in other components. So take cxx_path / cxx_flags /
|
(clang-tidy) analyze ESPHome headers that transitively pull in other
|
||||||
defines from a representative ESPHome TU, but union the include dirs across
|
components. So take cxx_path / cxx_flags / defines from a representative
|
||||||
all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata
|
ESPHome TU, but union the include dirs across all ESPHome TUs to get a
|
||||||
provides).
|
project-wide superset (as PlatformIO's idedata provides).
|
||||||
"""
|
"""
|
||||||
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
||||||
cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries))
|
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
|
||||||
|
# A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
|
||||||
|
raise EsphomeError(f"{compile_commands} is not a compile-command list")
|
||||||
|
|
||||||
build_includes: dict[str, None] = {}
|
representative = _pick_entry(entries)
|
||||||
|
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
|
||||||
|
|
||||||
|
# Seed with the representative's includes so it is not parsed twice
|
||||||
|
has_esphome_tu = _is_esphome_src(representative["file"])
|
||||||
|
build_includes: dict[str, None] = dict.fromkeys(
|
||||||
|
rep_includes if has_esphome_tu else ()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _shape(entry: dict) -> str:
|
||||||
|
# directory + command minus TU-specific paths: same shape means the
|
||||||
|
# same include set, so tokenize once per shape. Response-file
|
||||||
|
# commands never dedupe (the .rsp contents differ per object)
|
||||||
|
command = entry["command"]
|
||||||
|
directory = entry.get("directory", "")
|
||||||
|
if "@" in command:
|
||||||
|
return f"unique:{directory}|{entry.get('output') or command}"
|
||||||
|
stripped = command.replace(entry.get("file", ""), "").replace(
|
||||||
|
entry.get("output", ""), ""
|
||||||
|
)
|
||||||
|
return f"{directory}|{stripped}"
|
||||||
|
|
||||||
|
seen_shapes = {_shape(representative)}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not _is_esphome_src(entry["file"]):
|
if entry is representative or not _is_esphome_src(entry["file"]):
|
||||||
continue
|
continue
|
||||||
for inc in _parse_entry(entry)[2]:
|
has_esphome_tu = True
|
||||||
|
if (shape := _shape(entry)) in seen_shapes:
|
||||||
|
_LOGGER.debug("Include union: %s shares a command shape", entry["file"])
|
||||||
|
continue
|
||||||
|
seen_shapes.add(shape)
|
||||||
|
for inc in parse_entry(entry, launcher)[2]:
|
||||||
build_includes.setdefault(inc, None)
|
build_includes.setdefault(inc, None)
|
||||||
|
|
||||||
|
if not has_esphome_tu:
|
||||||
|
# An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
|
||||||
|
# warning would be cached into permanence; call sites downgrade this
|
||||||
|
raise EsphomeError(
|
||||||
|
f"No ESPHome translation unit found in {compile_commands}; "
|
||||||
|
"refusing to cache unusable idedata"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cc_path": _cc_path_from_cxx(cxx_path),
|
"cc_path": _cc_path_from_cxx(cxx_path),
|
||||||
"cxx_path": cxx_path,
|
"cxx_path": cxx_path,
|
||||||
@@ -246,6 +375,6 @@ def idedata_from_build(compile_commands: Path) -> dict:
|
|||||||
"defines": defines,
|
"defines": defines,
|
||||||
"includes": {
|
"includes": {
|
||||||
"build": list(build_includes),
|
"build": list(build_includes),
|
||||||
"toolchain": _get_toolchain_includes(cxx_path),
|
"toolchain": get_toolchain_includes(cxx_path),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from esphome.core import EsphomeError
|
||||||
|
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ninja_runs(binary: str) -> bool:
|
||||||
|
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||||
|
return tool_version_runs(
|
||||||
|
binary,
|
||||||
|
"Ignoring ninja at %s because it failed to run; "
|
||||||
|
"falling back to the bundled wheel",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_ninja() -> Path:
|
||||||
|
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||||
|
PyPI wheel."""
|
||||||
|
if binary := shutil.which("ninja"):
|
||||||
|
binary = strip_win_long_path_prefix(binary)
|
||||||
|
if _ninja_runs(binary):
|
||||||
|
return Path(binary)
|
||||||
|
import_error: ImportError | None = None
|
||||||
|
try:
|
||||||
|
import ninja
|
||||||
|
except ImportError as err:
|
||||||
|
import_error = err
|
||||||
|
wheel_binary = None
|
||||||
|
else:
|
||||||
|
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||||
|
"ninja.exe" if os.name == "nt" else "ninja"
|
||||||
|
)
|
||||||
|
if wheel_binary is None or not wheel_binary.is_file():
|
||||||
|
raise EsphomeError(
|
||||||
|
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||||
|
"esphome Python environment"
|
||||||
|
) from import_error
|
||||||
|
return wheel_binary
|
||||||
|
|
||||||
|
|
||||||
|
def escape(value: Path | str) -> str:
|
||||||
|
"""Escape a path or token for a ninja file."""
|
||||||
|
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||||
|
|
||||||
|
|
||||||
|
def quote_arg(tok: str) -> str:
|
||||||
|
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||||
|
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||||
|
already be doubled for ninja.
|
||||||
|
"""
|
||||||
|
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||||
|
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||||
|
return f'"{quoted}"'
|
||||||
|
|
||||||
|
|
||||||
|
# Force-quote any token containing a character outside the shlex.quote-style
|
||||||
|
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||||
|
# and friends would be re-parsed as shell syntax.
|
||||||
|
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||||
|
|
||||||
|
|
||||||
|
def shell_token(tok: str, force: bool = False) -> str:
|
||||||
|
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||||
|
|
||||||
|
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||||
|
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||||
|
the command reaches the shell.
|
||||||
|
"""
|
||||||
|
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||||
|
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||||
|
return tok
|
||||||
|
# An empty token must become '' / "" or it vanishes from the argv
|
||||||
|
if os.name == "nt":
|
||||||
|
return quote_arg(tok)
|
||||||
|
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||||
|
# re-examined for safe characters
|
||||||
|
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def quote_path(value: Path | str) -> str:
|
||||||
|
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||||
|
return shell_token(str(value), force=True)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""The PlatformIO-format size bar shared by the native toolchains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def format_bar(used: int, total: int) -> str:
|
||||||
|
"""Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
|
||||||
|
pct_raw = used / total if total else 0
|
||||||
|
blocks = 10
|
||||||
|
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||||
|
progress = "=" * filled
|
||||||
|
return (
|
||||||
|
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||||
|
f"(used {used:d} bytes from {total:d} bytes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_size_line(label: str, used: int, total: int) -> None:
|
||||||
|
"""One PlatformIO-format summary line (``RAM``/``Flash``).
|
||||||
|
|
||||||
|
The label padding is part of the format: ``script/ci_memory_impact_extract.py``
|
||||||
|
matches these lines verbatim.
|
||||||
|
"""
|
||||||
|
print(f"{label + ':':<7}{format_bar(used, total)}")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Machine-global tools cache location shared by the native backends."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||||
|
"""A backend's machine-global tools directory, with an env override.
|
||||||
|
|
||||||
|
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||||
|
to the CWD, which ``clean-all`` would then delete.
|
||||||
|
"""
|
||||||
|
import platformdirs
|
||||||
|
|
||||||
|
from esphome.helpers import get_str_env
|
||||||
|
|
||||||
|
if prefix := get_str_env(env_var, "").strip():
|
||||||
|
# resolve(): symlinked prefixes otherwise trip idf.py's
|
||||||
|
# venv-mismatch warning on every build
|
||||||
|
return Path(prefix).expanduser().resolve()
|
||||||
|
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||||
|
# deep IDF trees run into MAX_PATH otherwise
|
||||||
|
return (
|
||||||
|
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||||
|
# every entry via tools_cache_path, so listing a cache here is the single
|
||||||
|
# step that registers it for removal; the backends' own path getters use
|
||||||
|
# the same named pairs so the two cannot drift.
|
||||||
|
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||||
|
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||||
|
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||||
|
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||||
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
|||||||
add,
|
add,
|
||||||
add_build_flag,
|
add_build_flag,
|
||||||
add_build_unflag,
|
add_build_unflag,
|
||||||
|
add_cmake_arg,
|
||||||
add_cxx_build_flag,
|
add_cxx_build_flag,
|
||||||
add_define,
|
add_define,
|
||||||
add_global,
|
add_global,
|
||||||
@@ -77,6 +78,7 @@ from esphome.cpp_types import ( # noqa: F401
|
|||||||
StringRef,
|
StringRef,
|
||||||
arduino_json_ns,
|
arduino_json_ns,
|
||||||
bool_,
|
bool_,
|
||||||
|
char,
|
||||||
const_char_ptr,
|
const_char_ptr,
|
||||||
double,
|
double,
|
||||||
esphome_ns,
|
esphome_ns,
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome.const import __version__ as ESPHOME_VERSION
|
from esphome.const import __version__ as ESPHOME_VERSION
|
||||||
from esphome.core import CORE, Lambda
|
from esphome.core import CORE, EsphomeError, Lambda
|
||||||
from esphome.helpers import write_file
|
from esphome.helpers import write_file
|
||||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -65,7 +65,86 @@ def save_compiled_config(config: ConfigType) -> None:
|
|||||||
# non-basic dict key), so every upload/logs pays the slow path.
|
# non-basic dict key), so every upload/logs pays the slow path.
|
||||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
# Likely persistent (permissions, full disk): every upload/logs
|
||||||
|
# pays the slow path until it clears, so surface it.
|
||||||
|
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||||
|
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||||
|
|
||||||
|
The cache is only written when a complete sidecar is on disk:
|
||||||
|
load_compiled_config can't use it otherwise, and it holds resolved
|
||||||
|
secrets.
|
||||||
|
"""
|
||||||
|
if _refresh_sidecar():
|
||||||
|
save_compiled_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_sidecar() -> bool:
|
||||||
|
"""Ensure a complete sidecar is on disk; True when one is.
|
||||||
|
|
||||||
|
Writes one (without claiming a build) when missing or wizard-only.
|
||||||
|
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = storage_path()
|
||||||
|
try:
|
||||||
|
old = StorageJSON.load_strict(path)
|
||||||
|
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
|
# Present but unreadable: it may hold a real build's metadata,
|
||||||
|
# and a fresh rewrite would also stop the next compile from
|
||||||
|
# cleaning a possibly incoherent build tree.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if old is not None and old.can_apply_to_core():
|
||||||
|
if (
|
||||||
|
old.toolchain is not None
|
||||||
|
and CORE.toolchain is not None
|
||||||
|
and old.toolchain != CORE.toolchain.value
|
||||||
|
):
|
||||||
|
# Platforms normalize toolchain-sensitive keys differently;
|
||||||
|
# never cache a config validated under a different toolchain
|
||||||
|
# than the compile's
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Not caching: config validated with toolchain %r but the "
|
||||||
|
"last compile used %r",
|
||||||
|
CORE.toolchain.value,
|
||||||
|
old.toolchain,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
# Compile-written; nothing to refresh.
|
||||||
|
return True
|
||||||
|
if CORE.build_path is not None and CORE.build_path.exists():
|
||||||
|
# An unvalidated build tree: its absent or mismatched sidecar
|
||||||
|
# is what makes the next compile wipe it, so don't vouch for
|
||||||
|
# a build this run never saw.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Not caching: build tree %s has no matching sidecar; "
|
||||||
|
"'esphome compile' will settle it",
|
||||||
|
CORE.build_path,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||||
|
if not new.can_apply_to_core():
|
||||||
|
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||||
|
return False
|
||||||
|
new.save(path)
|
||||||
|
return True
|
||||||
|
except (OSError, EsphomeError) as err:
|
||||||
|
# write_file wraps OSError into EsphomeError. Persistent
|
||||||
|
# (unwritable storage dir), so surface that every upload/logs
|
||||||
|
# pays the slow path.
|
||||||
|
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||||
|
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
|
# A structural bug; keep the traceback so it isn't mistaken
|
||||||
|
# for the I/O failure above.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||||
@@ -98,11 +177,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||||
if storage is None:
|
if storage is None or not storage.can_apply_to_core():
|
||||||
return None
|
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
|
||||||
# sidecars leave both of these unset and can't drive upload/logs.
|
|
||||||
if not storage.core_platform and not storage.target_platform:
|
|
||||||
return None
|
return None
|
||||||
storage.apply_to_core()
|
storage.apply_to_core()
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_METER,
|
UNIT_METER,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@MrSuicideParrot"]
|
CODEOWNERS = ["@MrSuicideParrot"]
|
||||||
DEPENDENCIES = ["uart"]
|
DEPENDENCIES = ["uart"]
|
||||||
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await uart.register_uart_device(var, config)
|
await uart.register_uart_device(var, config)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_MILLIMETER,
|
UNIT_MILLIMETER,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@TH-Braemer"]
|
CODEOWNERS = ["@TH-Braemer"]
|
||||||
DEPENDENCIES = ["uart"]
|
DEPENDENCIES = ["uart"]
|
||||||
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await uart.register_uart_device(var, config)
|
await uart.register_uart_device(var, config)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import stepper
|
from esphome.components import stepper
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN
|
from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
a4988_ns = cg.esphome_ns.namespace("a4988")
|
a4988_ns = cg.esphome_ns.namespace("a4988")
|
||||||
A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component)
|
A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component)
|
||||||
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = stepper.STEPPER_SCHEMA.extend(
|
|||||||
).extend(cv.COMPONENT_SCHEMA)
|
).extend(cv.COMPONENT_SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await stepper.register_stepper(var, config)
|
await stepper.register_stepper(var, config)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_GRAMS_PER_CUBIC_METER,
|
UNIT_GRAMS_PER_CUBIC_METER,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity")
|
absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity")
|
||||||
AbsoluteHumidityComponent = absolute_humidity_ns.class_(
|
AbsoluteHumidityComponent = absolute_humidity_ns.class_(
|
||||||
@@ -43,7 +44,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from esphome.components import output
|
|||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER
|
from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@glmnet"]
|
CODEOWNERS = ["@glmnet"]
|
||||||
|
|
||||||
@@ -48,7 +49,13 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
|
if CORE.is_esp32:
|
||||||
|
from esphome.components.esp32 import include_builtin_idf_component
|
||||||
|
|
||||||
|
# Re-enable the gptimer driver (excluded by default to save compile time)
|
||||||
|
include_builtin_idf_component("esp_driver_gptimer")
|
||||||
|
|
||||||
if CORE.is_esp8266:
|
if CORE.is_esp8266:
|
||||||
# ac_dimmer uses setTimer1Callback which requires the waveform generator
|
# ac_dimmer uses setTimer1Callback which requires the waveform generator
|
||||||
from esphome.components.esp8266.const import require_waveform
|
from esphome.components.esp8266.const import require_waveform
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from esphome.components.light.effects import register_addressable_effect
|
|||||||
from esphome.components.light.types import AddressableLightEffect
|
from esphome.components.light.types import AddressableLightEffect
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_NAME, CONF_UART_ID
|
from esphome.const import CONF_NAME, CONF_UART_ID
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["uart"]
|
DEPENDENCIES = ["uart"]
|
||||||
|
|
||||||
@@ -21,7 +24,7 @@ CONFIG_SCHEMA = cv.Schema({})
|
|||||||
"Adalight",
|
"Adalight",
|
||||||
{cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)},
|
{cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)},
|
||||||
)
|
)
|
||||||
async def adalight_light_effect_to_code(config, effect_id):
|
async def adalight_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj:
|
||||||
effect = cg.new_Pvariable(effect_id, config[CONF_NAME])
|
effect = cg.new_Pvariable(effect_id, config[CONF_NAME])
|
||||||
await uart.register_uart_device(effect, config)
|
await uart.register_uart_device(effect, config)
|
||||||
return effect
|
return effect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from esphome import pins
|
from esphome import pins
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
@@ -11,11 +13,13 @@ from esphome.components.esp32 import (
|
|||||||
VARIANT_ESP32P4,
|
VARIANT_ESP32P4,
|
||||||
VARIANT_ESP32S2,
|
VARIANT_ESP32S2,
|
||||||
VARIANT_ESP32S3,
|
VARIANT_ESP32S3,
|
||||||
|
VARIANT_ESP32S31,
|
||||||
get_esp32_variant,
|
get_esp32_variant,
|
||||||
)
|
)
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
|
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@esphome/core"]
|
CODEOWNERS = ["@esphome/core"]
|
||||||
|
|
||||||
@@ -153,6 +157,17 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = {
|
|||||||
9: adc_channel_t.ADC_CHANNEL_8,
|
9: adc_channel_t.ADC_CHANNEL_8,
|
||||||
10: adc_channel_t.ADC_CHANNEL_9,
|
10: adc_channel_t.ADC_CHANNEL_9,
|
||||||
},
|
},
|
||||||
|
# https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s31/include/soc/adc_channel.h
|
||||||
|
VARIANT_ESP32S31: {
|
||||||
|
42: adc_channel_t.ADC_CHANNEL_0,
|
||||||
|
43: adc_channel_t.ADC_CHANNEL_1,
|
||||||
|
44: adc_channel_t.ADC_CHANNEL_2,
|
||||||
|
45: adc_channel_t.ADC_CHANNEL_3,
|
||||||
|
46: adc_channel_t.ADC_CHANNEL_4,
|
||||||
|
47: adc_channel_t.ADC_CHANNEL_5,
|
||||||
|
48: adc_channel_t.ADC_CHANNEL_6,
|
||||||
|
49: adc_channel_t.ADC_CHANNEL_7,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# pin to adc2 channel mapping
|
# pin to adc2 channel mapping
|
||||||
@@ -222,10 +237,21 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
|
|||||||
19: adc_channel_t.ADC_CHANNEL_8,
|
19: adc_channel_t.ADC_CHANNEL_8,
|
||||||
20: adc_channel_t.ADC_CHANNEL_9,
|
20: adc_channel_t.ADC_CHANNEL_9,
|
||||||
},
|
},
|
||||||
|
# https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s31/include/soc/adc_channel.h
|
||||||
|
VARIANT_ESP32S31: {
|
||||||
|
50: adc_channel_t.ADC_CHANNEL_0,
|
||||||
|
51: adc_channel_t.ADC_CHANNEL_1,
|
||||||
|
52: adc_channel_t.ADC_CHANNEL_2,
|
||||||
|
53: adc_channel_t.ADC_CHANNEL_3,
|
||||||
|
54: adc_channel_t.ADC_CHANNEL_4,
|
||||||
|
55: adc_channel_t.ADC_CHANNEL_5,
|
||||||
|
56: adc_channel_t.ADC_CHANNEL_6,
|
||||||
|
57: adc_channel_t.ADC_CHANNEL_7,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_adc_pin(value):
|
def validate_adc_pin(value: Any) -> ConfigType | str:
|
||||||
if str(value).upper() == "VCC":
|
if str(value).upper() == "VCC":
|
||||||
if CORE.is_rp2:
|
if CORE.is_rp2:
|
||||||
return pins.internal_gpio_input_pin_schema(29)
|
return pins.internal_gpio_input_pin_schema(29)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.common";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
const LogString *sampling_mode_to_str(SamplingMode mode) {
|
const LogString *sampling_mode_to_str(SamplingMode mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.esp32";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
|
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
|
||||||
|
|
||||||
@@ -74,9 +74,7 @@ void ADCSensor::setup() {
|
|||||||
if (this->calibration_handle_ == nullptr) {
|
if (this->calibration_handle_ == nullptr) {
|
||||||
adc_cali_handle_t handle = nullptr;
|
adc_cali_handle_t handle = nullptr;
|
||||||
|
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
// RISC-V variants (except C2) and S3 use curve fitting calibration
|
|
||||||
adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first
|
adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first
|
||||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
|
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
|
||||||
cali_config.chan = this->channel_;
|
cali_config.chan = this->channel_;
|
||||||
@@ -94,7 +92,7 @@ void ADCSensor::setup() {
|
|||||||
ESP_LOGW(TAG, "Curve fitting calibration failed with error %d, will use uncalibrated readings", err);
|
ESP_LOGW(TAG, "Curve fitting calibration failed with error %d, will use uncalibrated readings", err);
|
||||||
this->setup_flags_.calibration_complete = false;
|
this->setup_flags_.calibration_complete = false;
|
||||||
}
|
}
|
||||||
#else // ESP32, ESP32-S2, and ESP32-C2 use line fitting calibration
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_line_fitting_config_t cali_config = {
|
adc_cali_line_fitting_config_t cali_config = {
|
||||||
.unit_id = this->adc_unit_,
|
.unit_id = this->adc_unit_,
|
||||||
.atten = this->attenuation_,
|
.atten = this->attenuation_,
|
||||||
@@ -112,7 +110,11 @@ void ADCSensor::setup() {
|
|||||||
ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err);
|
ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err);
|
||||||
this->setup_flags_.calibration_complete = false;
|
this->setup_flags_.calibration_complete = false;
|
||||||
}
|
}
|
||||||
#endif // ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3
|
#else // No calibration scheme available
|
||||||
|
(void) handle;
|
||||||
|
ESP_LOGD(TAG, "No calibration scheme for this variant, readings are uncalibrated");
|
||||||
|
this->setup_flags_.calibration_complete = false;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
this->setup_flags_.init_complete = true;
|
this->setup_flags_.init_complete = true;
|
||||||
@@ -121,23 +123,28 @@ void ADCSensor::setup() {
|
|||||||
void ADCSensor::dump_config() {
|
void ADCSensor::dump_config() {
|
||||||
LOG_SENSOR("", "ADC Sensor", this);
|
LOG_SENSOR("", "ADC Sensor", this);
|
||||||
LOG_PIN(" Pin: ", this->pin_);
|
LOG_PIN(" Pin: ", this->pin_);
|
||||||
ESP_LOGCONFIG(
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) || defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
TAG,
|
const char *calibration_status = this->setup_flags_.calibration_complete ? "OK" : "FAILED";
|
||||||
" Channel: %d\n"
|
#else
|
||||||
" Unit: %s\n"
|
const char *calibration_status = "N/A"; // This variant has no calibration scheme
|
||||||
" Attenuation: %s\n"
|
#endif
|
||||||
" Samples: %i\n"
|
ESP_LOGCONFIG(TAG,
|
||||||
" Sampling mode: %s\n"
|
" Channel: %d\n"
|
||||||
" Setup Status:\n"
|
" Unit: %s\n"
|
||||||
" Handle Init: %s\n"
|
" Attenuation: %s\n"
|
||||||
" Config: %s\n"
|
" Samples: %i\n"
|
||||||
" Calibration: %s\n"
|
" Sampling mode: %s\n"
|
||||||
" Overall Init: %s",
|
" Setup Status:\n"
|
||||||
this->channel_, LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)),
|
" Handle Init: %s\n"
|
||||||
this->autorange_ ? "Auto" : LOG_STR_ARG(attenuation_to_str(this->attenuation_)), this->sample_count_,
|
" Config: %s\n"
|
||||||
LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_)),
|
" Calibration: %s\n"
|
||||||
this->setup_flags_.handle_init_complete ? "OK" : "FAILED", this->setup_flags_.config_complete ? "OK" : "FAILED",
|
" Overall Init: %s",
|
||||||
this->setup_flags_.calibration_complete ? "OK" : "FAILED", this->setup_flags_.init_complete ? "OK" : "FAILED");
|
this->channel_, LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)),
|
||||||
|
this->autorange_ ? "Auto" : LOG_STR_ARG(attenuation_to_str(this->attenuation_)), this->sample_count_,
|
||||||
|
LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_)),
|
||||||
|
this->setup_flags_.handle_init_complete ? "OK" : "FAILED",
|
||||||
|
this->setup_flags_.config_complete ? "OK" : "FAILED", calibration_status,
|
||||||
|
this->setup_flags_.init_complete ? "OK" : "FAILED");
|
||||||
|
|
||||||
LOG_UPDATE_INTERVAL(this);
|
LOG_UPDATE_INTERVAL(this);
|
||||||
}
|
}
|
||||||
@@ -184,12 +191,11 @@ float ADCSensor::sample_fixed_attenuation_() {
|
|||||||
} else {
|
} else {
|
||||||
ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err);
|
ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err);
|
||||||
if (this->calibration_handle_ != nullptr) {
|
if (this->calibration_handle_ != nullptr) {
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
adc_cali_delete_scheme_curve_fitting(this->calibration_handle_);
|
adc_cali_delete_scheme_curve_fitting(this->calibration_handle_);
|
||||||
#else // Other ESP32 variants use line fitting calibration
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_delete_scheme_line_fitting(this->calibration_handle_);
|
adc_cali_delete_scheme_line_fitting(this->calibration_handle_);
|
||||||
#endif // ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3
|
#endif
|
||||||
this->calibration_handle_ = nullptr;
|
this->calibration_handle_ = nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,10 +223,9 @@ float ADCSensor::sample_autorange_() {
|
|||||||
// Need to recalibrate for the new attenuation
|
// Need to recalibrate for the new attenuation
|
||||||
if (this->calibration_handle_ != nullptr) {
|
if (this->calibration_handle_ != nullptr) {
|
||||||
// Delete old calibration handle
|
// Delete old calibration handle
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
adc_cali_delete_scheme_curve_fitting(this->calibration_handle_);
|
adc_cali_delete_scheme_curve_fitting(this->calibration_handle_);
|
||||||
#else
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_delete_scheme_line_fitting(this->calibration_handle_);
|
adc_cali_delete_scheme_line_fitting(this->calibration_handle_);
|
||||||
#endif
|
#endif
|
||||||
this->calibration_handle_ = nullptr;
|
this->calibration_handle_ = nullptr;
|
||||||
@@ -229,8 +234,7 @@ float ADCSensor::sample_autorange_() {
|
|||||||
// Create new calibration handle for this attenuation
|
// Create new calibration handle for this attenuation
|
||||||
adc_cali_handle_t handle = nullptr;
|
adc_cali_handle_t handle = nullptr;
|
||||||
|
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
adc_cali_curve_fitting_config_t cali_config = {};
|
adc_cali_curve_fitting_config_t cali_config = {};
|
||||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
|
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
|
||||||
cali_config.chan = this->channel_;
|
cali_config.chan = this->channel_;
|
||||||
@@ -242,7 +246,7 @@ float ADCSensor::sample_autorange_() {
|
|||||||
err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle);
|
err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle);
|
||||||
ESP_LOGVV(TAG, "Autorange atten=%d: Calibration handle creation %s (err=%d)", atten,
|
ESP_LOGVV(TAG, "Autorange atten=%d: Calibration handle creation %s (err=%d)", atten,
|
||||||
(err == ESP_OK) ? "SUCCESS" : "FAILED", err);
|
(err == ESP_OK) ? "SUCCESS" : "FAILED", err);
|
||||||
#else
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_line_fitting_config_t cali_config = {
|
adc_cali_line_fitting_config_t cali_config = {
|
||||||
.unit_id = this->adc_unit_,
|
.unit_id = this->adc_unit_,
|
||||||
.atten = atten,
|
.atten = atten,
|
||||||
@@ -264,10 +268,9 @@ float ADCSensor::sample_autorange_() {
|
|||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err);
|
ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err);
|
||||||
if (handle != nullptr) {
|
if (handle != nullptr) {
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
adc_cali_delete_scheme_curve_fitting(handle);
|
adc_cali_delete_scheme_curve_fitting(handle);
|
||||||
#else
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_delete_scheme_line_fitting(handle);
|
adc_cali_delete_scheme_line_fitting(handle);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -286,10 +289,9 @@ float ADCSensor::sample_autorange_() {
|
|||||||
ESP_LOGVV(TAG, "Autorange atten=%d: UNCALIBRATED FALLBACK - raw=%d -> %.6fV (3.3V ref)", atten, raw, voltage);
|
ESP_LOGVV(TAG, "Autorange atten=%d: UNCALIBRATED FALLBACK - raw=%d -> %.6fV (3.3V ref)", atten, raw, voltage);
|
||||||
}
|
}
|
||||||
// Clean up calibration handle
|
// Clean up calibration handle
|
||||||
#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \
|
#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED)
|
||||||
USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3
|
|
||||||
adc_cali_delete_scheme_curve_fitting(handle);
|
adc_cali_delete_scheme_curve_fitting(handle);
|
||||||
#else
|
#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED)
|
||||||
adc_cali_delete_scheme_line_fitting(handle);
|
adc_cali_delete_scheme_line_fitting(handle);
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.esp8266";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
void ADCSensor::setup() {
|
void ADCSensor::setup() {
|
||||||
#ifndef USE_ADC_SENSOR_VCC
|
#ifndef USE_ADC_SENSOR_VCC
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.libretiny";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
void ADCSensor::setup() {
|
void ADCSensor::setup() {
|
||||||
#ifndef USE_ADC_SENSOR_VCC
|
#ifndef USE_ADC_SENSOR_VCC
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.rp2";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
namespace esphome::adc {
|
namespace esphome::adc {
|
||||||
|
|
||||||
static const char *const TAG = "adc.zephyr";
|
static const char *const TAG = "adc";
|
||||||
|
|
||||||
void ADCSensor::setup() {
|
void ADCSensor::setup() {
|
||||||
if (!adc_is_ready_dt(this->channel_)) {
|
if (!adc_is_ready_dt(this->channel_)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import sensor, voltage_sampler
|
from esphome.components import sensor, voltage_sampler
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
|
VARIANT_ESP32S31,
|
||||||
get_esp32_variant,
|
get_esp32_variant,
|
||||||
include_builtin_idf_component,
|
include_builtin_idf_component,
|
||||||
require_adc_oneshot_iram,
|
require_adc_oneshot_iram,
|
||||||
@@ -52,10 +53,18 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
|
|||||||
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
|
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
|
||||||
|
|
||||||
|
|
||||||
def validate_config(config):
|
def validate_config(config: ConfigType) -> ConfigType:
|
||||||
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
|
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
|
||||||
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
|
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
|
||||||
|
|
||||||
|
# The S31 ADC supports a single attenuation level (SOC_ADC_ATTEN_NUM is 1)
|
||||||
|
if (
|
||||||
|
CORE.is_esp32
|
||||||
|
and get_esp32_variant() == VARIANT_ESP32S31
|
||||||
|
and config.get(CONF_ATTENUATION, "0db") != "0db"
|
||||||
|
):
|
||||||
|
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
|
||||||
|
|
||||||
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
|
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
"Automatic attenuation cannot be used when multisampling is set"
|
"Automatic attenuation cannot be used when multisampling is set"
|
||||||
@@ -120,7 +129,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||||
|
|
||||||
|
|
||||||
def _overlay_io_channels():
|
def _overlay_io_channels() -> str:
|
||||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||||
return f"""
|
return f"""
|
||||||
@@ -132,7 +141,7 @@ def _overlay_io_channels():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await sensor.register_sensor(var, config)
|
await sensor.register_sensor(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import spi
|
from esphome.components import spi
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["spi"]
|
DEPENDENCIES = ["spi"]
|
||||||
MULTI_CONF = True
|
MULTI_CONF = True
|
||||||
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema(
|
|||||||
).extend(spi.spi_device_schema(cs_pin_required=True))
|
).extend(spi.spi_device_schema(cs_pin_required=True))
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await spi.register_spi_device(var, config)
|
await spi.register_spi_device(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import sensor, voltage_sampler
|
from esphome.components import sensor, voltage_sampler
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_CHANNEL, CONF_ID
|
from esphome.const import CONF_CHANNEL, CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import ADC128S102, adc128s102_ns
|
from .. import ADC128S102, adc128s102_ns
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(
|
var = cg.new_Pvariable(
|
||||||
config[CONF_ID],
|
config[CONF_ID],
|
||||||
config[CONF_CHANNEL],
|
config[CONF_CHANNEL],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
CONF_WIDTH,
|
CONF_WIDTH,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@justfalter"]
|
CODEOWNERS = ["@justfalter"]
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID])
|
wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID])
|
||||||
cg.add(var.set_width(config[CONF_WIDTH]))
|
cg.add(var.set_width(config[CONF_WIDTH]))
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from esphome.const import (
|
|||||||
UNIT_WATT,
|
UNIT_WATT,
|
||||||
UNIT_WATT_HOURS,
|
UNIT_WATT_HOURS,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
@@ -243,7 +244,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def neutral_channel(config):
|
async def neutral_channel(config: ConfigType) -> MockObj:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
|
|
||||||
current = config[CONF_CURRENT]
|
current = config[CONF_CURRENT]
|
||||||
@@ -257,7 +258,7 @@ async def neutral_channel(config):
|
|||||||
return var
|
return var
|
||||||
|
|
||||||
|
|
||||||
async def power_channel(config):
|
async def power_channel(config: ConfigType) -> MockObj:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
|
|
||||||
for sensor_type in POWER_SENSOR_TYPES:
|
for sensor_type in POWER_SENSOR_TYPES:
|
||||||
@@ -280,7 +281,7 @@ async def power_channel(config):
|
|||||||
return var
|
return var
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from esphome.const import (
|
|||||||
UNIT_VOLT_AMPS_REACTIVE,
|
UNIT_VOLT_AMPS_REACTIVE,
|
||||||
UNIT_WATT,
|
UNIT_WATT,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@angelnu"]
|
CODEOWNERS = ["@angelnu"]
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema(
|
|||||||
).extend(cv.polling_component_schema("60s"))
|
).extend(cv.polling_component_schema("60s"))
|
||||||
|
|
||||||
|
|
||||||
async def register_ade7953(var, config):
|
async def register_ade7953(var: MockObj, config: ConfigType) -> None:
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|
||||||
if irq_pin_config := config.get(CONF_IRQ_PIN):
|
if irq_pin_config := config.get(CONF_IRQ_PIN):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import ade7953_base, i2c
|
from esphome.components import ade7953_base, i2c
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
AUTO_LOAD = ["ade7953_base"]
|
AUTO_LOAD = ["ade7953_base"]
|
||||||
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
await ade7953_base.register_ade7953(var, config)
|
await ade7953_base.register_ade7953(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import ade7953_base, spi
|
from esphome.components import ade7953_base, spi
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["spi"]
|
DEPENDENCIES = ["spi"]
|
||||||
AUTO_LOAD = ["ade7953_base"]
|
AUTO_LOAD = ["ade7953_base"]
|
||||||
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await spi.register_spi_device(var, config)
|
await spi.register_spi_device(var, config)
|
||||||
await ade7953_base.register_ade7953(var, config)
|
await ade7953_base.register_ade7953(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import i2c
|
from esphome.components import i2c
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
MULTI_CONF = True
|
MULTI_CONF = True
|
||||||
@@ -24,7 +25,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_VOLT,
|
UNIT_VOLT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns
|
from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await sensor.register_sensor(var, config)
|
await sensor.register_sensor(var, config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import spi
|
from esphome.components import spi
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@solomondg1"]
|
CODEOWNERS = ["@solomondg1"]
|
||||||
DEPENDENCIES = ["spi"]
|
DEPENDENCIES = ["spi"]
|
||||||
@@ -23,7 +24,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await spi.register_spi_device(var, config)
|
await spi.register_spi_device(var, config)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
UNIT_CELSIUS,
|
UNIT_CELSIUS,
|
||||||
UNIT_VOLT,
|
UNIT_VOLT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns
|
from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = cv.typed_schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await cg.register_parented(var, config[CONF_ADS1118_ID])
|
await cg.register_parented(var, config[CONF_ADS1118_ID])
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from esphome.const import (
|
|||||||
UNIT_OHM,
|
UNIT_OHM,
|
||||||
UNIT_PARTS_PER_BILLION,
|
UNIT_PARTS_PER_BILLION,
|
||||||
)
|
)
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CONF_RESISTANCE = "resistance"
|
CONF_RESISTANCE = "resistance"
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ CONFIG_SCHEMA = (
|
|||||||
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
|
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
@@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
|||||||
AGS10_NEW_I2C_ADDRESS_SCHEMA,
|
AGS10_NEW_I2C_ADDRESS_SCHEMA,
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
)
|
)
|
||||||
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
|
async def ags10newi2caddress_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
var = cg.new_Pvariable(action_id, template_arg)
|
var = cg.new_Pvariable(action_id, template_arg)
|
||||||
await cg.register_parented(var, config[CONF_ID])
|
await cg.register_parented(var, config[CONF_ID])
|
||||||
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
|
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
|
||||||
@@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
|
|||||||
AGS10_SET_ZERO_POINT_SCHEMA,
|
AGS10_SET_ZERO_POINT_SCHEMA,
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
)
|
)
|
||||||
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
|
async def ags10setzeropoint_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
var = cg.new_Pvariable(action_id, template_arg)
|
var = cg.new_Pvariable(action_id, template_arg)
|
||||||
await cg.register_parented(var, config[CONF_ID])
|
await cg.register_parented(var, config[CONF_ID])
|
||||||
mode = await cg.templatable(
|
mode = await cg.templatable(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from esphome.const import (
|
|||||||
UNIT_CELSIUS,
|
UNIT_CELSIUS,
|
||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from esphome.components import i2c
|
|||||||
from esphome.components.audio_dac import AudioDac
|
from esphome.components.audio_dac import AudioDac
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID, CONF_MODE
|
from esphome.const import CONF_ID, CONF_MODE
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@kbx81"]
|
CODEOWNERS = ["@kbx81"]
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
@@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
|
|||||||
SET_AUTO_MUTE_ACTION_SCHEMA,
|
SET_AUTO_MUTE_ACTION_SCHEMA,
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
)
|
)
|
||||||
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
async def aic3204_set_volume_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
paren = await cg.get_variable(config[CONF_ID])
|
paren = await cg.get_variable(config[CONF_ID])
|
||||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||||
|
|
||||||
@@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
|||||||
return var
|
return var
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import ble_device_base
|
from esphome.components import ble_device_base
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
AUTO_LOAD = ["ble_device_base"]
|
AUTO_LOAD = ["ble_device_base"]
|
||||||
CODEOWNERS = ["@jeromelaban"]
|
CODEOWNERS = ["@jeromelaban"]
|
||||||
@@ -21,6 +22,6 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await ble_device_base.register_ble_device(var, config)
|
await ble_device_base.register_ble_device(var, config)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from esphome.const import (
|
|||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
UNIT_VOLT,
|
UNIT_VOLT,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@ncareau", "@jeromelaban"]
|
CODEOWNERS = ["@ncareau", "@jeromelaban"]
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ BASE_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def wave_base_to_code(var, config):
|
async def wave_base_to_code(var: MockObj, config: ConfigType) -> None:
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|
||||||
await ble_client.register_ble_node(var, config)
|
await ble_client.register_ble_node(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import airthings_wave_base
|
from esphome.components import airthings_wave_base
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = airthings_wave_base.DEPENDENCIES
|
DEPENDENCIES = airthings_wave_base.DEPENDENCIES
|
||||||
|
|
||||||
@@ -20,6 +21,6 @@ CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await airthings_wave_base.wave_base_to_code(var, config)
|
await airthings_wave_base.wave_base_to_code(var, config)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await airthings_wave_base.wave_base_to_code(var, config)
|
await airthings_wave_base.wave_base_to_code(var, config)
|
||||||
|
|
||||||
|
|||||||
@@ -162,8 +162,9 @@ void Alpha3::send_request_(uint8_t *request, size_t len) {
|
|||||||
auto status =
|
auto status =
|
||||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len,
|
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len,
|
||||||
request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||||
if (status)
|
if (status) {
|
||||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Alpha3::update() {
|
void Alpha3::update() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from esphome.const import (
|
|||||||
UNIT_VOLT,
|
UNIT_VOLT,
|
||||||
UNIT_WATT,
|
UNIT_WATT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
alpha3_ns = cg.esphome_ns.namespace("alpha3")
|
alpha3_ns = cg.esphome_ns.namespace("alpha3")
|
||||||
Alpha3 = alpha3_ns.class_("Alpha3", ble_client.BLEClientNode, cg.PollingComponent)
|
Alpha3 = alpha3_ns.class_("Alpha3", ble_client.BLEClientNode, cg.PollingComponent)
|
||||||
@@ -68,7 +69,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await ble_client.register_ble_node(var, config)
|
await ble_client.register_ble_node(var, config)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
UNIT_CELSIUS,
|
UNIT_CELSIUS,
|
||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
UNIT_CELSIUS,
|
UNIT_CELSIUS,
|
||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import ble_client, cover
|
from esphome.components import ble_client, cover
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_PIN
|
from esphome.const import CONF_PIN
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@buxtronix"]
|
CODEOWNERS = ["@buxtronix"]
|
||||||
DEPENDENCIES = ["ble_client"]
|
DEPENDENCIES = ["ble_client"]
|
||||||
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await cover.new_cover(config)
|
var = await cover.new_cover(config)
|
||||||
cg.add(var.set_pin(config[CONF_PIN]))
|
cg.add(var.set_pin(config[CONF_PIN]))
|
||||||
cg.add(var.set_invert_position(config[CONF_INVERT_POSITION]))
|
cg.add(var.set_invert_position(config[CONF_INVERT_POSITION]))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
AUTO_LOAD = ["am43"]
|
AUTO_LOAD = ["am43"]
|
||||||
CODEOWNERS = ["@buxtronix"]
|
CODEOWNERS = ["@buxtronix"]
|
||||||
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await ble_client.register_ble_node(var, config)
|
await ble_client.register_ble_node(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import binary_sensor, sensor
|
from esphome.components import binary_sensor, sensor
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_SENSOR_ID, CONF_THRESHOLD
|
from esphome.const import CONF_SENSOR_ID, CONF_THRESHOLD
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
analog_threshold_ns = cg.esphome_ns.namespace("analog_threshold")
|
analog_threshold_ns = cg.esphome_ns.namespace("analog_threshold")
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await binary_sensor.new_binary_sensor(config)
|
var = await binary_sensor.new_binary_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from esphome.components.file.image import image_schema, write_image
|
|||||||
from esphome.components.image import Image_, validate_settings
|
from esphome.components.image import Image_, validate_settings
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID, CONF_REPEAT
|
from esphome.const import CONF_ID, CONF_REPEAT
|
||||||
|
from esphome.core import ID
|
||||||
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@syndlex"]
|
CODEOWNERS = ["@syndlex"]
|
||||||
@@ -79,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema(
|
|||||||
@automation.register_action(
|
@automation.register_action(
|
||||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||||
)
|
)
|
||||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
async def animation_action_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
paren = await cg.get_variable(config[CONF_ID])
|
paren = await cg.get_variable(config[CONF_ID])
|
||||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import ble_client, climate
|
from esphome.components import ble_client, climate
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_UNIT_OF_MEASUREMENT
|
from esphome.const import CONF_UNIT_OF_MEASUREMENT
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
UNITS = {
|
UNITS = {
|
||||||
"f": "f",
|
"f": "f",
|
||||||
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await climate.new_climate(config)
|
var = await climate.new_climate(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await ble_client.register_ble_node(var, config)
|
await ble_client.register_ble_node(var, config)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Based on this datasheet:
|
# Based on this datasheet:
|
||||||
# https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf
|
# https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import i2c, sensor
|
from esphome.components import i2c, sensor
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
@@ -11,6 +13,8 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_LUX,
|
UNIT_LUX,
|
||||||
)
|
)
|
||||||
|
from esphome.cpp_generator import MockObj
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ AMBIENT_LIGHT_GAINS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate_measurement_rate(value):
|
def _validate_measurement_rate(value: Any) -> MockObj:
|
||||||
value = cv.positive_time_period_milliseconds(value)
|
value = cv.positive_time_period_milliseconds(value)
|
||||||
return cv.enum(MEASUREMENT_RATES, int=True)(value.total_milliseconds)
|
return cv.enum(MEASUREMENT_RATES, int=True)(value.total_milliseconds)
|
||||||
|
|
||||||
@@ -85,7 +89,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import i2c
|
from esphome.components import i2c
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["i2c"]
|
DEPENDENCIES = ["i2c"]
|
||||||
MULTI_CONF = True
|
MULTI_CONF = True
|
||||||
@@ -57,7 +58,7 @@ CONFIG_SCHEMA = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
await cg.register_component(var, config)
|
await cg.register_component(var, config)
|
||||||
await i2c.register_i2c_device(var, config)
|
await i2c.register_i2c_device(var, config)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
|||||||
from esphome.components import binary_sensor
|
from esphome.components import binary_sensor
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING
|
from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import APDS9960, CONF_APDS9960_ID
|
from . import APDS9960, CONF_APDS9960_ID
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
||||||
var = await binary_sensor.new_binary_sensor(config)
|
var = await binary_sensor.new_binary_sensor(config)
|
||||||
func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor")
|
func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from esphome.const import (
|
|||||||
STATE_CLASS_MEASUREMENT,
|
STATE_CLASS_MEASUREMENT,
|
||||||
UNIT_PERCENT,
|
UNIT_PERCENT,
|
||||||
)
|
)
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import APDS9960, CONF_APDS9960_ID
|
from . import APDS9960, CONF_APDS9960_ID
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config: ConfigType) -> None:
|
||||||
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
||||||
var = await sensor.new_sensor(config)
|
var = await sensor.new_sensor(config)
|
||||||
func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor")
|
func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor")
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation
|
||||||
from esphome.automation import Condition
|
from esphome.automation import Condition
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
|
from esphome.components.const import CONF_DESCRIPTION
|
||||||
from esphome.components.logger import request_log_listener
|
from esphome.components.logger import request_log_listener
|
||||||
from esphome.config_helpers import get_logger_level
|
|
||||||
|
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||||
|
# components and downstream consumers that import them from api
|
||||||
|
from esphome.components.noise import ( # noqa: F401
|
||||||
|
ENCRYPTION_SCHEMA,
|
||||||
|
decode_encryption_key,
|
||||||
|
encryption_schema,
|
||||||
|
validate_encryption_key,
|
||||||
|
)
|
||||||
|
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_ACTION,
|
CONF_ACTION,
|
||||||
@@ -31,12 +42,18 @@ from esphome.const import (
|
|||||||
CONF_TAG,
|
CONF_TAG,
|
||||||
CONF_THEN,
|
CONF_THEN,
|
||||||
CONF_TRIGGER_ID,
|
CONF_TRIGGER_ID,
|
||||||
|
CONF_TYPE,
|
||||||
CONF_VARIABLES,
|
CONF_VARIABLES,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||||
|
from esphome.helpers import fnv1_hash
|
||||||
from esphome.types import ConfigFragmentType, ConfigType
|
from esphome.types import ConfigFragmentType, ConfigType
|
||||||
|
|
||||||
|
# Compat alias: downstream consumers (e.g. device-builder) referenced the
|
||||||
|
# schema by its old private name before it moved to the noise component
|
||||||
|
_encryption_schema = encryption_schema
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DOMAIN = "api"
|
DOMAIN = "api"
|
||||||
@@ -45,9 +62,15 @@ CODEOWNERS = ["@esphome/core"]
|
|||||||
|
|
||||||
|
|
||||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||||
"""Conditionally auto-load json only when capture_response is used."""
|
"""Conditionally auto-load noise (encryption) and json (capture_response)."""
|
||||||
base = ["socket"]
|
base = ["socket"]
|
||||||
|
|
||||||
|
# A falsy config is a tooling probe for the maximal set (None from
|
||||||
|
# dependency resolution, {} from the components-graph platform probe);
|
||||||
|
# a validated config always carries defaults, never empty
|
||||||
|
if not config or CONF_ENCRYPTION in config:
|
||||||
|
base = base + ["noise"]
|
||||||
|
|
||||||
# Check if any homeassistant.action/homeassistant.service has capture_response: true
|
# Check if any homeassistant.action/homeassistant.service has capture_response: true
|
||||||
# This flag is set during config validation in _validate_response_config
|
# This flag is set during config validation in _validate_response_config
|
||||||
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
|
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
|
||||||
@@ -105,6 +128,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
|
|||||||
}
|
}
|
||||||
CONF_BATCH_DELAY = "batch_delay"
|
CONF_BATCH_DELAY = "batch_delay"
|
||||||
CONF_CUSTOM_SERVICES = "custom_services"
|
CONF_CUSTOM_SERVICES = "custom_services"
|
||||||
|
CONF_EXAMPLE = "example"
|
||||||
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||||
@@ -129,20 +153,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def validate_encryption_key(value):
|
|
||||||
value = cv.string_strict(value)
|
|
||||||
try:
|
|
||||||
decoded = base64.b64decode(value, validate=True)
|
|
||||||
except ValueError as err:
|
|
||||||
raise cv.Invalid("Invalid key format, please check it's using base64") from err
|
|
||||||
|
|
||||||
if len(decoded) != 32:
|
|
||||||
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
|
|
||||||
|
|
||||||
# Return original data for roundtrip conversion
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
CONF_SUPPORTS_RESPONSE = "supports_response"
|
CONF_SUPPORTS_RESPONSE = "supports_response"
|
||||||
|
|
||||||
# Enum values in api::enums namespace
|
# Enum values in api::enums namespace
|
||||||
@@ -217,19 +227,35 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _validate_supports_response(value):
|
def _validate_supports_response(value: Any) -> str:
|
||||||
"""Validate supports_response after auto-detection has set the value."""
|
"""Validate supports_response after auto-detection has set the value."""
|
||||||
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
|
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
|
||||||
|
|
||||||
|
|
||||||
|
# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small
|
||||||
|
ESP8266_ACTION_STRINGS_MAX_TOTAL = 384
|
||||||
|
|
||||||
|
VARIABLE_SCHEMA = cv.Schema(
|
||||||
|
{
|
||||||
|
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
|
||||||
|
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
|
||||||
|
cv.Optional(CONF_EXAMPLE): cv.string_strict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accepts the plain `name: type` shorthand or the full mapping form
|
||||||
|
validate_variable = cv.maybe_simple_value(VARIABLE_SCHEMA, key=CONF_TYPE)
|
||||||
|
|
||||||
|
|
||||||
ACTIONS_SCHEMA = automation.validate_automation(
|
ACTIONS_SCHEMA = automation.validate_automation(
|
||||||
{
|
{
|
||||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger),
|
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger),
|
||||||
cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name,
|
cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name,
|
||||||
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name,
|
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name,
|
||||||
|
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
|
||||||
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
||||||
{
|
{
|
||||||
cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
|
cv.validate_id_name: validate_variable,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
# No default - auto-detected by _auto_detect_supports_response
|
# No default - auto-detected by _auto_detect_supports_response
|
||||||
@@ -249,18 +275,6 @@ ACTIONS_SCHEMA = automation.validate_automation(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ENCRYPTION_SCHEMA = cv.Schema(
|
|
||||||
{
|
|
||||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _encryption_schema(config):
|
|
||||||
if config is None:
|
|
||||||
config = {}
|
|
||||||
return ENCRYPTION_SCHEMA(config)
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||||
"""Register socket needs for API component."""
|
"""Register socket needs for API component."""
|
||||||
@@ -296,7 +310,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
|
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
|
||||||
): ACTIONS_SCHEMA,
|
): ACTIONS_SCHEMA,
|
||||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||||
cv.Optional(CONF_ENCRYPTION): _encryption_schema,
|
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||||
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
|
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
|
||||||
cv.positive_time_period_milliseconds,
|
cv.positive_time_period_milliseconds,
|
||||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||||
@@ -358,6 +372,85 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_action_metadata(actions: list[ConfigType]) -> bool:
|
||||||
|
# Empty strings count as unset, matching _action_strings
|
||||||
|
return any(
|
||||||
|
conf.get(CONF_DESCRIPTION)
|
||||||
|
or any(
|
||||||
|
var_.get(CONF_DESCRIPTION) or var_.get(CONF_EXAMPLE)
|
||||||
|
for var_ in conf[CONF_VARIABLES].values()
|
||||||
|
)
|
||||||
|
for conf in actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
|
||||||
|
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
|
||||||
|
# An empty description or example is treated as unset
|
||||||
|
strings: list[str | None] = [conf[CONF_ACTION]]
|
||||||
|
if has_metadata:
|
||||||
|
strings.append(conf.get(CONF_DESCRIPTION) or None)
|
||||||
|
for name, var_ in conf[CONF_VARIABLES].items():
|
||||||
|
strings.append(name)
|
||||||
|
if has_metadata:
|
||||||
|
strings += [
|
||||||
|
var_.get(CONF_DESCRIPTION) or None,
|
||||||
|
var_.get(CONF_EXAMPLE) or None,
|
||||||
|
]
|
||||||
|
return strings
|
||||||
|
|
||||||
|
|
||||||
|
def _action_strings_size(strings: list[str | None]) -> int:
|
||||||
|
"""Bytes needed to copy every string out of flash, each with its terminator."""
|
||||||
|
return sum(
|
||||||
|
len(string.encode("utf-8")) + 1 for string in strings if string is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
||||||
|
if not CORE.is_esp8266:
|
||||||
|
return config
|
||||||
|
actions = config.get(CONF_ACTIONS, [])
|
||||||
|
has_metadata = _has_action_metadata(actions)
|
||||||
|
for conf in actions:
|
||||||
|
size = _action_strings_size(_action_strings(conf, has_metadata))
|
||||||
|
if size > ESP8266_ACTION_STRINGS_MAX_TOTAL:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, "
|
||||||
|
f"description and example text; ESP8266 allows at most "
|
||||||
|
f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action"
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||||
|
|
||||||
|
|
||||||
|
def _add_action_strings(
|
||||||
|
index: int, strings: list[str | None], interned: dict[str, MockObj]
|
||||||
|
) -> MockObj:
|
||||||
|
"""Emit the PROGMEM string table for one action.
|
||||||
|
|
||||||
|
Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical
|
||||||
|
strings are shared between actions through `interned`.
|
||||||
|
"""
|
||||||
|
entries: list[MockObj] = []
|
||||||
|
for string in strings:
|
||||||
|
if string is None:
|
||||||
|
entries.append(cg.nullptr)
|
||||||
|
continue
|
||||||
|
if (var := interned.get(string)) is None:
|
||||||
|
var = interned[string] = cg.progmem_array(
|
||||||
|
ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char),
|
||||||
|
string,
|
||||||
|
)
|
||||||
|
entries.append(var)
|
||||||
|
return cg.progmem_array(
|
||||||
|
ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr),
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.WEB)
|
@coroutine_with_priority(CoroPriority.WEB)
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
var = cg.new_Pvariable(config[CONF_ID])
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
@@ -377,8 +470,10 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||||
|
|
||||||
|
actions = config.get(CONF_ACTIONS, [])
|
||||||
|
has_user_actions = bool(actions) or config[CONF_CUSTOM_SERVICES]
|
||||||
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
|
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
|
||||||
if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]:
|
if has_user_actions:
|
||||||
cg.add_define("USE_API_USER_DEFINED_ACTIONS")
|
cg.add_define("USE_API_USER_DEFINED_ACTIONS")
|
||||||
|
|
||||||
# Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration
|
# Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration
|
||||||
@@ -391,10 +486,17 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
if config[CONF_HOMEASSISTANT_STATES]:
|
if config[CONF_HOMEASSISTANT_STATES]:
|
||||||
cg.add_define("USE_API_HOMEASSISTANT_STATES")
|
cg.add_define("USE_API_HOMEASSISTANT_STATES")
|
||||||
|
|
||||||
if actions := config.get(CONF_ACTIONS, []):
|
scratch_size = 0
|
||||||
|
if actions:
|
||||||
|
# Metadata is compiled in for every action once any action declares it, because the
|
||||||
|
# string table layout is fixed by the define rather than per action
|
||||||
|
has_metadata = _has_action_metadata(actions)
|
||||||
|
if has_metadata:
|
||||||
|
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
|
||||||
|
interned_strings: dict[str, MockObj] = {}
|
||||||
# Collect all triggers first, then register all at once with initializer_list
|
# Collect all triggers first, then register all at once with initializer_list
|
||||||
triggers: list[cg.Pvariable] = []
|
triggers: list[cg.MockObj] = []
|
||||||
for conf in actions:
|
for index, conf in enumerate(actions):
|
||||||
func_args: list[tuple[MockObj, str]] = []
|
func_args: list[tuple[MockObj, str]] = []
|
||||||
service_template_args: list[MockObj] = [] # User service argument types
|
service_template_args: list[MockObj] = [] # User service argument types
|
||||||
|
|
||||||
@@ -427,22 +529,23 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
conf.get(CONF_THEN, [])
|
conf.get(CONF_THEN, [])
|
||||||
)
|
)
|
||||||
|
|
||||||
service_arg_names: list[str] = []
|
|
||||||
for name, var_ in conf[CONF_VARIABLES].items():
|
for name, var_ in conf[CONF_VARIABLES].items():
|
||||||
if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES:
|
var_type = var_[CONF_TYPE]
|
||||||
native = SERVICE_ARG_FALLBACK_TYPES[var_]
|
if has_non_synchronous and var_type in SERVICE_ARG_FALLBACK_TYPES:
|
||||||
|
native = SERVICE_ARG_FALLBACK_TYPES[var_type]
|
||||||
else:
|
else:
|
||||||
native = SERVICE_ARG_NATIVE_TYPES[var_]
|
native = SERVICE_ARG_NATIVE_TYPES[var_type]
|
||||||
service_template_args.append(native)
|
service_template_args.append(native)
|
||||||
func_args.append((native, name))
|
func_args.append((native, name))
|
||||||
service_arg_names.append(name)
|
strings = _action_strings(conf, has_metadata)
|
||||||
|
table = _add_action_strings(index, strings, interned_strings)
|
||||||
|
if CORE.is_esp8266:
|
||||||
|
scratch_size = max(scratch_size, _action_strings_size(strings))
|
||||||
# Template args: supports_response mode, then user service arg types
|
# Template args: supports_response mode, then user service arg types
|
||||||
templ = cg.TemplateArguments(supports_response, *service_template_args)
|
templ = cg.TemplateArguments(supports_response, *service_template_args)
|
||||||
|
# Key is hashed here because the name is not readable at runtime on ESP8266
|
||||||
trigger = cg.new_Pvariable(
|
trigger = cg.new_Pvariable(
|
||||||
conf[CONF_TRIGGER_ID],
|
conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION])
|
||||||
templ,
|
|
||||||
conf[CONF_ACTION],
|
|
||||||
service_arg_names,
|
|
||||||
)
|
)
|
||||||
triggers.append(trigger)
|
triggers.append(trigger)
|
||||||
auto = await automation.build_automation(trigger, func_args, conf)
|
auto = await automation.build_automation(trigger, func_args, conf)
|
||||||
@@ -464,6 +567,9 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add(auto.add_actions([unregister_action]))
|
cg.add(auto.add_actions([unregister_action]))
|
||||||
# Register all services at once - single allocation, no reallocations
|
# Register all services at once - single allocation, no reallocations
|
||||||
cg.add(var.initialize_user_services(triggers))
|
cg.add(var.initialize_user_services(triggers))
|
||||||
|
if CORE.is_esp8266 and has_user_actions:
|
||||||
|
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
|
||||||
|
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
|
||||||
|
|
||||||
if CONF_ON_CLIENT_CONNECTED in config:
|
if CONF_ON_CLIENT_CONNECTED in config:
|
||||||
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
|
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
|
||||||
@@ -483,7 +589,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
|
|
||||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||||
if key := encryption_config.get(CONF_KEY):
|
if key := encryption_config.get(CONF_KEY):
|
||||||
decoded = base64.b64decode(key)
|
decoded = decode_encryption_key(key)
|
||||||
cg.add(var.set_noise_psk(list(decoded)))
|
cg.add(var.set_noise_psk(list(decoded)))
|
||||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||||
else:
|
else:
|
||||||
@@ -497,10 +603,6 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
# and plaintext disabled. Only a factory reset can remove it.
|
# and plaintext disabled. Only a factory reset can remove it.
|
||||||
cg.add_define("USE_API_PLAINTEXT")
|
cg.add_define("USE_API_PLAINTEXT")
|
||||||
cg.add_define("USE_API_NOISE")
|
cg.add_define("USE_API_NOISE")
|
||||||
cg.add_library("esphome/noise-c", "0.1.21")
|
|
||||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
|
||||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
|
||||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
|
||||||
else:
|
else:
|
||||||
cg.add_define("USE_API_PLAINTEXT")
|
cg.add_define("USE_API_PLAINTEXT")
|
||||||
|
|
||||||
@@ -510,6 +612,40 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
|
|
||||||
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
|
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
|
||||||
|
|
||||||
|
_ID_CALL_PROG = re.compile(r"\bid\s*\(")
|
||||||
|
|
||||||
|
|
||||||
|
# Remove before 2027.3.0: untagged strings that look like lambda source keep
|
||||||
|
# being compiled as lambdas during the deprecation window
|
||||||
|
def _coerce_implicit_lambda(value: Any) -> Any:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if cv.looks_like_returning_lambda(value):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"[api] The 'variables' value '%s' looks like a lambda but is "
|
||||||
|
"missing the !lambda tag. It is compiled as a lambda for now but "
|
||||||
|
"will be sent as literal text from 2027.3.0. Add !lambda to keep "
|
||||||
|
"it evaluated; literal text belongs under 'data:'.",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
# cv.templatable runs returning_lambda on the coerced Lambda
|
||||||
|
return cv.lambda_(value)
|
||||||
|
if _ID_CALL_PROG.search(value):
|
||||||
|
# lambda source without a return: issue 5394's mistake class
|
||||||
|
_LOGGER.warning(
|
||||||
|
"[api] The 'variables' value '%s' is sent as literal text; wrap "
|
||||||
|
"it in !lambda 'return ...;' to evaluate it instead.",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Static strings or !lambda values. cv.templatable stays introspectable for
|
||||||
|
# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
|
||||||
|
VARIABLES_SCHEMA = cv.Schema(
|
||||||
|
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||||
# Validate dependencies:
|
# Validate dependencies:
|
||||||
@@ -546,9 +682,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
|||||||
),
|
),
|
||||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||||
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||||
{cv.string: cv.returning_lambda}
|
|
||||||
),
|
|
||||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||||
@@ -581,7 +715,7 @@ async def homeassistant_service_to_code(
|
|||||||
action_id: ID,
|
action_id: ID,
|
||||||
template_arg: cg.TemplateArguments,
|
template_arg: cg.TemplateArguments,
|
||||||
args: TemplateArgsType,
|
args: TemplateArgsType,
|
||||||
):
|
) -> MockObj:
|
||||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||||
serv = await cg.get_variable(config[CONF_ID])
|
serv = await cg.get_variable(config[CONF_ID])
|
||||||
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
||||||
@@ -609,6 +743,8 @@ async def homeassistant_service_to_code(
|
|||||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||||
for key, value in config[CONF_VARIABLES].items():
|
for key, value in config[CONF_VARIABLES].items():
|
||||||
templ = await cg.templatable(value, args, None)
|
templ = await cg.templatable(value, args, None)
|
||||||
|
if isinstance(templ, str):
|
||||||
|
templ = cg.FlashStringLiteral(templ)
|
||||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||||
|
|
||||||
if on_error := config.get(CONF_ON_ERROR):
|
if on_error := config.get(CONF_ON_ERROR):
|
||||||
@@ -647,7 +783,7 @@ async def homeassistant_service_to_code(
|
|||||||
return var
|
return var
|
||||||
|
|
||||||
|
|
||||||
def validate_homeassistant_event(value):
|
def validate_homeassistant_event(value: Any) -> str:
|
||||||
value = cv.string(value)
|
value = cv.string(value)
|
||||||
if not value.startswith("esphome."):
|
if not value.startswith("esphome."):
|
||||||
raise cv.Invalid(
|
raise cv.Invalid(
|
||||||
@@ -663,7 +799,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
|||||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||||
cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
|
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -676,7 +812,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
|||||||
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
)
|
)
|
||||||
async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
async def homeassistant_event_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||||
serv = await cg.get_variable(config[CONF_ID])
|
serv = await cg.get_variable(config[CONF_ID])
|
||||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||||
@@ -704,6 +845,8 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
|||||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||||
for key, value in config[CONF_VARIABLES].items():
|
for key, value in config[CONF_VARIABLES].items():
|
||||||
templ = await cg.templatable(value, args, None)
|
templ = await cg.templatable(value, args, None)
|
||||||
|
if isinstance(templ, str):
|
||||||
|
templ = cg.FlashStringLiteral(templ)
|
||||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||||
|
|
||||||
return var
|
return var
|
||||||
@@ -724,7 +867,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
|||||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
|
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
|
||||||
synchronous=True,
|
synchronous=True,
|
||||||
)
|
)
|
||||||
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
|
async def homeassistant_tag_scanned_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
action_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||||
serv = await cg.get_variable(config[CONF_ID])
|
serv = await cg.get_variable(config[CONF_ID])
|
||||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||||
@@ -740,7 +888,7 @@ CONF_SUCCESS = "success"
|
|||||||
CONF_ERROR_MESSAGE = "error_message"
|
CONF_ERROR_MESSAGE = "error_message"
|
||||||
|
|
||||||
|
|
||||||
def _validate_api_respond_data(config):
|
def _validate_api_respond_data(config: ConfigType) -> ConfigType:
|
||||||
"""Set flag during validation so AUTO_LOAD can include json component."""
|
"""Set flag during validation so AUTO_LOAD can include json component."""
|
||||||
if CONF_DATA in config:
|
if CONF_DATA in config:
|
||||||
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
|
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
|
||||||
@@ -824,18 +972,32 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema(
|
|||||||
@automation.register_condition(
|
@automation.register_condition(
|
||||||
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
|
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
|
||||||
)
|
)
|
||||||
async def api_connected_to_code(config, condition_id, template_arg, args):
|
async def api_connected_to_code(
|
||||||
|
config: ConfigType,
|
||||||
|
condition_id: ID,
|
||||||
|
template_arg: cg.TemplateArguments,
|
||||||
|
args: TemplateArgsType,
|
||||||
|
) -> MockObj:
|
||||||
var = cg.new_Pvariable(condition_id, template_arg)
|
var = cg.new_Pvariable(condition_id, template_arg)
|
||||||
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
|
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
|
||||||
cg.add(var.set_state_subscription_only(templ))
|
cg.add(var.set_state_subscription_only(templ))
|
||||||
return var
|
return var
|
||||||
|
|
||||||
|
|
||||||
|
# user_services.cpp is only needed when user defined actions exist; the
|
||||||
|
# frame helpers are fully #ifdef'd on the protocol defines set in to_code
|
||||||
|
# (both are set when encryption is configured without a key).
|
||||||
|
_define_filter = filter_source_files_from_defines(
|
||||||
|
{
|
||||||
|
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||||
|
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||||
|
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def FILTER_SOURCE_FILES() -> list[str]:
|
def FILTER_SOURCE_FILES() -> list[str]:
|
||||||
"""Filter out api_pb2_dump.cpp when proto message dumping is not enabled,
|
files_to_filter = _define_filter()
|
||||||
user_services.cpp when no services are defined, and protocol-specific
|
|
||||||
implementations based on encryption configuration."""
|
|
||||||
files_to_filter: list[str] = []
|
|
||||||
|
|
||||||
# api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined
|
# api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined
|
||||||
# This is a particularly large file that still needs to be opened and read
|
# This is a particularly large file that still needs to be opened and read
|
||||||
@@ -846,21 +1008,4 @@ def FILTER_SOURCE_FILES() -> list[str]:
|
|||||||
if get_logger_level() != "VERY_VERBOSE":
|
if get_logger_level() != "VERY_VERBOSE":
|
||||||
files_to_filter.append("api_pb2_dump.cpp")
|
files_to_filter.append("api_pb2_dump.cpp")
|
||||||
|
|
||||||
# user_services.cpp is only needed when services are defined
|
|
||||||
config = CORE.config.get(DOMAIN, {})
|
|
||||||
if config and not config.get(CONF_ACTIONS) and not config[CONF_CUSTOM_SERVICES]:
|
|
||||||
files_to_filter.append("user_services.cpp")
|
|
||||||
|
|
||||||
# Filter protocol-specific implementations based on encryption configuration
|
|
||||||
encryption_config = config.get(CONF_ENCRYPTION) if config else None
|
|
||||||
|
|
||||||
# If encryption is not configured at all, we only need plaintext
|
|
||||||
if encryption_config is None:
|
|
||||||
files_to_filter.append("api_frame_helper_noise.cpp")
|
|
||||||
# If encryption is configured with a key, we only need noise
|
|
||||||
elif encryption_config.get(CONF_KEY):
|
|
||||||
files_to_filter.append("api_frame_helper_plaintext.cpp")
|
|
||||||
# If encryption is configured but no key is provided, we need both
|
|
||||||
# (this allows a plaintext client to provide a noise key)
|
|
||||||
|
|
||||||
return files_to_filter
|
return files_to_filter
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ enum SerialProxyPortType {
|
|||||||
message SerialProxyInfo {
|
message SerialProxyInfo {
|
||||||
string name = 1; // Human-readable port name
|
string name = 1; // Human-readable port name
|
||||||
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
|
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
|
||||||
|
uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceInfoResponse max_data_length values:
|
// DeviceInfoResponse max_data_length values:
|
||||||
@@ -1002,8 +1003,12 @@ message GetTimeResponse {
|
|||||||
option (no_delay) = true;
|
option (no_delay) = true;
|
||||||
|
|
||||||
fixed32 epoch_seconds = 1;
|
fixed32 epoch_seconds = 1;
|
||||||
string timezone = 2;
|
// Deprecated in 2026.9.0: clients still send this string for older firmware,
|
||||||
ParsedTimezone parsed_timezone = 3;
|
// but new firmware only reads parsed_timezone. Clients older than Home
|
||||||
|
// Assistant 2026.3.0 that send only the string leave the device on its
|
||||||
|
// codegen-configured timezone (or UTC).
|
||||||
|
string timezone = 2 [deprecated = true];
|
||||||
|
ParsedTimezone parsed_timezone = 3 [(track_presence) = true];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== USER-DEFINES SERVICES ====================
|
// ==================== USER-DEFINES SERVICES ====================
|
||||||
@@ -1029,6 +1034,8 @@ message ListEntitiesServicesArgument {
|
|||||||
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
|
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
|
||||||
string name = 1;
|
string name = 1;
|
||||||
ServiceArgType type = 2;
|
ServiceArgType type = 2;
|
||||||
|
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
|
||||||
|
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
|
||||||
}
|
}
|
||||||
message ListEntitiesServicesResponse {
|
message ListEntitiesServicesResponse {
|
||||||
option (id) = 41;
|
option (id) = 41;
|
||||||
@@ -1039,6 +1046,7 @@ message ListEntitiesServicesResponse {
|
|||||||
fixed32 key = 2 [(force) = true];
|
fixed32 key = 2 [(force) = true];
|
||||||
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
|
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
|
||||||
SupportsResponseType supports_response = 4;
|
SupportsResponseType supports_response = 4;
|
||||||
|
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
|
||||||
}
|
}
|
||||||
message ExecuteServiceArgument {
|
message ExecuteServiceArgument {
|
||||||
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
|
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
|
||||||
@@ -1649,7 +1657,8 @@ message ListEntitiesMediaPlayerResponse {
|
|||||||
bool disabled_by_default = 6;
|
bool disabled_by_default = 6;
|
||||||
EntityCategory entity_category = 7;
|
EntityCategory entity_category = 7;
|
||||||
|
|
||||||
bool supports_pause = 8;
|
// Deprecated in ESPHome 2026.9.0; use feature_flags instead.
|
||||||
|
bool supports_pause = 8 [deprecated = true];
|
||||||
|
|
||||||
repeated MediaPlayerSupportedFormat supported_formats = 9;
|
repeated MediaPlayerSupportedFormat supported_formats = 9;
|
||||||
|
|
||||||
@@ -2622,6 +2631,22 @@ message ZWaveProxyRequest {
|
|||||||
bytes data = 2;
|
bytes data = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ZWaveProxyStatus {
|
||||||
|
ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
|
||||||
|
ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
|
||||||
|
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
|
||||||
|
message ZWaveProxyRequestResponse {
|
||||||
|
option (id) = 151;
|
||||||
|
option (source) = SOURCE_SERVER;
|
||||||
|
option (ifdef) = "USE_ZWAVE_PROXY";
|
||||||
|
|
||||||
|
ZWaveProxyRequestType type = 1; // Which request type this responds to
|
||||||
|
ZWaveProxyStatus status = 2; // Result status
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== INFRARED ====================
|
// ==================== INFRARED ====================
|
||||||
// Note: Feature and capability flag enums are defined in
|
// Note: Feature and capability flag enums are defined in
|
||||||
// esphome/components/infrared/infrared.h
|
// esphome/components/infrared/infrared.h
|
||||||
@@ -2765,12 +2790,18 @@ message SerialProxyGetModemPinsResponse {
|
|||||||
|
|
||||||
uint32 instance = 1; // Instance index (0-based)
|
uint32 instance = 1; // Instance index (0-based)
|
||||||
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
|
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
|
||||||
|
SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SerialProxyRequestType {
|
enum SerialProxyRequestType {
|
||||||
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
|
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
|
||||||
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
|
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
|
||||||
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
|
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
|
||||||
|
// Values below are only valid in SerialProxyRequestResponse.type, identifying which
|
||||||
|
// operation is being acknowledged. Sending them in SerialProxyRequest.type is an
|
||||||
|
// error the device answers with INVALID_ARGUMENT.
|
||||||
|
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
|
||||||
|
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SerialProxyStatus {
|
enum SerialProxyStatus {
|
||||||
@@ -2779,6 +2810,8 @@ enum SerialProxyStatus {
|
|||||||
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
|
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
|
||||||
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
|
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
|
||||||
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
|
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
|
||||||
|
SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
|
||||||
|
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic request message for simple serial proxy operations
|
// Generic request message for simple serial proxy operations
|
||||||
@@ -2791,7 +2824,9 @@ message SerialProxyRequest {
|
|||||||
SerialProxyRequestType type = 2; // Request type
|
SerialProxyRequestType type = 2; // Request type
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response to a SerialProxyRequest (e.g. flush completion or failure)
|
// Acknowledges a serial proxy operation; the type field identifies which
|
||||||
|
// operation is being acknowledged. Flush has been acknowledged since the
|
||||||
|
// message was introduced; all other acknowledgements are sent since API 1.16.
|
||||||
message SerialProxyRequestResponse {
|
message SerialProxyRequestResponse {
|
||||||
option (id) = 147;
|
option (id) = 147;
|
||||||
option (source) = SOURCE_SERVER;
|
option (source) = SOURCE_SERVER;
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
#include "api_buffer.h"
|
#include "api_buffer.h"
|
||||||
|
#include <new>
|
||||||
|
|
||||||
namespace esphome::api {
|
namespace esphome::api {
|
||||||
|
|
||||||
void APIBuffer::grow_(size_t n) {
|
bool APIBuffer::grow_(size_t n) {
|
||||||
auto new_data = make_buffer(n);
|
// nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
|
||||||
|
// (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
|
||||||
|
// RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
|
||||||
|
std::unique_ptr<uint8_t[]> new_data(new (std::nothrow) uint8_t[n]);
|
||||||
|
if (new_data == nullptr)
|
||||||
|
return false;
|
||||||
if (this->size_)
|
if (this->size_)
|
||||||
std::memcpy(new_data.get(), this->data_.get(), this->size_);
|
std::memcpy(new_data.get(), this->data_.get(), this->size_);
|
||||||
this->data_ = std::move(new_data);
|
this->data_ = std::move(new_data);
|
||||||
this->capacity_ = n;
|
this->capacity_ = n;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace esphome::api
|
} // namespace esphome::api
|
||||||
|
|||||||
@@ -9,16 +9,6 @@
|
|||||||
|
|
||||||
namespace esphome::api {
|
namespace esphome::api {
|
||||||
|
|
||||||
/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
|
|
||||||
/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
|
|
||||||
inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
|
||||||
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
|
|
||||||
return std::make_unique<uint8_t[]>(n);
|
|
||||||
#else
|
|
||||||
return std::make_unique_for_overwrite<uint8_t[]>(n);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Byte buffer that skips zero-initialization on resize().
|
/// Byte buffer that skips zero-initialization on resize().
|
||||||
///
|
///
|
||||||
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
|
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
|
||||||
@@ -36,23 +26,23 @@ inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
|||||||
class APIBuffer {
|
class APIBuffer {
|
||||||
public:
|
public:
|
||||||
void clear() { this->size_ = 0; }
|
void clear() { this->size_ = 0; }
|
||||||
inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
|
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||||
if (n > this->capacity_)
|
[[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
|
||||||
this->grow_(n);
|
/// Returns false if allocation fails; the buffer is left unchanged. No zero-fill.
|
||||||
}
|
[[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); }
|
||||||
inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
|
|
||||||
this->reserve(n);
|
|
||||||
this->size_ = n; // no zero-fill
|
|
||||||
}
|
|
||||||
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
|
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
|
||||||
/// Single grow_ check regardless of argument order.
|
/// Single grow_ check regardless of argument order.
|
||||||
inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||||
this->reserve(std::max(reserve_size, new_size));
|
[[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
||||||
|
if (!this->reserve(std::max(reserve_size, new_size)))
|
||||||
|
return false;
|
||||||
this->size_ = new_size;
|
this->size_ = new_size;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
uint8_t *data() { return this->data_.get(); }
|
uint8_t *data() { return this->data_.get(); }
|
||||||
const uint8_t *data() const { return this->data_.get(); }
|
const uint8_t *data() const { return this->data_.get(); }
|
||||||
size_t size() const { return this->size_; }
|
size_t size() const { return this->size_; }
|
||||||
|
size_t capacity() const { return this->capacity_; }
|
||||||
bool empty() const { return this->size_ == 0; }
|
bool empty() const { return this->size_ == 0; }
|
||||||
uint8_t &operator[](size_t i) { return this->data_[i]; }
|
uint8_t &operator[](size_t i) { return this->data_[i]; }
|
||||||
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
|
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
|
||||||
@@ -64,7 +54,7 @@ class APIBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void grow_(size_t n);
|
bool grow_(size_t n);
|
||||||
std::unique_ptr<uint8_t[]> data_;
|
std::unique_ptr<uint8_t[]> data_;
|
||||||
size_t size_{0};
|
size_t size_{0};
|
||||||
size_t capacity_{0};
|
size_t capacity_{0};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "api_connection.h"
|
#include "api_connection.h"
|
||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
#include "api_frame_helper_noise.h"
|
#include "api_frame_helper_noise.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
|
|||||||
#else
|
#else
|
||||||
#error "No frame helper defined"
|
#error "No frame helper defined"
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_CAMERA
|
|
||||||
if (camera::Camera::instance() != nullptr) {
|
|
||||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIConnection::start() {
|
void APIConnection::start() {
|
||||||
@@ -1104,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
|
|||||||
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
|
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
|
||||||
ListEntitiesMediaPlayerResponse msg;
|
ListEntitiesMediaPlayerResponse msg;
|
||||||
auto traits = media_player->get_traits();
|
auto traits = media_player->get_traits();
|
||||||
msg.supports_pause = traits.get_supports_pause();
|
|
||||||
msg.feature_flags = traits.get_feature_flags();
|
msg.feature_flags = traits.get_feature_flags();
|
||||||
for (auto &supported_format : traits.get_supported_formats()) {
|
for (auto &supported_format : traits.get_supported_formats()) {
|
||||||
msg.supported_formats.emplace_back();
|
msg.supported_formats.emplace_back();
|
||||||
@@ -1140,6 +1134,7 @@ void APIConnection::try_send_camera_image_() {
|
|||||||
if (!this->image_reader_)
|
if (!this->image_reader_)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const auto *cam = camera::Camera::instance();
|
||||||
// Send as many chunks as possible without blocking
|
// Send as many chunks as possible without blocking
|
||||||
while (this->image_reader_->available()) {
|
while (this->image_reader_->available()) {
|
||||||
if (!this->helper_->can_write_without_blocking())
|
if (!this->helper_->can_write_without_blocking())
|
||||||
@@ -1149,11 +1144,11 @@ void APIConnection::try_send_camera_image_() {
|
|||||||
bool done = this->image_reader_->available() == to_send;
|
bool done = this->image_reader_->available() == to_send;
|
||||||
|
|
||||||
CameraImageResponse msg;
|
CameraImageResponse msg;
|
||||||
msg.key = camera::Camera::instance()->get_object_id_hash();
|
msg.key = cam->get_object_id_hash();
|
||||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||||
msg.done = done;
|
msg.done = done;
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
msg.device_id = camera::Camera::instance()->get_device_id();
|
msg.device_id = cam->get_device_id();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (!this->send_message(msg)) {
|
if (!this->send_message(msg)) {
|
||||||
@@ -1169,15 +1164,19 @@ void APIConnection::try_send_camera_image_() {
|
|||||||
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
||||||
if (!this->flags_.state_subscription)
|
if (!this->flags_.state_subscription)
|
||||||
return;
|
return;
|
||||||
if (!this->image_reader_)
|
if (this->image_reader_ && this->image_reader_->available())
|
||||||
return;
|
return;
|
||||||
if (this->image_reader_->available())
|
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
|
||||||
return;
|
return;
|
||||||
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
|
if (!this->image_reader_) {
|
||||||
this->image_reader_->set_image(std::move(image));
|
// Created on the first image this connection will send, so connections
|
||||||
// Try to send immediately to reduce latency
|
// that never receive one never pay for a reader. Only a registered
|
||||||
this->try_send_camera_image_();
|
// camera's listener can reach this, so instance() is non-null here.
|
||||||
|
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||||
}
|
}
|
||||||
|
this->image_reader_->set_image(std::move(image));
|
||||||
|
// Try to send immediately to reduce latency
|
||||||
|
this->try_send_camera_image_();
|
||||||
}
|
}
|
||||||
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||||
auto *camera = static_cast<camera::Camera *>(entity);
|
auto *camera = static_cast<camera::Camera *>(entity);
|
||||||
@@ -1204,31 +1203,28 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
|
|||||||
if (homeassistant::global_homeassistant_time != nullptr) {
|
if (homeassistant::global_homeassistant_time != nullptr) {
|
||||||
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
||||||
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
|
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
|
||||||
if (!value.timezone.empty()) {
|
// Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0
|
||||||
// Check if the sender provided pre-parsed timezone data.
|
// and newer); field presence distinguishes a genuine all-zero UTC timezone from an
|
||||||
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
|
// absent field. Older clients send only the deprecated timezone string, which is no
|
||||||
// For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
|
// longer decoded; for them the device keeps its codegen-configured timezone.
|
||||||
|
if (value.has_parsed_timezone) {
|
||||||
const auto &pt = value.parsed_timezone;
|
const auto &pt = value.parsed_timezone;
|
||||||
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
|
time::ParsedTimezone tz{};
|
||||||
time::ParsedTimezone tz{};
|
tz.std_offset_seconds = pt.std_offset_seconds;
|
||||||
tz.std_offset_seconds = pt.std_offset_seconds;
|
tz.dst_offset_seconds = pt.dst_offset_seconds;
|
||||||
tz.dst_offset_seconds = pt.dst_offset_seconds;
|
tz.dst_start.time_seconds = pt.dst_start.time_seconds;
|
||||||
tz.dst_start.time_seconds = pt.dst_start.time_seconds;
|
tz.dst_start.day = static_cast<uint16_t>(pt.dst_start.day);
|
||||||
tz.dst_start.day = static_cast<uint16_t>(pt.dst_start.day);
|
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
|
||||||
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
|
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
|
||||||
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
|
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
|
||||||
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
|
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
|
||||||
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
|
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
|
||||||
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
|
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
|
||||||
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
|
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
|
||||||
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
|
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
|
||||||
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
|
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
|
||||||
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
|
tz.dst_end.day_of_week = static_cast<uint8_t>(pt.dst_end.day_of_week);
|
||||||
tz.dst_end.day_of_week = static_cast<uint8_t>(pt.dst_end.day_of_week);
|
time::set_global_tz(tz);
|
||||||
time::set_global_tz(tz);
|
|
||||||
} else {
|
|
||||||
homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -1384,7 +1380,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||||
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
|
ZWaveProxyRequestResponse resp{};
|
||||||
|
resp.type = msg.type;
|
||||||
|
resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
|
||||||
|
if (!this->send_message(resp)) {
|
||||||
|
API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -1553,15 +1554,50 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_SERIAL_PROXY
|
#ifdef USE_SERIAL_PROXY
|
||||||
|
static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
|
||||||
|
switch (result) {
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_OK;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_TIMEOUT;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||||
|
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
|
||||||
|
return enums::SERIAL_PROXY_STATUS_ERROR;
|
||||||
|
}
|
||||||
|
return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
|
||||||
|
enums::SerialProxyStatus status) {
|
||||||
|
SerialProxyRequestResponse resp{};
|
||||||
|
resp.instance = instance;
|
||||||
|
resp.type = type;
|
||||||
|
resp.status = status;
|
||||||
|
if (!conn->send_message(resp)) {
|
||||||
|
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
|
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
|
||||||
auto &proxies = App.get_serial_proxies();
|
auto &proxies = App.get_serial_proxies();
|
||||||
if (msg.instance >= proxies.size()) {
|
if (msg.instance >= proxies.size()) {
|
||||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
|
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
|
||||||
static_cast<uint32_t>(proxies.size()));
|
static_cast<uint32_t>(proxies.size()));
|
||||||
|
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
|
||||||
|
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
|
serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
|
||||||
msg.stop_bits, msg.data_size);
|
this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, msg.data_size);
|
||||||
|
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
|
||||||
|
serial_proxy_result_to_status(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
|
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
|
||||||
@@ -1577,20 +1613,30 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
|
|||||||
auto &proxies = App.get_serial_proxies();
|
auto &proxies = App.get_serial_proxies();
|
||||||
if (msg.instance >= proxies.size()) {
|
if (msg.instance >= proxies.size()) {
|
||||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||||
|
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
|
||||||
|
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
|
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
|
||||||
|
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
|
||||||
|
serial_proxy_result_to_status(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
|
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
|
||||||
auto &proxies = App.get_serial_proxies();
|
auto &proxies = App.get_serial_proxies();
|
||||||
if (msg.instance >= proxies.size()) {
|
|
||||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SerialProxyGetModemPinsResponse resp{};
|
SerialProxyGetModemPinsResponse resp{};
|
||||||
resp.instance = msg.instance;
|
resp.instance = msg.instance;
|
||||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
if (msg.instance >= proxies.size()) {
|
||||||
|
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||||
|
// Pre-1.16 clients do not read the status field and would take this error
|
||||||
|
// for a successful "both pins deasserted" answer; let them time out as before
|
||||||
|
if (!this->client_supports_api_version(1, 16)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||||
|
} else {
|
||||||
|
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||||
|
}
|
||||||
if (!this->send_message(resp)) {
|
if (!this->send_message(resp)) {
|
||||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||||
}
|
}
|
||||||
@@ -1600,40 +1646,31 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
|||||||
auto &proxies = App.get_serial_proxies();
|
auto &proxies = App.get_serial_proxies();
|
||||||
if (msg.instance >= proxies.size()) {
|
if (msg.instance >= proxies.size()) {
|
||||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||||
|
send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
auto *proxy = proxies[msg.instance];
|
||||||
|
enums::SerialProxyStatus status;
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
|
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
|
||||||
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
|
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
|
||||||
proxies[msg.instance]->serial_proxy_request(this, msg.type);
|
status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
|
||||||
break;
|
break;
|
||||||
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
|
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
|
||||||
SerialProxyRequestResponse resp{};
|
status = serial_proxy_result_to_status(proxy->flush_port(this));
|
||||||
resp.instance = msg.instance;
|
break;
|
||||||
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
|
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
|
||||||
switch (proxies[msg.instance]->flush_port()) {
|
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
|
// Response-only discriminators; never valid in a request
|
||||||
resp.status = enums::SERIAL_PROXY_STATUS_OK;
|
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||||
break;
|
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
|
|
||||||
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
|
|
||||||
break;
|
|
||||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
|
|
||||||
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
|
|
||||||
break;
|
|
||||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
|
|
||||||
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!this->send_message(resp)) {
|
|
||||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||||
|
status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
send_serial_proxy_ack(this, msg.instance, msg.type, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||||
@@ -1752,15 +1789,17 @@ void APIConnection::complete_authentication_() {
|
|||||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||||
// Copy client name with truncation if needed (set_client_name handles truncation)
|
// Copy client name with truncation if needed (set_client_name handles truncation)
|
||||||
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
||||||
this->client_api_version_major_ = msg.api_version_major;
|
this->client_api_version_major_ =
|
||||||
this->client_api_version_minor_ = msg.api_version_minor;
|
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_major, std::numeric_limits<uint8_t>::max()));
|
||||||
|
this->client_api_version_minor_ =
|
||||||
|
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_minor, std::numeric_limits<uint8_t>::max()));
|
||||||
char peername[socket::SOCKADDR_STR_LEN];
|
char peername[socket::SOCKADDR_STR_LEN];
|
||||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
|
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(),
|
||||||
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
||||||
|
|
||||||
HelloResponse resp;
|
HelloResponse resp;
|
||||||
resp.api_version_major = 1;
|
resp.api_version_major = 1;
|
||||||
resp.api_version_minor = 15;
|
resp.api_version_minor = 16;
|
||||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||||
resp.server_info = ESPHOME_VERSION_REF;
|
resp.server_info = ESPHOME_VERSION_REF;
|
||||||
resp.name = StringRef(App.get_name());
|
resp.name = StringRef(App.get_name());
|
||||||
@@ -1894,6 +1933,7 @@ bool APIConnection::send_device_info_response_() {
|
|||||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||||
info.name = StringRef(proxy->get_name());
|
info.name = StringRef(proxy->get_name());
|
||||||
info.port_type = proxy->get_port_type();
|
info.port_type = proxy->get_port_type();
|
||||||
|
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
@@ -1954,6 +1994,7 @@ bool APIConnection::send_device_capabilities_response_() {
|
|||||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||||
info.name = StringRef(proxy->get_name());
|
info.name = StringRef(proxy->get_name());
|
||||||
info.port_type = proxy->get_port_type();
|
info.port_type = proxy->get_port_type();
|
||||||
|
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return this->send_message(resp);
|
return this->send_message(resp);
|
||||||
@@ -2130,7 +2171,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
psk_t psk{};
|
noise::psk_t psk{};
|
||||||
if (msg.key_len == 0) {
|
if (msg.key_len == 0) {
|
||||||
if (this->parent_->clear_noise_psk(true)) {
|
if (this->parent_->clear_noise_psk(true)) {
|
||||||
resp.success = true;
|
resp.success = true;
|
||||||
@@ -2139,7 +2180,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
|||||||
}
|
}
|
||||||
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
|
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
|
||||||
ESP_LOGW(TAG, "Invalid encryption key length");
|
ESP_LOGW(TAG, "Invalid encryption key length");
|
||||||
} else if (APINoiseContext::is_all_zeros(psk)) {
|
} else if (noise::NoiseContext::is_all_zeros(psk)) {
|
||||||
// Accepting the reserved provisioning PSK would report success without
|
// Accepting the reserved provisioning PSK would report success without
|
||||||
// enabling encryption (or silently clear an existing key)
|
// enabling encryption (or silently clear an existing key)
|
||||||
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
|
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
|
||||||
@@ -2184,7 +2225,7 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn,
|
bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
|
||||||
const void *msg) {
|
const void *msg) {
|
||||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
|
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
|
||||||
@@ -2198,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M
|
|||||||
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] {
|
||||||
|
this->fatal_out_of_memory_();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||||
this->prepare_first_message_buffer(shared_buf, payload_size);
|
|
||||||
size_t write_start = shared_buf.size();
|
size_t write_start = shared_buf.size();
|
||||||
shared_buf.resize(write_start + payload_size);
|
#ifdef ESPHOME_DEBUG_API
|
||||||
|
assert(shared_buf.capacity() >= write_start + payload_size);
|
||||||
|
#endif
|
||||||
|
// Capacity reserved above, cannot fail
|
||||||
|
(void) shared_buf.resize(write_start + payload_size);
|
||||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||||
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
|
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
|
||||||
@@ -2213,7 +2261,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE
|
|||||||
APIConnection *conn, uint32_t remaining_size) {
|
APIConnection *conn, uint32_t remaining_size) {
|
||||||
return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
|
return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
|
||||||
}
|
}
|
||||||
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) {
|
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) {
|
||||||
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
|
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
|
||||||
|
|
||||||
if (!this->try_to_clear_buffer(!is_log_message)) {
|
if (!this->try_to_clear_buffer(!is_log_message)) {
|
||||||
@@ -2237,30 +2285,42 @@ void APIConnection::on_no_setup_connection() {
|
|||||||
this->on_fatal_error();
|
this->on_fatal_error();
|
||||||
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
|
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
|
||||||
}
|
}
|
||||||
|
void APIConnection::fatal_out_of_memory_() {
|
||||||
|
this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY);
|
||||||
|
}
|
||||||
void APIConnection::on_fatal_error() {
|
void APIConnection::on_fatal_error() {
|
||||||
// Don't close socket here - keep it open so getpeername() works for logging
|
// Don't close socket here - keep it open so getpeername() works for logging
|
||||||
// Socket will be closed when client is removed from the list in APIServer::loop()
|
// Socket will be closed when client is removed from the list in APIServer::loop()
|
||||||
this->flags_.remove = true;
|
this->flags_.remove = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||||
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
|
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
|
||||||
return this->schedule_batch_();
|
return this->schedule_batch_();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||||
uint8_t aux_data_index) {
|
uint8_t aux_data_index) {
|
||||||
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
|
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
|
||||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
// No local for the shared buffer here: keeping it live across
|
||||||
this->prepare_first_message_buffer(shared_buf, estimated_size);
|
// dispatch_message_ costs a register and spills message_type into the
|
||||||
|
// batching path's dedup loop (measured on x86 GCC -Os)
|
||||||
|
if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] {
|
||||||
|
this->fatal_out_of_memory_();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
|
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
|
||||||
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
|
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
|
||||||
this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) {
|
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
|
||||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||||
this->log_batch_item_(item);
|
this->log_batch_item_(item);
|
||||||
#endif
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// An OOM during the immediate attempt marks the connection for removal;
|
||||||
|
// don't queue more work (schedule_message_'s push_back may allocate again)
|
||||||
|
if (this->flags_.remove) [[unlikely]]
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
|
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
|
||||||
}
|
}
|
||||||
@@ -2310,7 +2370,11 @@ void APIConnection::process_batch_() {
|
|||||||
total_estimated_size = MAX_BATCH_PACKET_SIZE;
|
total_estimated_size = MAX_BATCH_PACKET_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size);
|
if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] {
|
||||||
|
this->fatal_out_of_memory_();
|
||||||
|
this->clear_batch_();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fast path for single message - buffer already allocated above
|
// Fast path for single message - buffer already allocated above
|
||||||
if (num_items == 1) {
|
if (num_items == 1) {
|
||||||
@@ -2325,8 +2389,11 @@ void APIConnection::process_batch_() {
|
|||||||
#endif
|
#endif
|
||||||
this->clear_batch_();
|
this->clear_batch_();
|
||||||
} else if (payload_size == 0) {
|
} else if (payload_size == 0) {
|
||||||
// Message too large to fit in available space
|
// payload_size == 0 with remove set means encoding hit OOM and the
|
||||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
// connection is being dropped; warn only for a genuinely oversized message
|
||||||
|
if (!this->flags_.remove) {
|
||||||
|
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||||
|
}
|
||||||
this->clear_batch_();
|
this->clear_batch_();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2389,8 +2456,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
|
|||||||
|
|
||||||
if (items_processed > 0) {
|
if (items_processed > 0) {
|
||||||
// Add footer space for the last message (for Noise protocol MAC)
|
// Add footer space for the last message (for Noise protocol MAC)
|
||||||
if (footer_size > 0) {
|
if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] {
|
||||||
shared_buf.resize(shared_buf.size() + footer_size);
|
this->fatal_out_of_memory_();
|
||||||
|
this->clear_batch_();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send all collected messages
|
// Send all collected messages
|
||||||
|
|||||||
@@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
bool is_marked_for_removal() const { return this->flags_.remove; }
|
bool is_marked_for_removal() const { return this->flags_.remove; }
|
||||||
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
|
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
|
||||||
|
|
||||||
// Get client API version for feature detection
|
// Get client API version for feature detection.
|
||||||
bool client_supports_api_version(uint16_t major, uint16_t minor) const {
|
// Stored versions saturate at 255 (see send_hello_response_), so requesting
|
||||||
|
// a minimum above that can never match.
|
||||||
|
bool client_supports_api_version(uint8_t major, uint8_t minor) const {
|
||||||
return this->client_api_version_major_ > major ||
|
return this->client_api_version_major_ > major ||
|
||||||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
|
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
|
||||||
}
|
}
|
||||||
@@ -350,22 +352,13 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
|
/// Clear the shared write buffer and reserve space for the first message.
|
||||||
shared_buf.clear();
|
/// Returns false if the allocation fails (out of memory).
|
||||||
// Reserve space for header padding + message + footer
|
/// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||||
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
|
[[nodiscard]] bool prepare_first_message_buffer(size_t header_padding, size_t total_size);
|
||||||
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
|
|
||||||
// Reserve full size but only set initial size to header padding
|
|
||||||
// so message encoding starts at the correct position
|
|
||||||
shared_buf.reserve_and_resize(total_size, header_padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience overload - computes frame overhead internally
|
// Convenience overload - computes frame overhead internally
|
||||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
|
[[nodiscard]] bool prepare_first_message_buffer(size_t payload_size);
|
||||||
const uint8_t header_padding = this->helper_->frame_header_padding();
|
|
||||||
const uint8_t footer_size = this->helper_->frame_footer_size();
|
|
||||||
this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool try_to_clear_buffer(bool log_out_of_space) {
|
bool try_to_clear_buffer(bool log_out_of_space) {
|
||||||
if (this->flags_.remove)
|
if (this->flags_.remove)
|
||||||
@@ -374,7 +367,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
return true;
|
return true;
|
||||||
return this->try_to_clear_buffer_slow_(log_out_of_space);
|
return this->try_to_clear_buffer_slow_(log_out_of_space);
|
||||||
}
|
}
|
||||||
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
|
bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type);
|
||||||
|
|
||||||
const char *get_name() const { return this->helper_->get_client_name(); }
|
const char *get_name() const { return this->helper_->get_client_name(); }
|
||||||
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
|
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
|
||||||
@@ -423,7 +416,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-template buffer management for send_message
|
// Non-template buffer management for send_message
|
||||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||||
|
|
||||||
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
||||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||||
@@ -664,10 +657,9 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
|
|
||||||
struct BatchItem {
|
struct BatchItem {
|
||||||
EntityBase *entity; // 4 bytes - Entity pointer
|
EntityBase *entity; // 4 bytes - Entity pointer
|
||||||
uint8_t message_type; // 1 byte - Message type for protocol and dispatch
|
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
|
||||||
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
|
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
|
||||||
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
|
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
|
||||||
// 1 byte padding
|
|
||||||
};
|
};
|
||||||
|
|
||||||
std::vector<BatchItem> items;
|
std::vector<BatchItem> items;
|
||||||
@@ -677,7 +669,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
// connections that do, buffers are released after initial sync anyway
|
// connections that do, buffers are released after initial sync anyway
|
||||||
|
|
||||||
// Add item to the batch (with deduplication)
|
// Add item to the batch (with deduplication)
|
||||||
void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||||
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
||||||
// Dedup: O(n) scan but optimized for RAM over performance
|
// Dedup: O(n) scan but optimized for RAM over performance
|
||||||
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
||||||
@@ -693,7 +685,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
||||||
}
|
}
|
||||||
// Add item to the front of the batch (for high priority messages like ping)
|
// Add item to the front of the batch (for high priority messages like ping)
|
||||||
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||||
// Swap to front avoids expensive vector::insert which shifts all elements
|
// Swap to front avoids expensive vector::insert which shifts all elements
|
||||||
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
|
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
|
||||||
if (this->items.size() > 1) {
|
if (this->items.size() > 1) {
|
||||||
@@ -758,13 +750,15 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
#endif
|
#endif
|
||||||
} flags_{}; // 2 bytes total
|
} flags_{}; // 2 bytes total
|
||||||
|
|
||||||
// 2-byte types immediately after flags_ (no padding between them)
|
// 2-byte type immediately after flags_ (no padding between them)
|
||||||
uint16_t client_api_version_major_{0};
|
uint16_t batch_message_type_{0}; // Current message type during batch encoding
|
||||||
uint16_t client_api_version_minor_{0};
|
|
||||||
// 1-byte types to fill remaining space before next 4-byte boundary
|
// 1-byte types to fill remaining space before next 4-byte boundary
|
||||||
|
// Client API versions are clamped to 255 on receive (see send_hello_response_)
|
||||||
|
uint8_t client_api_version_major_{0};
|
||||||
|
uint8_t client_api_version_minor_{0};
|
||||||
ActiveIterator active_iterator_{ActiveIterator::NONE};
|
ActiveIterator active_iterator_{ActiveIterator::NONE};
|
||||||
uint8_t batch_message_type_{0}; // Current message type during batch encoding
|
// Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes,
|
||||||
// Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
|
// aligned to 4-byte boundary
|
||||||
|
|
||||||
// Actual header size used by encode_to_buffer for the current message.
|
// Actual header size used by encode_to_buffer for the current message.
|
||||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||||
@@ -813,7 +807,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
|
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
|
||||||
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
|
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
|
||||||
// AND batch_delay = 0)
|
// AND batch_delay = 0)
|
||||||
inline bool should_send_immediately_(uint8_t message_type) const {
|
inline bool should_send_immediately_(uint16_t message_type) const {
|
||||||
return (
|
return (
|
||||||
#ifdef USE_UPDATE
|
#ifdef USE_UPDATE
|
||||||
message_type == UpdateStateResponse::MESSAGE_TYPE ||
|
message_type == UpdateStateResponse::MESSAGE_TYPE ||
|
||||||
@@ -827,11 +821,11 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
// Helper method to send a message either immediately or via batching
|
// Helper method to send a message either immediately or via batching
|
||||||
// Tries immediate send if should_send_immediately_() returns true and buffer has space
|
// Tries immediate send if should_send_immediately_() returns true and buffer has space
|
||||||
// Falls back to batching if immediate send fails or isn't applicable
|
// Falls back to batching if immediate send fails or isn't applicable
|
||||||
bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||||
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED);
|
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED);
|
||||||
|
|
||||||
// Helper function to schedule a deferred message with known message type
|
// Helper function to schedule a deferred message with known message type
|
||||||
bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||||
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) {
|
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) {
|
||||||
this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index);
|
this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index);
|
||||||
return this->schedule_batch_();
|
return this->schedule_batch_();
|
||||||
@@ -839,7 +833,7 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
|
|
||||||
// Helper function to schedule a high priority message at the front of the batch
|
// Helper function to schedule a high priority message at the front of the batch
|
||||||
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
|
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
|
||||||
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
|
bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size);
|
||||||
|
|
||||||
// Helper function to log client messages with name and peername
|
// Helper function to log client messages with name and peername
|
||||||
void log_client_(int level, const LogString *message);
|
void log_client_(int level, const LogString *message);
|
||||||
@@ -850,6 +844,9 @@ class APIConnection final : public APIServerConnectionBase {
|
|||||||
this->on_fatal_error();
|
this->on_fatal_error();
|
||||||
this->log_warning_(message, err);
|
this->log_warning_(message, err);
|
||||||
}
|
}
|
||||||
|
// Shared cold path for buffer allocation failures — noinline keeps the
|
||||||
|
// OOM handling out of the hot send paths
|
||||||
|
void __attribute__((noinline)) fatal_out_of_memory_();
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace esphome::api
|
} // namespace esphome::api
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
#include "esphome/core/defines.h"
|
#include "esphome/core/defines.h"
|
||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
|
|
||||||
// Inline APIConnection methods that need APIServer complete. Include this
|
// Inline APIConnection members that need APIServer complete. Include this
|
||||||
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
|
// instead of api_connection.h when calling them.
|
||||||
|
|
||||||
#include "api_connection.h"
|
#include "api_connection.h"
|
||||||
#include "api_server.h"
|
#include "api_server.h"
|
||||||
@@ -41,7 +41,10 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||||
shared_buf.resize(shared_buf.size() + to_add);
|
if (!shared_buf.resize(shared_buf.size() + to_add)) [[unlikely]] {
|
||||||
|
conn->fatal_out_of_memory_();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||||
|
|
||||||
@@ -50,5 +53,22 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
|||||||
|
|
||||||
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||||
|
|
||||||
|
inline bool APIConnection::prepare_first_message_buffer(size_t header_padding, size_t total_size) {
|
||||||
|
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||||
|
shared_buf.clear();
|
||||||
|
// Reserve space for header padding + message + footer
|
||||||
|
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
|
||||||
|
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
|
||||||
|
// Reserve full size but only set initial size to header padding
|
||||||
|
// so message encoding starts at the correct position
|
||||||
|
return shared_buf.reserve_and_resize(total_size, header_padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool APIConnection::prepare_first_message_buffer(size_t payload_size) {
|
||||||
|
const uint8_t header_padding = this->helper_->frame_header_padding();
|
||||||
|
const uint8_t footer_size = this->helper_->frame_footer_size();
|
||||||
|
return this->prepare_first_message_buffer(header_padding, payload_size + header_padding + footer_size);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace esphome::api
|
} // namespace esphome::api
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
|
|||||||
|
|
||||||
// Queue unsent data into overflow buffer
|
// Queue unsent data into overflow buffer
|
||||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
||||||
HELPER_LOG("Overflow buffer full, dropping connection");
|
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
|
||||||
this->state_ = State::FAILED;
|
this->state_ = State::FAILED;
|
||||||
return APIError::SOCKET_WRITE_FAILED;
|
return APIError::SOCKET_WRITE_FAILED;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ struct ReadPacketBuffer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Packed message info structure to minimize memory usage
|
// Packed message info structure to minimize memory usage
|
||||||
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
|
// message_type matches the wire formats: noise carries a fixed 16-bit type
|
||||||
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
|
// field, plaintext a type varint. The proto codegen caps message IDs at 16383
|
||||||
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
|
// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING.
|
||||||
struct MessageInfo {
|
struct MessageInfo {
|
||||||
uint16_t offset; // Offset in buffer where message starts
|
uint16_t offset; // Offset in buffer where message starts
|
||||||
uint16_t payload_size; // Size of the message payload
|
uint16_t payload_size; // Size of the message payload
|
||||||
uint8_t message_type; // Message type (0-255)
|
uint16_t message_type; // Message type (0-16383)
|
||||||
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
|
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
|
||||||
|
|
||||||
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
||||||
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
|
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ class APIFrameHelper {
|
|||||||
}
|
}
|
||||||
// Write a single protobuf message - the hot path (87-100% of all writes).
|
// Write a single protobuf message - the hot path (87-100% of all writes).
|
||||||
// Caller must ensure state is DATA before calling.
|
// Caller must ensure state is DATA before calling.
|
||||||
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
|
virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0;
|
||||||
// Write multiple protobuf messages in a single batched operation.
|
// Write multiple protobuf messages in a single batched operation.
|
||||||
// Caller must ensure state is DATA and messages is not empty.
|
// Caller must ensure state is DATA and messages is not empty.
|
||||||
// messages contains (message_type, offset, length) for each message in the buffer.
|
// messages contains (message_type, offset, length) for each message in the buffer.
|
||||||
@@ -187,15 +187,15 @@ class APIFrameHelper {
|
|||||||
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
|
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
|
||||||
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
|
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
|
||||||
// added, this should become a virtual method.
|
// added, this should become a virtual method.
|
||||||
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
|
uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const {
|
||||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||||
return this->frame_footer_size_
|
return this->frame_footer_size_
|
||||||
? this->frame_header_padding_
|
? this->frame_header_padding_
|
||||||
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
|
||||||
#elif defined(USE_API_NOISE)
|
#elif defined(USE_API_NOISE)
|
||||||
return this->frame_header_padding_;
|
return this->frame_header_padding_;
|
||||||
#else // USE_API_PLAINTEXT only
|
#else // USE_API_PLAINTEXT only
|
||||||
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
// Get the frame footer size required by this protocol
|
// Get the frame footer size required by this protocol
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
#include "api_connection.h" // For ClientInfo struct
|
#include "api_connection.h" // For ClientInfo struct
|
||||||
|
#include "esphome/components/noise/noise.h"
|
||||||
#include "esphome/core/application.h"
|
#include "esphome/core/application.h"
|
||||||
#include "esphome/core/entity_base.h"
|
#include "esphome/core/entity_base.h"
|
||||||
#include "esphome/core/hal.h"
|
|
||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
#include "proto.h"
|
#include "proto.h"
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
|
|
||||||
namespace esphome::api {
|
namespace esphome::api {
|
||||||
|
|
||||||
|
using noise::noise_err_to_logstr;
|
||||||
|
|
||||||
|
// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is
|
||||||
|
// also compiled in plaintext-only builds without the noise component; keep
|
||||||
|
// the two definitions from drifting apart.
|
||||||
|
static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE,
|
||||||
|
"api and noise component handshake size limits must match");
|
||||||
|
|
||||||
static const char *const TAG = "api.noise";
|
static const char *const TAG = "api.noise";
|
||||||
#ifdef USE_ESP8266
|
#ifdef USE_ESP8266
|
||||||
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
|
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
|
||||||
@@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
|
|||||||
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
|
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Convert a noise error code to a readable error
|
|
||||||
const LogString *noise_err_to_logstr(int err) {
|
|
||||||
if (err == NOISE_ERROR_NO_MEMORY)
|
|
||||||
return LOG_STR("NO_MEMORY");
|
|
||||||
if (err == NOISE_ERROR_UNKNOWN_ID)
|
|
||||||
return LOG_STR("UNKNOWN_ID");
|
|
||||||
if (err == NOISE_ERROR_UNKNOWN_NAME)
|
|
||||||
return LOG_STR("UNKNOWN_NAME");
|
|
||||||
if (err == NOISE_ERROR_MAC_FAILURE)
|
|
||||||
return LOG_STR("MAC_FAILURE");
|
|
||||||
if (err == NOISE_ERROR_NOT_APPLICABLE)
|
|
||||||
return LOG_STR("NOT_APPLICABLE");
|
|
||||||
if (err == NOISE_ERROR_SYSTEM)
|
|
||||||
return LOG_STR("SYSTEM");
|
|
||||||
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
|
|
||||||
return LOG_STR("REMOTE_KEY_REQUIRED");
|
|
||||||
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
|
|
||||||
return LOG_STR("LOCAL_KEY_REQUIRED");
|
|
||||||
if (err == NOISE_ERROR_PSK_REQUIRED)
|
|
||||||
return LOG_STR("PSK_REQUIRED");
|
|
||||||
if (err == NOISE_ERROR_INVALID_LENGTH)
|
|
||||||
return LOG_STR("INVALID_LENGTH");
|
|
||||||
if (err == NOISE_ERROR_INVALID_PARAM)
|
|
||||||
return LOG_STR("INVALID_PARAM");
|
|
||||||
if (err == NOISE_ERROR_INVALID_STATE)
|
|
||||||
return LOG_STR("INVALID_STATE");
|
|
||||||
if (err == NOISE_ERROR_INVALID_NONCE)
|
|
||||||
return LOG_STR("INVALID_NONCE");
|
|
||||||
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
|
|
||||||
return LOG_STR("INVALID_PRIVATE_KEY");
|
|
||||||
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
|
|
||||||
return LOG_STR("INVALID_PUBLIC_KEY");
|
|
||||||
if (err == NOISE_ERROR_INVALID_FORMAT)
|
|
||||||
return LOG_STR("INVALID_FORMAT");
|
|
||||||
if (err == NOISE_ERROR_INVALID_SIGNATURE)
|
|
||||||
return LOG_STR("INVALID_SIGNATURE");
|
|
||||||
return LOG_STR("UNKNOWN");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize the frame helper, returns OK if successful.
|
/// Initialize the frame helper, returns OK if successful.
|
||||||
APIError APINoiseFrameHelper::init() {
|
APIError APINoiseFrameHelper::init() {
|
||||||
APIError err = init_common_();
|
APIError err = init_common_();
|
||||||
@@ -99,7 +68,10 @@ APIError APINoiseFrameHelper::init() {
|
|||||||
|
|
||||||
// init prologue
|
// init prologue
|
||||||
size_t old_size = prologue_.size();
|
size_t old_size = prologue_.size();
|
||||||
prologue_.resize(old_size + PROLOGUE_INIT_LEN);
|
if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
|
||||||
|
state_ = State::FAILED;
|
||||||
|
return APIError::OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
#ifdef USE_ESP8266
|
#ifdef USE_ESP8266
|
||||||
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
|
||||||
#else
|
#else
|
||||||
@@ -194,9 +166,9 @@ APIError APINoiseFrameHelper::loop() {
|
|||||||
*/
|
*/
|
||||||
APIError APINoiseFrameHelper::try_read_frame_() {
|
APIError APINoiseFrameHelper::try_read_frame_() {
|
||||||
// read header
|
// read header
|
||||||
if (rx_header_buf_len_ < 3) {
|
if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) {
|
||||||
// no header information yet
|
// no header information yet
|
||||||
uint8_t to_read = 3 - rx_header_buf_len_;
|
uint8_t to_read = static_cast<uint8_t>(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_;
|
||||||
ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read);
|
ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read);
|
||||||
APIError err = handle_socket_read_result_(received);
|
APIError err = handle_socket_read_result_(received);
|
||||||
if (err != APIError::OK) {
|
if (err != APIError::OK) {
|
||||||
@@ -208,7 +180,7 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
|||||||
return APIError::WOULD_BLOCK;
|
return APIError::WOULD_BLOCK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rx_header_buf_[0] != 0x01) {
|
if (rx_header_buf_[0] != noise::FRAME_INDICATOR) {
|
||||||
state_ = State::FAILED;
|
state_ = State::FAILED;
|
||||||
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
|
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
|
||||||
return APIError::BAD_INDICATOR;
|
return APIError::BAD_INDICATOR;
|
||||||
@@ -233,7 +205,10 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
|||||||
// During handshake, rx_buf_.size() is used in prologue construction, so
|
// During handshake, rx_buf_.size() is used in prologue construction, so
|
||||||
// the buffer must be exactly msg_size to avoid prologue mismatch.)
|
// the buffer must be exactly msg_size to avoid prologue mismatch.)
|
||||||
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
|
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
|
||||||
this->rx_buf_.resize(alloc_size);
|
if (!this->rx_buf_.resize(alloc_size)) [[unlikely]] {
|
||||||
|
state_ = State::FAILED;
|
||||||
|
return APIError::OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
|
||||||
if (rx_buf_len_ < msg_size) {
|
if (rx_buf_len_ < msg_size) {
|
||||||
// more data to read
|
// more data to read
|
||||||
@@ -300,7 +275,10 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
|||||||
// Resize for: existing prologue + 2 size bytes + frame data
|
// Resize for: existing prologue + 2 size bytes + frame data
|
||||||
size_t old_size = this->prologue_.size();
|
size_t old_size = this->prologue_.size();
|
||||||
size_t rx_size = this->rx_buf_.size();
|
size_t rx_size = this->rx_buf_.size();
|
||||||
this->prologue_.resize(old_size + 2 + rx_size);
|
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
|
||||||
|
state_ = State::FAILED;
|
||||||
|
return APIError::OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
|
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
|
||||||
this->prologue_[old_size + 1] = (uint8_t) rx_size;
|
this->prologue_[old_size + 1] = (uint8_t) rx_size;
|
||||||
if (rx_size > 0) {
|
if (rx_size > 0) {
|
||||||
@@ -348,15 +326,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
|||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
APIError APINoiseFrameHelper::state_action_handshake_() {
|
APIError APINoiseFrameHelper::state_action_handshake_() {
|
||||||
int action = noise_handshakestate_get_action(this->handshake_);
|
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
|
||||||
if (action == NOISE_ACTION_READ_MESSAGE) {
|
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
|
||||||
return this->state_action_handshake_read_();
|
return this->state_action_handshake_read_();
|
||||||
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
|
} else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) {
|
||||||
return this->state_action_handshake_write_();
|
return this->state_action_handshake_write_();
|
||||||
}
|
}
|
||||||
// bad state for action
|
// bad state for action
|
||||||
this->state_ = State::FAILED;
|
this->state_ = State::FAILED;
|
||||||
HELPER_LOG("Bad action for handshake: %d", action);
|
HELPER_LOG("Bad action for handshake: %d", (int) action);
|
||||||
return APIError::HANDSHAKESTATE_BAD_STATE;
|
return APIError::HANDSHAKESTATE_BAD_STATE;
|
||||||
}
|
}
|
||||||
APIError APINoiseFrameHelper::state_action_handshake_read_() {
|
APIError APINoiseFrameHelper::state_action_handshake_read_() {
|
||||||
@@ -368,20 +346,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
|
|||||||
if (this->rx_buf_.empty()) {
|
if (this->rx_buf_.empty()) {
|
||||||
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
|
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
|
||||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||||
} else if (this->rx_buf_[0] != 0x00) {
|
} else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
|
||||||
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
|
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
|
||||||
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
|
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
|
||||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
NoiseBuffer mbuf;
|
int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
|
||||||
noise_buffer_init(mbuf);
|
|
||||||
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
|
|
||||||
int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
|
|
||||||
if (err != 0) {
|
if (err != 0) {
|
||||||
// Special handling for MAC failure
|
// Special handling for MAC failure
|
||||||
this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
|
this->send_explicit_handshake_reject_(noise::reject_reason_for(err));
|
||||||
: LOG_STR("Handshake error"));
|
|
||||||
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
|
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
|
||||||
APIError::HANDSHAKESTATE_READ_FAILED);
|
APIError::HANDSHAKESTATE_READ_FAILED);
|
||||||
}
|
}
|
||||||
@@ -390,18 +364,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
|
|||||||
}
|
}
|
||||||
APIError APINoiseFrameHelper::state_action_handshake_write_() {
|
APIError APINoiseFrameHelper::state_action_handshake_write_() {
|
||||||
uint8_t buffer[65];
|
uint8_t buffer[65];
|
||||||
NoiseBuffer mbuf;
|
size_t msg_len = 0;
|
||||||
noise_buffer_init(mbuf);
|
|
||||||
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
|
|
||||||
|
|
||||||
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
|
int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len);
|
||||||
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
|
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
|
||||||
APIError::HANDSHAKESTATE_WRITE_FAILED);
|
APIError::HANDSHAKESTATE_WRITE_FAILED);
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
buffer[0] = 0x00; // success
|
buffer[0] = noise::HANDSHAKE_STATUS_OK;
|
||||||
|
|
||||||
aerr = this->write_frame_(buffer, mbuf.size + 1);
|
aerr = this->write_frame_(buffer, msg_len + 1);
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
return this->check_handshake_finished_();
|
return this->check_handshake_finished_();
|
||||||
@@ -409,33 +381,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() {
|
|||||||
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
|
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
|
||||||
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
|
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
|
||||||
uint8_t data[32];
|
uint8_t data[32];
|
||||||
data[0] = 0x01; // failure
|
static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE,
|
||||||
|
"reject buffer must fit the MAC failure wire contract");
|
||||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
size_t data_size = noise::format_reject_payload(data, sizeof(data), reason);
|
||||||
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
|
|
||||||
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
|
|
||||||
reason_len = std::min(reason_len, sizeof(data) - 1);
|
|
||||||
if (reason_len > 0) {
|
|
||||||
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
// Normal memory access
|
|
||||||
const char *reason_str = LOG_STR_ARG(reason);
|
|
||||||
size_t reason_len = strlen(reason_str);
|
|
||||||
reason_len = std::min(reason_len, sizeof(data) - 1);
|
|
||||||
if (reason_len > 0) {
|
|
||||||
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
|
|
||||||
std::memcpy(data + 1, reason_str, reason_len);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
size_t data_size = reason_len + 1;
|
|
||||||
|
|
||||||
// temporarily remove failed state
|
// temporarily remove failed state
|
||||||
auto orig_state = state_;
|
auto orig_state = state_;
|
||||||
state_ = State::EXPLICIT_REJECT;
|
state_ = State::EXPLICIT_REJECT;
|
||||||
write_frame_(data, data_size);
|
APIError aerr = write_frame_(data, data_size);
|
||||||
state_ = orig_state;
|
if (aerr != APIError::OK) {
|
||||||
|
// Best effort; the reject reason is a diagnosis aid, not a protocol step
|
||||||
|
ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr);
|
||||||
|
}
|
||||||
|
if (state_ == State::EXPLICIT_REJECT) {
|
||||||
|
// write_frame_ may have moved the state to FAILED; keep that decision
|
||||||
|
state_ = orig_state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||||
APIError aerr = this->check_data_state_();
|
APIError aerr = this->check_data_state_();
|
||||||
@@ -490,14 +451,12 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
|||||||
}
|
}
|
||||||
// Encrypt a single noise message in place and return the encrypted frame length.
|
// Encrypt a single noise message in place and return the encrypted frame length.
|
||||||
// Returns APIError::OK on success.
|
// Returns APIError::OK on success.
|
||||||
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
|
||||||
uint16_t &encrypted_len_out) {
|
uint16_t &encrypted_len_out) {
|
||||||
// Write noise header
|
// The noise frame header is written after encryption, when the size is known
|
||||||
buf_start[0] = 0x01; // indicator
|
|
||||||
// buf_start[1], buf_start[2] to be set after encryption
|
|
||||||
|
|
||||||
// Write message header (to be encrypted)
|
// Write message header (to be encrypted)
|
||||||
constexpr uint8_t msg_offset = 3;
|
constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE;
|
||||||
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
|
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
|
||||||
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
|
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
|
||||||
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
|
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
|
||||||
@@ -515,26 +474,27 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
|
|||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
|
|
||||||
// Fill in the encrypted size
|
// Fill in the frame header now that the encrypted size is known
|
||||||
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
|
noise::write_frame_header(buf_start, static_cast<uint16_t>(mbuf.size));
|
||||||
buf_start[2] = static_cast<uint8_t>(mbuf.size);
|
|
||||||
|
|
||||||
encrypted_len_out = static_cast<uint16_t>(3 + mbuf.size); // indicator + size + encrypted data
|
encrypted_len_out = static_cast<uint16_t>(noise::FRAME_HEADER_SIZE + mbuf.size);
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
|
||||||
#ifdef ESPHOME_DEBUG_API
|
#ifdef ESPHOME_DEBUG_API
|
||||||
assert(this->state_ == State::DATA);
|
assert(this->state_ == State::DATA);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
APIBuffer *buf = buffer.get_buffer();
|
||||||
// Resize buffer to include footer space for Noise MAC
|
// Resize buffer to include footer space for Noise MAC
|
||||||
if (this->frame_footer_size_)
|
if (this->frame_footer_size_ && !buf->resize(buf->size() + this->frame_footer_size_)) [[unlikely]] {
|
||||||
buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
|
state_ = State::FAILED;
|
||||||
|
return APIError::OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
|
||||||
uint16_t payload_size =
|
uint16_t payload_size = static_cast<uint16_t>(buf->size() - HEADER_PADDING - this->frame_footer_size_);
|
||||||
static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
|
uint8_t *buf_start = buf->data();
|
||||||
uint8_t *buf_start = buffer.get_buffer()->data();
|
|
||||||
uint16_t encrypted_len;
|
uint16_t encrypted_len;
|
||||||
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
|
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
@@ -568,21 +528,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||||
uint8_t header[3];
|
uint8_t header[noise::FRAME_HEADER_SIZE];
|
||||||
header[0] = 0x01; // indicator
|
noise::write_frame_header(header, len);
|
||||||
header[1] = (uint8_t) (len >> 8);
|
|
||||||
header[2] = (uint8_t) len;
|
|
||||||
|
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
return this->write_raw_buf_(header, 3);
|
return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE);
|
||||||
}
|
}
|
||||||
struct iovec iov[2];
|
struct iovec iov[2];
|
||||||
iov[0].iov_base = header;
|
iov[0].iov_base = header;
|
||||||
iov[0].iov_len = 3;
|
iov[0].iov_len = noise::FRAME_HEADER_SIZE;
|
||||||
iov[1].iov_base = const_cast<uint8_t *>(data);
|
iov[1].iov_base = const_cast<uint8_t *>(data);
|
||||||
iov[1].iov_len = len;
|
iov[1].iov_len = len;
|
||||||
|
|
||||||
return this->write_raw_iov_(iov, 2, 3 + len);
|
return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initiate the data structures for the handshake.
|
/** Initiate the data structures for the handshake.
|
||||||
@@ -590,42 +548,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
|||||||
* @return 0 on success, -1 on error (check errno)
|
* @return 0 on success, -1 on error (check errno)
|
||||||
*/
|
*/
|
||||||
APIError APINoiseFrameHelper::init_handshake_() {
|
APIError APINoiseFrameHelper::init_handshake_() {
|
||||||
int err;
|
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
|
||||||
memset(&nid_, 0, sizeof(nid_));
|
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||||
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
|
|
||||||
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
|
|
||||||
nid_.pattern_id = NOISE_PATTERN_NN;
|
|
||||||
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
|
|
||||||
nid_.dh_id = NOISE_DH_CURVE25519;
|
|
||||||
nid_.prefix_id = NOISE_PREFIX_STANDARD;
|
|
||||||
nid_.hybrid_id = NOISE_DH_NONE;
|
|
||||||
nid_.hash_id = NOISE_HASH_SHA256;
|
|
||||||
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
|
|
||||||
|
|
||||||
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
|
|
||||||
APIError aerr =
|
|
||||||
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
return aerr;
|
return aerr;
|
||||||
|
// init copies the prologue into the handshakestate, so we can get rid of it now
|
||||||
const auto &psk = this->ctx_.get_psk();
|
|
||||||
err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size());
|
|
||||||
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"),
|
|
||||||
APIError::HANDSHAKESTATE_SETUP_FAILED);
|
|
||||||
if (aerr != APIError::OK)
|
|
||||||
return aerr;
|
|
||||||
|
|
||||||
err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size());
|
|
||||||
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
|
||||||
if (aerr != APIError::OK)
|
|
||||||
return aerr;
|
|
||||||
// set_prologue copies it into handshakestate, so we can get rid of it now
|
|
||||||
prologue_.release();
|
prologue_.release();
|
||||||
|
|
||||||
err = noise_handshakestate_start(handshake_);
|
|
||||||
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
|
||||||
if (aerr != APIError::OK)
|
|
||||||
return aerr;
|
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,15 +562,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
|
|||||||
assert(state_ == State::HANDSHAKE);
|
assert(state_ == State::HANDSHAKE);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
int action = noise_handshakestate_get_action(handshake_);
|
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
|
||||||
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
|
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ ||
|
||||||
|
action == noise::NoiseResponderHandshake::Action::ACTION_WRITE)
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
if (action != NOISE_ACTION_SPLIT) {
|
if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) {
|
||||||
state_ = State::FAILED;
|
state_ = State::FAILED;
|
||||||
HELPER_LOG("Bad action for handshake: %d", action);
|
HELPER_LOG("Bad action for handshake: %d", (int) action);
|
||||||
return APIError::HANDSHAKESTATE_BAD_STATE;
|
return APIError::HANDSHAKESTATE_BAD_STATE;
|
||||||
}
|
}
|
||||||
int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_);
|
// split() also frees the handshake state
|
||||||
|
int err = this->handshake_.split(send_cipher_, recv_cipher_);
|
||||||
APIError aerr =
|
APIError aerr =
|
||||||
handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED);
|
handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED);
|
||||||
if (aerr != APIError::OK)
|
if (aerr != APIError::OK)
|
||||||
@@ -651,17 +581,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
|
|||||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
|
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
|
||||||
|
|
||||||
HELPER_LOG("Handshake complete!");
|
HELPER_LOG("Handshake complete!");
|
||||||
noise_handshakestate_free(handshake_);
|
|
||||||
handshake_ = nullptr;
|
|
||||||
state_ = State::DATA;
|
state_ = State::DATA;
|
||||||
return APIError::OK;
|
return APIError::OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
APINoiseFrameHelper::~APINoiseFrameHelper() {
|
APINoiseFrameHelper::~APINoiseFrameHelper() {
|
||||||
if (handshake_ != nullptr) {
|
|
||||||
noise_handshakestate_free(handshake_);
|
|
||||||
handshake_ = nullptr;
|
|
||||||
}
|
|
||||||
if (send_cipher_ != nullptr) {
|
if (send_cipher_ != nullptr) {
|
||||||
noise_cipherstate_free(send_cipher_);
|
noise_cipherstate_free(send_cipher_);
|
||||||
send_cipher_ = nullptr;
|
send_cipher_ = nullptr;
|
||||||
@@ -672,16 +596,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
|
|
||||||
void noise_rand_bytes(void *output, size_t len) {
|
|
||||||
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
|
|
||||||
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
|
|
||||||
arch_restart();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace esphome::api
|
} // namespace esphome::api
|
||||||
#endif // USE_API_NOISE
|
#endif // USE_API_NOISE
|
||||||
#endif // USE_API
|
#endif // USE_API
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
#include "noise/protocol.h"
|
#include "noise/protocol.h"
|
||||||
#include "api_noise_context.h"
|
#include "esphome/components/noise/noise_handshake.h"
|
||||||
|
|
||||||
namespace esphome::api {
|
namespace esphome::api {
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
// Pos 1-2: encrypted payload size (16-bit big-endian)
|
// Pos 1-2: encrypted payload size (16-bit big-endian)
|
||||||
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
|
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
|
||||||
// Pos 7+: actual payload data
|
// Pos 7+: actual payload data
|
||||||
static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
|
static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len
|
||||||
|
|
||||||
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, APINoiseContext &ctx)
|
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, noise::NoiseContext &ctx)
|
||||||
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
|
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
|
||||||
frame_header_padding_ = HEADER_PADDING;
|
frame_header_padding_ = HEADER_PADDING;
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
#endif
|
#endif
|
||||||
APIError loop() override;
|
APIError loop() override;
|
||||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
|
||||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -44,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
APIError state_action_handshake_write_();
|
APIError state_action_handshake_write_();
|
||||||
APIError try_read_frame_();
|
APIError try_read_frame_();
|
||||||
APIError write_frame_(const uint8_t *data, uint16_t len);
|
APIError write_frame_(const uint8_t *data, uint16_t len);
|
||||||
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
|
||||||
uint16_t &encrypted_len_out);
|
uint16_t &encrypted_len_out);
|
||||||
APIError init_handshake_();
|
APIError init_handshake_();
|
||||||
APIError check_handshake_finished_();
|
APIError check_handshake_finished_();
|
||||||
@@ -52,25 +52,22 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
|||||||
APIError handle_handshake_frame_error_(APIError aerr);
|
APIError handle_handshake_frame_error_(APIError aerr);
|
||||||
APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err);
|
APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err);
|
||||||
|
|
||||||
// Pointers first (4 bytes each)
|
// Pointers first (4 bytes each; the handshake wrapper holds one pointer)
|
||||||
NoiseHandshakeState *handshake_{nullptr};
|
noise::NoiseResponderHandshake handshake_;
|
||||||
NoiseCipherState *send_cipher_{nullptr};
|
NoiseCipherState *send_cipher_{nullptr};
|
||||||
NoiseCipherState *recv_cipher_{nullptr};
|
NoiseCipherState *recv_cipher_{nullptr};
|
||||||
|
|
||||||
// Reference to noise context (4 bytes on 32-bit)
|
// Reference to noise context (4 bytes on 32-bit)
|
||||||
APINoiseContext &ctx_;
|
noise::NoiseContext &ctx_;
|
||||||
|
|
||||||
// Buffer for noise handshake prologue (released after handshake)
|
// Buffer for noise handshake prologue (released after handshake)
|
||||||
APIBuffer prologue_;
|
APIBuffer prologue_;
|
||||||
|
|
||||||
// NoiseProtocolId (size depends on implementation)
|
|
||||||
NoiseProtocolId nid_;
|
|
||||||
|
|
||||||
// Group small types together
|
// Group small types together
|
||||||
// Fixed-size header buffer for noise protocol:
|
// Fixed-size header buffer for noise protocol:
|
||||||
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
|
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
|
||||||
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
|
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
|
||||||
uint8_t rx_header_buf_[3];
|
uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
|
||||||
uint8_t rx_header_buf_len_ = 0;
|
uint8_t rx_header_buf_len_ = 0;
|
||||||
// 4 bytes total, no padding
|
// 4 bytes total, no padding
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "esphome/core/hal.h"
|
#include "esphome/core/hal.h"
|
||||||
#include "esphome/core/helpers.h"
|
#include "esphome/core/helpers.h"
|
||||||
#include "esphome/core/log.h"
|
#include "esphome/core/log.h"
|
||||||
|
#include "api_pb2.h"
|
||||||
#include "proto.h"
|
#include "proto.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cinttypes>
|
#include <cinttypes>
|
||||||
@@ -171,7 +172,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
|||||||
|
|
||||||
// Reserve space for body (+ null terminator so protobuf StringRef fields
|
// Reserve space for body (+ null terminator so protobuf StringRef fields
|
||||||
// can be safely null-terminated in-place after decode)
|
// can be safely null-terminated in-place after decode)
|
||||||
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
|
if (!this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR)) [[unlikely]] {
|
||||||
|
state_ = State::FAILED;
|
||||||
|
return APIError::OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
|
||||||
if (rx_buf_len_ < rx_header_parsed_len_) {
|
if (rx_buf_len_ < rx_header_parsed_len_) {
|
||||||
// more data to read
|
// more data to read
|
||||||
@@ -252,24 +256,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_
|
|||||||
*p = static_cast<uint8_t>(value);
|
*p = static_cast<uint8_t>(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
|
// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint
|
||||||
ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
|
// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this
|
||||||
if (varint_len == 2) {
|
// bound, write_plaintext_header's header_offset would underflow for the first
|
||||||
*p++ = static_cast<uint8_t>(value | 0x80);
|
// message in a batch and the header write would land outside the buffer.
|
||||||
*p = static_cast<uint8_t>(value >> 7);
|
static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING,
|
||||||
} else {
|
"HEADER_PADDING cannot fit the type varint of the largest message ID");
|
||||||
*p = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write plaintext header into pre-allocated padding before payload.
|
// Write plaintext header into pre-allocated padding before payload.
|
||||||
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
|
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
|
||||||
// actual header size for contiguous batch messages).
|
// actual header size for contiguous batch messages).
|
||||||
// Returns the total header length (indicator + varints).
|
// Returns the total header length (indicator + varints).
|
||||||
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
|
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
|
||||||
uint8_t message_type, uint8_t padding_size) {
|
uint16_t message_type, uint8_t padding_size) {
|
||||||
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
|
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
|
||||||
uint8_t type_varint_len = ProtoSize::varint8(message_type);
|
uint8_t type_varint_len = ProtoSize::varint16(message_type);
|
||||||
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
|
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
|
||||||
|
|
||||||
// The header is right-justified within the padding so it sits immediately before payload.
|
// The header is right-justified within the padding so it sits immediately before payload.
|
||||||
@@ -292,12 +293,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_
|
|||||||
|
|
||||||
// Encode varints directly into buffer using pre-computed lengths
|
// Encode varints directly into buffer using pre-computed lengths
|
||||||
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
|
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
|
||||||
encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
|
encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
|
||||||
|
|
||||||
return total_header_len;
|
return total_header_len;
|
||||||
}
|
}
|
||||||
|
|
||||||
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
|
||||||
#ifdef ESPHOME_DEBUG_API
|
#ifdef ESPHOME_DEBUG_API
|
||||||
assert(this->state_ == State::DATA);
|
assert(this->state_ == State::DATA);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
|||||||
// Plaintext header structure (worst case):
|
// Plaintext header structure (worst case):
|
||||||
// Pos 0: indicator (0x00)
|
// Pos 0: indicator (0x00)
|
||||||
// Pos 1-3: payload size varint (up to 3 bytes)
|
// Pos 1-3: payload size varint (up to 3 bytes)
|
||||||
// Pos 4-5: message type varint (up to 2 bytes)
|
// Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to
|
||||||
|
// 16383, enforced by the proto codegen)
|
||||||
// Pos 6+: actual payload data
|
// Pos 6+: actual payload data
|
||||||
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
|
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
|||||||
APIError init() override;
|
APIError init() override;
|
||||||
APIError loop() override;
|
APIError loop() override;
|
||||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
|
||||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
|
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <array>
|
|
||||||
#include <cstdint>
|
|
||||||
#include "esphome/core/defines.h"
|
|
||||||
|
|
||||||
namespace esphome::api {
|
|
||||||
|
|
||||||
#ifdef USE_API_NOISE
|
|
||||||
using psk_t = std::array<uint8_t, 32>;
|
|
||||||
|
|
||||||
class APINoiseContext {
|
|
||||||
public:
|
|
||||||
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
|
|
||||||
// doubles as the well-known provisioning PSK that unprovisioned devices
|
|
||||||
// accept for Noise handshakes (passive-sniffing protection only, no
|
|
||||||
// authentication). It is never a valid real key.
|
|
||||||
static bool is_all_zeros(const psk_t &psk) {
|
|
||||||
uint8_t acc = 0;
|
|
||||||
for (uint8_t b : psk) {
|
|
||||||
acc |= b;
|
|
||||||
}
|
|
||||||
return acc == 0;
|
|
||||||
}
|
|
||||||
void set_psk(psk_t psk) {
|
|
||||||
this->psk_ = psk;
|
|
||||||
this->has_psk_ = !is_all_zeros(psk);
|
|
||||||
}
|
|
||||||
const psk_t &get_psk() const { return this->psk_; }
|
|
||||||
bool has_psk() const { return this->has_psk_; }
|
|
||||||
|
|
||||||
protected:
|
|
||||||
psk_t psk_{};
|
|
||||||
bool has_psk_{false};
|
|
||||||
};
|
|
||||||
#endif // USE_API_NOISE
|
|
||||||
|
|
||||||
} // namespace esphome::api
|
|
||||||
@@ -116,4 +116,10 @@ extend google.protobuf.FieldOptions {
|
|||||||
// the per-byte loop when the upper bits are non-zero (the common case
|
// the per-byte loop when the upper bits are non-zero (the common case
|
||||||
// for real MAC addresses, since OUIs occupy the top 24 bits).
|
// for real MAC addresses, since OUIs occupy the top 24 bits).
|
||||||
optional bool mac_address = 50019 [default=false];
|
optional bool mac_address = 50019 [default=false];
|
||||||
|
|
||||||
|
// track_presence: Track whether this message-typed field was present on the wire.
|
||||||
|
// Generates a `bool has_<field>{false};` member on the decoding side that is set
|
||||||
|
// to true when the field arrives, so an all-default submessage can be told apart
|
||||||
|
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
|
||||||
|
optional bool track_presence = 50020 [default=false];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "api_overflow_buffer.h"
|
#include "api_overflow_buffer.h"
|
||||||
#ifdef USE_API
|
#ifdef USE_API
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
namespace esphome::api {
|
namespace esphome::api {
|
||||||
|
|
||||||
@@ -61,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
uint16_t buffer_size = total_len - skip;
|
uint16_t buffer_size = total_len - skip;
|
||||||
|
// nothrow: a failed allocation returns nullptr so the connection is dropped
|
||||||
|
// cleanly instead of plain new's crash or abort on OOM
|
||||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||||
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
|
auto *data = new (std::nothrow) uint8_t[buffer_size];
|
||||||
this->queue_[this->tail_] = entry;
|
if (data == nullptr)
|
||||||
|
return false;
|
||||||
|
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||||
|
auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
|
||||||
|
if (entry == nullptr) {
|
||||||
|
delete[] data;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
uint16_t to_skip = skip;
|
uint16_t to_skip = skip;
|
||||||
uint16_t write_pos = 0;
|
uint16_t write_pos = 0;
|
||||||
@@ -80,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish only after the copy completes so a half-built entry is never reachable
|
||||||
|
this->queue_[this->tail_] = entry;
|
||||||
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
|
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
|
||||||
this->count_++;
|
this->count_++;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class APIOverflowBuffer {
|
|||||||
|
|
||||||
/// Enqueue unsent IOV data into the backlog.
|
/// Enqueue unsent IOV data into the backlog.
|
||||||
/// Copies iov data starting at byte offset `skip` into a new entry.
|
/// Copies iov data starting at byte offset `skip` into a new entry.
|
||||||
/// Returns false if the queue is full (caller should fail the connection).
|
/// Returns false if the queue is full or allocation fails (caller should fail the connection).
|
||||||
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
|
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user