mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
438
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d530f5c56 | ||
|
|
5a78f636b9 | ||
|
|
53d504137b | ||
|
|
bb0546fc0b | ||
|
|
b43630cf8e | ||
|
|
c9664d4e42 | ||
|
|
39ea6a3220 | ||
|
|
f4a6d6f18b | ||
|
|
6c24d4aa43 | ||
|
|
2c4b1f3ed5 | ||
|
|
0df7b0b366 | ||
|
|
bd4b32873a | ||
|
|
9e580db434 | ||
|
|
87187c5391 | ||
|
|
60402614cd | ||
|
|
2727d35655 | ||
|
|
fb1ef29d54 | ||
|
|
7c0f63ea20 | ||
|
|
5d75828ceb | ||
|
|
bd2e8d184d | ||
|
|
3c7ce71d6f | ||
|
|
58e03a875f | ||
|
|
41a9fca881 | ||
|
|
ec58e864f5 | ||
|
|
375fbd829e | ||
|
|
4bae89bd4a | ||
|
|
897b2f16a9 | ||
|
|
93c8796ae8 | ||
|
|
654afb536e | ||
|
|
97154f9e5f | ||
|
|
c9e49b8e8f | ||
|
|
3ae52e21cf | ||
|
|
f2395f9001 | ||
|
|
3d248496ab | ||
|
|
34eca948c4 | ||
|
|
58815575ef | ||
|
|
28b012389d | ||
|
|
54f86537ad | ||
|
|
3284d93a33 | ||
|
|
01686a5f67 | ||
|
|
3b0c485701 | ||
|
|
e61d348f0c | ||
|
|
22e594bc62 | ||
|
|
061733672f | ||
|
|
b1f5da9561 | ||
|
|
db517059a3 | ||
|
|
0b40892775 | ||
|
|
8e6ed71e2a | ||
|
|
cf569702c4 | ||
|
|
2affc04e62 | ||
|
|
a648822027 | ||
|
|
c6244fa4b9 | ||
|
|
f6746b4977 | ||
|
|
f1f1a77151 | ||
|
|
ba1af627fd | ||
|
|
b82dfd7e64 | ||
|
|
7de2bcb584 | ||
|
|
d5d11e1077 | ||
|
|
ab66b20944 | ||
|
|
baf1682635 | ||
|
|
eea11834f8 | ||
|
|
1d86cc39e9 | ||
|
|
686252f151 | ||
|
|
f5c15f66a5 | ||
|
|
9d6b50320d | ||
|
|
b903c54be4 | ||
|
|
63b4a59588 | ||
|
|
adc6255fd4 | ||
|
|
b28e7a1357 | ||
|
|
e7ba9a5726 | ||
|
|
2586e78127 | ||
|
|
23f6dedb42 | ||
|
|
10f30ddb59 | ||
|
|
96a66f3ddd | ||
|
|
5bf8ceac66 | ||
|
|
4d42352ec5 | ||
|
|
479232b6c0 | ||
|
|
33cf262616 | ||
|
|
5e4990badc | ||
|
|
16568c0453 | ||
|
|
677c089614 | ||
|
|
6db2e781ce | ||
|
|
3296bccd3c | ||
|
|
0323fd36d4 | ||
|
|
1ba323e9a3 | ||
|
|
6f940a486b | ||
|
|
82cffe81f3 | ||
|
|
887e3c302f | ||
|
|
2560a4436e | ||
|
|
6dec759261 | ||
|
|
1c0d406cbb | ||
|
|
ae6ebd2d9d | ||
|
|
13a8efd694 | ||
|
|
678975b6b2 | ||
|
|
006f5f0fc9 | ||
|
|
cf96c53279 | ||
|
|
72413494b0 | ||
|
|
51dbd3a63f | ||
|
|
19561d44a2 | ||
|
|
1661f84deb | ||
|
|
f0035c1db6 | ||
|
|
b78f00a037 | ||
|
|
40809ffa10 | ||
|
|
e4d8913680 | ||
|
|
d234a4d51e | ||
|
|
3cc89a35a2 | ||
|
|
15aac378df | ||
|
|
0e04b230fe | ||
|
|
f3f79c301b | ||
|
|
923700dcc9 | ||
|
|
19dce8840d | ||
|
|
e040647530 | ||
|
|
ab5a4b4f04 | ||
|
|
cb1b4b0bd7 | ||
|
|
597a4e46b1 | ||
|
|
e68a499429 | ||
|
|
a56c85949a | ||
|
|
4eeb110247 | ||
|
|
194273955c | ||
|
|
490d478e54 | ||
|
|
1520d228bc | ||
|
|
61ac0cb753 | ||
|
|
2b02602a49 | ||
|
|
cad670785b | ||
|
|
980fed9e96 | ||
|
|
f670993fe9 | ||
|
|
fb20966353 | ||
|
|
74d0a90308 | ||
|
|
fef3804aa5 | ||
|
|
e66ef45df7 | ||
|
|
1f53ccb572 | ||
|
|
b63794b53c | ||
|
|
a69c648c6f | ||
|
|
29090d50a7 | ||
|
|
9b1a75b0ed | ||
|
|
b7ddd1daa2 | ||
|
|
6002c28a8d | ||
|
|
718b6651fe | ||
|
|
6191b7553e | ||
|
|
082a0b746a | ||
|
|
8d8bd73112 | ||
|
|
3a3707926d | ||
|
|
80dc93d6ce | ||
|
|
40af90ad25 | ||
|
|
4097f45677 | ||
|
|
039f07d332 | ||
|
|
cdcc7bbf74 | ||
|
|
9af9414dcc | ||
|
|
cf334e8758 | ||
|
|
888043762e | ||
|
|
7af96c8d36 | ||
|
|
a778baddca | ||
|
|
fc441646f4 | ||
|
|
4a47a984a0 | ||
|
|
8735aee847 | ||
|
|
c8eaf518ba | ||
|
|
bb8c23805a | ||
|
|
f334b9ea11 | ||
|
|
666cf169b9 | ||
|
|
a058937fe0 | ||
|
|
417fdd9dc7 | ||
|
|
bc388b1de5 | ||
|
|
9820896430 | ||
|
|
4258b226a4 | ||
|
|
5be0aca6d2 | ||
|
|
1110857ffa | ||
|
|
9a97419cfc | ||
|
|
ca74e71348 | ||
|
|
00303fb72b | ||
|
|
aa02a0996a | ||
|
|
59b49d34cc | ||
|
|
862047386c | ||
|
|
bbd05e4811 | ||
|
|
a02fa71dc8 | ||
|
|
99fd1bca3e | ||
|
|
2d73ea24ca | ||
|
|
645144ad4d | ||
|
|
88378ab354 | ||
|
|
f91ac5ed1d | ||
|
|
3b319240c6 | ||
|
|
d9b3f299e0 | ||
|
|
6f2d2a2001 | ||
|
|
8b9d51cf93 | ||
|
|
66eec7af15 | ||
|
|
60e511282a | ||
|
|
e4be4c7fcb | ||
|
|
99bf080092 | ||
|
|
0017b9c439 | ||
|
|
ad1b6ab37e | ||
|
|
acf7257293 | ||
|
|
b36c5441cb | ||
|
|
feedea4954 | ||
|
|
4f9cd7821a | ||
|
|
31e0a31c65 | ||
|
|
e09cab94d4 | ||
|
|
e708c85100 | ||
|
|
cfbbd13b86 | ||
|
|
8f0e032b5e | ||
|
|
0dc10d9f30 | ||
|
|
c4aa8fa9c5 | ||
|
|
9b83f37b78 | ||
|
|
a1a4474a01 | ||
|
|
3a06318b65 | ||
|
|
2f9e37d6a9 | ||
|
|
fe2e541dc8 | ||
|
|
474c2791fe | ||
|
|
7421b165e8 | ||
|
|
52fc5f2c75 | ||
|
|
4f539d3391 | ||
|
|
aad7907c37 | ||
|
|
fb8f679be9 | ||
|
|
84435e7d81 | ||
|
|
d0473438d9 | ||
|
|
2ee13d59ac | ||
|
|
bccdbc23c4 | ||
|
|
4b5375145a | ||
|
|
c6e3a5a165 | ||
|
|
63cb75726b | ||
|
|
b070c828b3 | ||
|
|
2d56559f04 | ||
|
|
8691a43879 | ||
|
|
f5c79eeec0 | ||
|
|
fcf8fe2c11 | ||
|
|
4447f8a065 | ||
|
|
911bd3dadb | ||
|
|
03f264775f | ||
|
|
fe5a155c13 | ||
|
|
25eaf1d025 | ||
|
|
5328e5f5e0 | ||
|
|
de0a0b2af4 | ||
|
|
a5178a0f3d | ||
|
|
483b9ebe2c | ||
|
|
3cc4df4b3a | ||
|
|
8a49ba7e98 | ||
|
|
b130c79267 | ||
|
|
b0d5681022 | ||
|
|
b15bc083f6 | ||
|
|
690227f6d8 | ||
|
|
46b12b43b2 | ||
|
|
ba9d54b8ad | ||
|
|
7169c0c930 | ||
|
|
e3fcf173c2 | ||
|
|
6d33b0401d | ||
|
|
78f39c7fee | ||
|
|
79ad67b56f | ||
|
|
b6c79fdc9a | ||
|
|
cbe89c940d | ||
|
|
4f1be0fcb2 | ||
|
|
25a30fc472 | ||
|
|
2d286031a4 | ||
|
|
995109aa4f | ||
|
|
5def4d46ca | ||
|
|
a531ea07a2 | ||
|
|
984de58853 | ||
|
|
09ba060fd5 | ||
|
|
d0eddc8fc8 | ||
|
|
bf176633b2 | ||
|
|
26398747a1 | ||
|
|
6491eaeaae | ||
|
|
7096546d83 | ||
|
|
1c31e9d28d | ||
|
|
f595d8677c | ||
|
|
416d0c46ac | ||
|
|
97235a20b8 | ||
|
|
61446e3e4f | ||
|
|
1440998f3c | ||
|
|
4c9a44301d | ||
|
|
54cb4c711a | ||
|
|
7b018b9dca | ||
|
|
65fef7d5d8 | ||
|
|
87d4bea24b | ||
|
|
d938bc9913 | ||
|
|
a27c73b44e | ||
|
|
0c0015ef88 | ||
|
|
4724aea42e | ||
|
|
d970e9292e | ||
|
|
797682cccc | ||
|
|
a58f3d34aa | ||
|
|
3fd8f00e97 | ||
|
|
12b016e3d2 | ||
|
|
8617368294 | ||
|
|
6479e079d2 | ||
|
|
ff97353767 | ||
|
|
b324a7c6e0 | ||
|
|
99070187b8 | ||
|
|
dff793a949 | ||
|
|
4075c15bea | ||
|
|
2d37c6f58b | ||
|
|
d49741dc10 | ||
|
|
8ac184228d | ||
|
|
96c6120849 | ||
|
|
f7b4ea9f0e | ||
|
|
7962b52d2a | ||
|
|
bfb7e76b2e | ||
|
|
0ed997da92 | ||
|
|
3ed1bc7e20 | ||
|
|
5d4b436fc0 | ||
|
|
1346342250 | ||
|
|
36bcce563a | ||
|
|
e500ece4d5 | ||
|
|
2de4f4cf81 | ||
|
|
e9f0a53ec9 | ||
|
|
3bae9e4ee2 | ||
|
|
788d2bc631 | ||
|
|
afba431600 | ||
|
|
5e67258818 | ||
|
|
879c5cfc5b | ||
|
|
b4cb164e5d | ||
|
|
af9c4cb778 | ||
|
|
852fb0c5a5 | ||
|
|
9a855b4377 | ||
|
|
eb423be9d2 | ||
|
|
54f6fb0eb2 | ||
|
|
826571518a | ||
|
|
56a7a11162 | ||
|
|
2accef437f | ||
|
|
5a3e95b161 | ||
|
|
7e32770c5a | ||
|
|
3dc608c772 | ||
|
|
5a11f8d7c3 | ||
|
|
94ed17815c | ||
|
|
6f5d7475f0 | ||
|
|
568c1ae494 | ||
|
|
cb47dfcf0e | ||
|
|
35474aae3d | ||
|
|
ea5832a410 | ||
|
|
1d69ddc09e | ||
|
|
e742e64fb0 | ||
|
|
52079dd3f3 | ||
|
|
3fac97867e | ||
|
|
6c43247395 | ||
|
|
e8c5cd56a8 | ||
|
|
9208804707 | ||
|
|
199cc08270 | ||
|
|
6121bb3bcf | ||
|
|
c2d830ebd3 | ||
|
|
a706fa9e6b | ||
|
|
08ed25833d | ||
|
|
3062e1f844 | ||
|
|
39f41656b9 | ||
|
|
987618b49e | ||
|
|
e7f2159f0e | ||
|
|
f327cbbcfe | ||
|
|
3cccdca771 | ||
|
|
5baad05926 | ||
|
|
e111f1130c | ||
|
|
4d534dc420 | ||
|
|
0fd0f72550 | ||
|
|
4a34315fc7 | ||
|
|
4a00cebd61 | ||
|
|
cd0a0f3fca | ||
|
|
23afdc1d9b | ||
|
|
ef4d06e0ea | ||
|
|
83d689d944 | ||
|
|
203a7f70a3 | ||
|
|
7d904b6330 | ||
|
|
ac1190094a | ||
|
|
ed88002c37 | ||
|
|
ef0819f983 | ||
|
|
7187acec04 | ||
|
|
25ddc94d5d | ||
|
|
5502f620d4 | ||
|
|
99c7f9e625 | ||
|
|
35d41adf86 | ||
|
|
662c7db222 | ||
|
|
6b465443bc | ||
|
|
fc270cb90f | ||
|
|
46be513a39 | ||
|
|
cc6828f34f | ||
|
|
0b2b16e664 | ||
|
|
a43d3382b5 | ||
|
|
b8ba8aa1a8 | ||
|
|
a0f933ea60 | ||
|
|
b6cd92e915 | ||
|
|
4618c73b05 | ||
|
|
1b86959ee0 | ||
|
|
1a9f43909a | ||
|
|
55a7340d06 | ||
|
|
b22f2e6b4c | ||
|
|
a7e0afeb46 | ||
|
|
5be41dec15 | ||
|
|
5fdb73aceb | ||
|
|
c1303c048b | ||
|
|
627832f7ba | ||
|
|
757a1c42f9 | ||
|
|
9b9d548ad5 | ||
|
|
eb9aae04d0 | ||
|
|
54a9238425 | ||
|
|
21c9604918 | ||
|
|
bf7aa1dc60 | ||
|
|
571cbb72eb | ||
|
|
8032542076 | ||
|
|
aba15f8ff6 | ||
|
|
7a4ffa98f6 | ||
|
|
eca4c2d0a5 | ||
|
|
43b6b41d16 | ||
|
|
b6d70d4e34 | ||
|
|
4af2db7e8a | ||
|
|
92059abf6c | ||
|
|
e6d124d229 | ||
|
|
0575eb14f2 | ||
|
|
c732903390 | ||
|
|
eaee1cdd86 | ||
|
|
964a54f7a7 | ||
|
|
3e5797d153 | ||
|
|
7e99301644 | ||
|
|
08387ad289 | ||
|
|
3f06553023 | ||
|
|
2d6a7dae54 | ||
|
|
30dc453dc1 | ||
|
|
89cc689864 | ||
|
|
13c27429a4 | ||
|
|
8cbc18d040 | ||
|
|
728449f69d | ||
|
|
7e7035db72 | ||
|
|
45f8dbb4da | ||
|
|
eb97d15f2e | ||
|
|
98754bedcd | ||
|
|
9e8044868c | ||
|
|
fc803fcc6d | ||
|
|
025cf1f638 | ||
|
|
45189ef445 | ||
|
|
d31ffd4ca3 | ||
|
|
4ec8fae5df | ||
|
|
c52ce65d1c | ||
|
|
1f08ea68e6 | ||
|
|
6c2a4d3999 | ||
|
|
446c47f7f8 | ||
|
|
787ee418b0 | ||
|
|
45b9c8b94c | ||
|
|
b4f2334b99 | ||
|
|
3fb4a9cb3a | ||
|
|
b56198f242 | ||
|
|
21d9aff769 | ||
|
|
b4b4000633 | ||
|
|
9f7d7284a2 | ||
|
|
4c23564851 | ||
|
|
b8252f56ea |
@@ -22,7 +22,7 @@ runs:
|
||||
# cached path cannot diverge.
|
||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
||||
. venv/bin/activate
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{t}")')
|
||||
[ -n "$key" ] || exit 1
|
||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache the native toolchain (write on dev)
|
||||
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
- "esphome/core/**"
|
||||
- "esphome/writer.py"
|
||||
- "esphome/build_gen/**"
|
||||
- "esphome/build_helpers/**"
|
||||
- "esphome/espidf/**"
|
||||
- "esphome/platformio/**"
|
||||
- "esphome/components/bk72xx/**"
|
||||
@@ -195,6 +196,7 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -217,5 +219,6 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
-e ESPHOME_LDGEN_STRICT=1 \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
# Pushes any fixes the hooks made back to the pull request. This step
|
||||
# must keep its default name: the GitHub App that performs the push
|
||||
# locates the workflow run by that name.
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
- uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0
|
||||
if: always()
|
||||
with:
|
||||
msg: apply automatic formatting fixes
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -893,14 +893,20 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 4/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 5/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
@@ -1225,9 +1231,28 @@ jobs:
|
||||
|
||||
if [ -n "$compile_csv" ]; then
|
||||
# Run compilation with grouping and isolation
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
|
||||
# The bootloader has no ESPHome code and these builds never
|
||||
# flash; the check_idf_py batch keeps the full build so the
|
||||
# native sub-build and the equivalence check stay covered.
|
||||
skip_flag="--skip-bootloader"
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
skip_flag=""
|
||||
fi
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
# The real idf.py must find nothing to configure or build in a
|
||||
# tree built above; catches drift on ESP-IDF bumps.
|
||||
echo "Checking the native ESP-IDF build matches idf.py"
|
||||
python3 script/check_idf_py_equivalence.py
|
||||
fi
|
||||
else
|
||||
echo "All components in this batch are validate-only -- skipping compile stage."
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
# determine-jobs and this step disagree on what compiles; fail
|
||||
# rather than let the check run nowhere.
|
||||
echo "::error::This batch was picked for the idf.py check but compiled nothing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Print ccache statistics
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -125,14 +125,20 @@ jobs:
|
||||
}
|
||||
|
||||
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
|
||||
const changedFiles = await github.rest.pulls.listFiles({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const esphomeChanges = changedFiles.data
|
||||
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
|
||||
// Files used only for development and CI, which do not affect use as an external component
|
||||
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
|
||||
const esphomeChanges = changedFiles
|
||||
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -144,7 +150,7 @@ jobs:
|
||||
}
|
||||
|
||||
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
|
||||
const componentChanges = changedFiles.data
|
||||
const componentChanges = changedFiles
|
||||
.filter(file => file.filename.startsWith('esphome/components/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check blocking labels
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.8
|
||||
rev: v0.16.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.3.0
|
||||
rev: 7.4.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
|
||||
+13
-1
@@ -187,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz
|
||||
esphome/components/esp32_rmt_led_strip/* @jesserockz
|
||||
esphome/components/esp8266/* @esphome/core
|
||||
esphome/components/esp_ldo/* @clydebarrow
|
||||
esphome/components/espectre/* @francescopace
|
||||
esphome/components/espnow/* @jesserockz
|
||||
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
|
||||
esphome/components/ethernet_info/* @gtjadsonsantos
|
||||
esphome/components/event/* @nohat
|
||||
esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
@@ -245,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
esphome/components/homeassistant/text/* @jesserockz
|
||||
esphome/components/honeywell_hih_i2c/* @Benichou34
|
||||
esphome/components/honeywellabp/* @RubyBailey
|
||||
esphome/components/honeywellabp2_i2c/* @jpfaff
|
||||
@@ -282,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci
|
||||
esphome/components/integration/* @OttoWinter
|
||||
esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_base/* @bdraco @kbx81
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
@@ -541,6 +547,7 @@ esphome/components/st7735/* @SenexCrenshaw
|
||||
esphome/components/st7789v/* @kbx81
|
||||
esphome/components/st7920/* @marsjan155
|
||||
esphome/components/statsd/* @Links2004
|
||||
esphome/components/stcc4/* @j9brown
|
||||
esphome/components/stts22h/* @B48D81EFCC
|
||||
esphome/components/substitutions/* @esphome/core
|
||||
esphome/components/sun/* @OttoWinter
|
||||
@@ -554,12 +561,14 @@ esphome/components/syslog/* @clydebarrow
|
||||
esphome/components/systa_bus/* @Mat931
|
||||
esphome/components/t6615/* @tylermenezes
|
||||
esphome/components/tas2780/* @remcom
|
||||
esphome/components/tas58xx/* @mrtoy-me @remcom
|
||||
esphome/components/tc74/* @sethgirvan
|
||||
esphome/components/tca9548a/* @andreashergert1984
|
||||
esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax
|
||||
esphome/components/teleinfo/* @0hax @FredM67
|
||||
esphome/components/tem3200/* @bakerkj
|
||||
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
|
||||
esphome/components/template/datetime/* @rfdarter
|
||||
@@ -599,11 +608,13 @@ esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
esphome/components/ultrasonic/* @ssieb @swoboda1337
|
||||
esphome/components/unicode/* @esphome/core
|
||||
esphome/components/update/* @jesserockz
|
||||
esphome/components/uponor_smatrix/* @kroimon
|
||||
esphome/components/usb_cdc_acm/* @kbx81
|
||||
@@ -647,6 +658,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51
|
||||
esphome/components/xiaomi_hhccjcy10/* @fariouche
|
||||
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
|
||||
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
|
||||
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
|
||||
esphome/components/xiaomi_mhoc303/* @drug123
|
||||
esphome/components/xiaomi_mhoc401/* @vevsvevs
|
||||
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.10.0-dev
|
||||
PROJECT_NUMBER = 2026.10.0b4
|
||||
|
||||
# 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
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.17.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -21,10 +21,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent cache root, not the
|
||||
# container's ephemeral user cache dir (re-downloaded on every restart).
|
||||
# Keep the native toolchain installs and compiler caches on the persistent
|
||||
# cache root, not the container's user cache dir: it is lost on every
|
||||
# restart, and not writable when the container runs as a non-root user.
|
||||
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
|
||||
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
|
||||
export ESPHOME_ARDUINO8266_PREFIX="$(dirname "${pio_cache_base}")/arduino8266"
|
||||
export ESPHOME_HOST_PREFIX="$(dirname "${pio_cache_base}")/host"
|
||||
export ESPHOME_PLATFORMIO_CCACHE_DIR="$(dirname "${pio_cache_base}")/platformio-ccache"
|
||||
|
||||
# If /build is mounted, use that as the build path
|
||||
# otherwise use path in /config (so that builds aren't lost on container restart)
|
||||
|
||||
@@ -15,10 +15,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent /data volume, not the
|
||||
# container's ephemeral user cache dir (wiped on every add-on update/restart).
|
||||
# Keep the native toolchain installs and compiler caches on the persistent
|
||||
# /data volume, not the container's ephemeral user cache dir (wiped on every
|
||||
# add-on update/restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
|
||||
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
|
||||
export ESPHOME_ARDUINO8266_PREFIX=/data/cache/arduino8266
|
||||
export ESPHOME_HOST_PREFIX=/data/cache/host
|
||||
export ESPHOME_PLATFORMIO_CCACHE_DIR=/data/cache/platformio-ccache
|
||||
|
||||
if bashio::config.true 'leave_front_door_open'; then
|
||||
export DISABLE_HA_AUTHENTICATION=true
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
@@ -3,5 +3,7 @@ esphome:
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
# The PlatformIO path stays covered whatever the default is
|
||||
toolchain: platformio
|
||||
|
||||
logger:
|
||||
|
||||
+87
-166
@@ -7,6 +7,7 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -18,7 +19,6 @@ from typing import TYPE_CHECKING, Protocol
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.build_helpers.native import analysis_backend, native_backend
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
@@ -36,7 +36,6 @@ from esphome.const import (
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
CONF_MQTT,
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
@@ -56,12 +55,13 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
from esphome.util import (
|
||||
ESPHOME_COMMAND,
|
||||
PICOTOOL_PACKAGE,
|
||||
FlashImage,
|
||||
detect_rp2040_bootsel,
|
||||
@@ -85,7 +85,6 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
|
||||
|
||||
# Maximum buffer size for serial log reading to prevent unbounded memory growth
|
||||
SERIAL_BUFFER_MAX_SIZE = 65536
|
||||
@@ -150,6 +149,7 @@ class ArgsProtocol(Protocol):
|
||||
file: str | None
|
||||
no_logs: bool
|
||||
only_generate: bool
|
||||
skip_bootloader: bool
|
||||
show_secrets: bool
|
||||
dashboard: bool
|
||||
configuration: str
|
||||
@@ -829,6 +829,14 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
|
||||
# Debug only: an orchestrator cannot see YAML toolchain overrides,
|
||||
# so this is its expected no-op, and a full build is safe.
|
||||
_LOGGER.debug(
|
||||
"--skip-bootloader ignored: only supported on ESP32 with the "
|
||||
"esp-idf toolchain"
|
||||
)
|
||||
CORE.skip_bootloader = False
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
@@ -838,7 +846,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
if CORE.is_esp8266 and CORE.using_toolchain_platformio:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
@@ -859,7 +867,10 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
return rc
|
||||
|
||||
# Create factory.bin, ota.bin, and firmware.elf copy
|
||||
toolchain.create_factory_bin()
|
||||
if not toolchain.create_factory_bin():
|
||||
# A build whose factory image could not be produced must not
|
||||
# exit 0; downloads would serve an image from an older build.
|
||||
return 1
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
@@ -977,8 +988,9 @@ def upload_using_esptool(
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
hint = getattr(native, "missing_image_hint", lambda: None)()
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
hint or f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
else:
|
||||
@@ -1289,10 +1301,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
cleartext over the LAN. (The native path also compresses the upload:
|
||||
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
@@ -1301,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
# platform's final-validate hook merges duplicates anyway.
|
||||
available: dict[str, None] = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
platform = ota_item.get(CONF_PLATFORM)
|
||||
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[platform] = None
|
||||
ota_platform = ota_item.get(CONF_PLATFORM)
|
||||
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[ota_platform] = None
|
||||
|
||||
if not available:
|
||||
raise EsphomeError(
|
||||
@@ -1381,6 +1392,12 @@ def _upload_via_native_api(
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
elif getattr(args, "bootloader", False):
|
||||
check_partition_access("--bootloader")
|
||||
if (
|
||||
getattr(args, "file", None) is None
|
||||
and (native := native_backend())
|
||||
and (hint := getattr(native, "missing_image_hint", lambda: None)())
|
||||
):
|
||||
raise EsphomeError(hint)
|
||||
binary = CORE.bootloader_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
|
||||
if getattr(args, "file", None) is not None:
|
||||
@@ -1756,6 +1773,18 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if (
|
||||
CORE.skip_bootloader
|
||||
and CORE.is_esp32
|
||||
and CORE.using_toolchain_esp_idf
|
||||
and any(
|
||||
get_port_type(device) == PortType.SERIAL for device in (args.device or [])
|
||||
)
|
||||
):
|
||||
# Fail before the compile: the result could never flash over serial.
|
||||
# Elsewhere the flag is ignored, so serial stays fine there.
|
||||
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
|
||||
return 1
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
@@ -1917,7 +1946,10 @@ def run_multiple_configs(
|
||||
safe_print()
|
||||
|
||||
cmd = command_builder(f)
|
||||
rc = run_external_process(*cmd)
|
||||
# The parent already logged the Intel macOS warning; children skip it.
|
||||
rc = run_external_process(
|
||||
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
|
||||
@@ -2104,155 +2136,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
|
||||
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.cli.rename import command_rename as run
|
||||
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
# Load existing yaml file
|
||||
raw_contents = CORE.config_path.read_text(encoding="utf-8")
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
)
|
||||
return 1
|
||||
old_name = yaml[CONF_ESPHOME][CONF_NAME]
|
||||
match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name)
|
||||
if match is None:
|
||||
# Only swap the ``name:`` line that sits directly under the
|
||||
# top-level ``esphome:`` block. A naked ``re.sub`` would
|
||||
# also clobber any other ``name:`` line whose value happens
|
||||
# to match (e.g. a sensor / output / wifi entry sharing the
|
||||
# device's hostname), silently rewriting unrelated user
|
||||
# configuration. The pattern anchors:
|
||||
# - at the start of the line so ``friendly_name:``,
|
||||
# ``device_name:`` etc. don't match the trailing ``name:``
|
||||
# substring; and
|
||||
# - at the end of the value (lookahead for whitespace +
|
||||
# comment + EOL) so ``old_name`` doesn't match as a
|
||||
# prefix of a longer value (``kitchen`` vs ``kitchen2``).
|
||||
name_pattern = re.compile(
|
||||
rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))"
|
||||
)
|
||||
out_lines: list[str] = []
|
||||
in_esphome_block = False
|
||||
for line in raw_contents.splitlines(keepends=True):
|
||||
if line and not line[0].isspace() and line.strip():
|
||||
in_esphome_block = line.lstrip().startswith("esphome:")
|
||||
out_lines.append(line)
|
||||
continue
|
||||
if in_esphome_block:
|
||||
line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1)
|
||||
out_lines.append(line)
|
||||
new_raw = "".join(out_lines)
|
||||
else:
|
||||
old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)]
|
||||
if (
|
||||
len(
|
||||
re.findall(
|
||||
rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?",
|
||||
raw_contents,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
)
|
||||
> 1
|
||||
):
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?",
|
||||
f'\\1: "{new_name}"',
|
||||
raw_contents,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ``new_name == old_name`` (after substitution resolution) is
|
||||
# a no-op rewrite that would still queue a pointless re-flash.
|
||||
# Catch it before the path-equality check below — covers the
|
||||
# case where the config filename doesn't match the device name
|
||||
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
"Refusing to overwrite an existing configuration.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
|
||||
new_path.write_text(new_raw, encoding="utf-8")
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
cli_args = [
|
||||
"run",
|
||||
str(new_path),
|
||||
"--no-logs",
|
||||
"--device",
|
||||
CORE.address,
|
||||
]
|
||||
|
||||
if args.dashboard:
|
||||
cli_args.insert(0, "--dashboard")
|
||||
|
||||
try:
|
||||
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
|
||||
except KeyboardInterrupt:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
return run(args, config)
|
||||
|
||||
|
||||
PRE_CONFIG_ACTIONS = {
|
||||
@@ -2287,6 +2173,15 @@ SIMPLE_CONFIG_ACTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--skip-bootloader",
|
||||
help="Do not build the bootloader or the factory image; "
|
||||
"the result can only be flashed over OTA.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
|
||||
def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
|
||||
|
||||
@@ -2367,7 +2262,8 @@ def parse_args(argv):
|
||||
metavar="{" + ",".join(t.value for t in Toolchain) + "}",
|
||||
help=(
|
||||
"Select toolchain for compiling. Overrides '<platform>.toolchain' in YAML. "
|
||||
f"Default: {Toolchain.PLATFORMIO.value}."
|
||||
"Default: the platform's native toolchain where it has one, else "
|
||||
f"{Toolchain.PLATFORMIO.value}."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2427,6 +2323,7 @@ def parse_args(argv):
|
||||
help="Only generate source code, do not compile.",
|
||||
action="store_true",
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_compile)
|
||||
|
||||
parser_upload = subparsers.add_parser(
|
||||
"upload",
|
||||
@@ -2490,7 +2387,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
_add_states_args(parser_logs)
|
||||
|
||||
@@ -2523,6 +2420,7 @@ def parse_args(argv):
|
||||
parser_run.add_argument(
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_run)
|
||||
|
||||
_add_states_args(parser_run)
|
||||
|
||||
@@ -2531,7 +2429,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--ota-platform",
|
||||
@@ -2720,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None:
|
||||
)
|
||||
|
||||
|
||||
_INTEL_MACOS_REMOVAL = "2027.6.0"
|
||||
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
|
||||
|
||||
|
||||
def _warn_if_intel_macos() -> None:
|
||||
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
|
||||
if (
|
||||
not IS_MACOS
|
||||
or platform.machine() != "x86_64"
|
||||
or _INTEL_MACOS_WARNED_ENV in os.environ
|
||||
):
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
|
||||
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
|
||||
"so future releases will not install on this machine. On an Apple "
|
||||
"Silicon Mac, switch to a native arm64 Python.",
|
||||
_INTEL_MACOS_REMOVAL,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2739,6 +2658,7 @@ def run_esphome(argv):
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
_warn_if_intel_macos()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2782,6 +2702,7 @@ def run_esphome(argv):
|
||||
|
||||
CORE.config_path = conf_path
|
||||
CORE.dashboard = args.dashboard
|
||||
CORE.skip_bootloader = getattr(args, "skip_bootloader", False)
|
||||
if args.toolchain is not None:
|
||||
# CLI toolchain wins over esp32.toolchain in YAML.
|
||||
CORE.toolchain = args.toolchain
|
||||
|
||||
@@ -148,6 +148,13 @@ class AddressCache:
|
||||
continue
|
||||
hostname, ips = arg.split("=", 1)
|
||||
# Normalize hostname for consistent lookups
|
||||
normalized = normalize_hostname(hostname)
|
||||
cache[normalized] = [ip.strip() for ip in ips.split(",")]
|
||||
normalized = normalize_hostname(hostname.strip())
|
||||
addresses = [ip for value in ips.split(",") if (ip := value.strip())]
|
||||
if not normalized or not addresses:
|
||||
_LOGGER.warning(
|
||||
"Invalid cache entry: %s (hostname and at least one address are required)",
|
||||
arg,
|
||||
)
|
||||
continue
|
||||
cache[normalized] = addresses
|
||||
return cache
|
||||
|
||||
@@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# Native ESP-IDF: the build writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
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)
|
||||
<cache>/arduino8266/frameworks/<tag>/ the Arduino core
|
||||
<cache>/arduino8266/toolchains/<tag>/ xtensa-lx106-elf 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.
|
||||
Both come from esphome-libs releases pinned below;
|
||||
``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs, with ``{VERSION}``
|
||||
standing for the release tag. ninja comes from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,13 +22,69 @@ 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
|
||||
from esphome.platformio.registry import (
|
||||
Download,
|
||||
PackageSpec,
|
||||
Resolver,
|
||||
get_systype,
|
||||
install_packages,
|
||||
prefetch_packages,
|
||||
)
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
FRAMEWORK_PACKAGE = "arduino-esp8266"
|
||||
_FRAMEWORK_RELEASES = "https://github.com/esphome-libs/arduino-esp8266/releases/"
|
||||
|
||||
|
||||
class FrameworkRelease(NamedTuple):
|
||||
tag: str
|
||||
sha256: str
|
||||
size: int
|
||||
|
||||
def download(self) -> Download:
|
||||
archive = f"{FRAMEWORK_PACKAGE}-{self.tag}.tar.gz"
|
||||
url = f"{_FRAMEWORK_RELEASES}download/{self.tag}/{archive}"
|
||||
return Download(url, self.sha256, self.size)
|
||||
|
||||
|
||||
# Arduino core version -> its build in esphome-libs/arduino-esp8266
|
||||
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
|
||||
Version(3, 1, 2): FrameworkRelease(
|
||||
"3.1.2-esphome.3",
|
||||
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
|
||||
37185650,
|
||||
),
|
||||
}
|
||||
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf"
|
||||
# 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"
|
||||
TOOLCHAIN_VERSION = "10.3.0-esphome.2"
|
||||
_TOOLCHAIN_RELEASES = (
|
||||
"https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/"
|
||||
)
|
||||
# Registry system tag -> (sha256, size) of that host's archive
|
||||
TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = {
|
||||
"darwin_arm64": (
|
||||
"849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201",
|
||||
60830321,
|
||||
),
|
||||
"darwin_x86_64": (
|
||||
"ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897",
|
||||
64149487,
|
||||
),
|
||||
"linux_aarch64": (
|
||||
"60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6",
|
||||
67573397,
|
||||
),
|
||||
"linux_x86_64": (
|
||||
"1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4",
|
||||
68431336,
|
||||
),
|
||||
"windows_amd64": (
|
||||
"af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b",
|
||||
67664137,
|
||||
),
|
||||
}
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
@@ -44,36 +100,40 @@ def get_arduino8266_tools_path() -> Path:
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0
|
||||
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 for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
def framework_release(version: Version) -> FrameworkRelease:
|
||||
if (release := FRAMEWORK_RELEASES.get(version)) is None:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
f"'toolchain: arduino' has no build of Arduino core {version}; "
|
||||
f"available: {', '.join(str(v) for v in FRAMEWORK_RELEASES)}. "
|
||||
"Use one of those or 'toolchain: platformio'"
|
||||
)
|
||||
if ver.major < 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported; ESPHome requires core 3.x"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
return release
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
def get_framework_path(tag: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / tag
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
def toolchain_download() -> Download:
|
||||
"""The toolchain archive for the current host."""
|
||||
systype = get_systype()
|
||||
if (build := TOOLCHAIN_BUILDS.get(systype)) is None:
|
||||
raise EsphomeError(
|
||||
f"There is no ESP8266 toolchain for this system ({systype}); "
|
||||
f"supported systems are {', '.join(sorted(TOOLCHAIN_BUILDS))}. "
|
||||
"Either set 'toolchain: platformio' under 'esp8266:', or point "
|
||||
"ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS at a toolchain archive"
|
||||
)
|
||||
sha256, size = build
|
||||
archive = f"{TOOLCHAIN_PACKAGE}-{TOOLCHAIN_VERSION}-{systype}.tar.gz"
|
||||
url = f"{_TOOLCHAIN_RELEASES}download/{TOOLCHAIN_VERSION}/{archive}"
|
||||
return Download(url, sha256, size)
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
@@ -84,29 +144,22 @@ class InstalledPaths(NamedTuple):
|
||||
|
||||
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}"
|
||||
)
|
||||
release = framework_release(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)
|
||||
framework_path = get_framework_path(release.tag)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
PackageSpec(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
release.tag,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
PackageSpec(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
@@ -115,10 +168,17 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
("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)
|
||||
# Resolved only when a download is needed, so an installed toolchain
|
||||
# keeps working on a host without a build; a mirror override wins
|
||||
resolvers: dict[str, Resolver] = {}
|
||||
if not ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS:
|
||||
resolvers[FRAMEWORK_PACKAGE] = release.download
|
||||
if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS:
|
||||
resolvers[TOOLCHAIN_PACKAGE] = toolchain_download
|
||||
# Fetch both archives at once; the installs verify and extract them.
|
||||
# One spec list for both, so the two phases cannot drift.
|
||||
prefetch_packages(specs, downloads_dir, resolvers)
|
||||
install_packages(specs, downloads_dir, resolvers)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
|
||||
+33
-52
@@ -21,7 +21,7 @@ from esphome.const import (
|
||||
CONF_TYPE_ID,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
|
||||
from esphome.cpp_generator import (
|
||||
FlashStringLiteral,
|
||||
LambdaExpression,
|
||||
@@ -35,6 +35,32 @@ from esphome.types import ConfigType, SafeExpType
|
||||
from esphome.util import Registry
|
||||
|
||||
|
||||
def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj:
|
||||
"""Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr."""
|
||||
if not data:
|
||||
return cg.nullptr
|
||||
return cg.shared_progmem_array(
|
||||
name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data))
|
||||
)
|
||||
|
||||
|
||||
async def templatable_bytes(
|
||||
value: Any,
|
||||
args: TemplateArgsType,
|
||||
set_template: MockObj,
|
||||
set_static: MockObj,
|
||||
table_name: str,
|
||||
) -> None:
|
||||
"""Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static."""
|
||||
if cg.is_template(value):
|
||||
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
|
||||
cg.add(set_template(fn))
|
||||
elif len(value) > 0xFFFF:
|
||||
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
|
||||
else:
|
||||
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
|
||||
|
||||
|
||||
def maybe_simple_id(*validators):
|
||||
"""Allow a raw ID to be specified in place of a config block.
|
||||
If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's
|
||||
@@ -646,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
|
||||
|
||||
|
||||
@register_condition("and", AndCondition, validate_condition_list)
|
||||
async def and_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
async def or_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("all", AndCondition, validate_condition_list)
|
||||
async def all_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
@register_condition("any", OrCondition, validate_condition_list)
|
||||
async def any_condition_to_code(
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def condition_group_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
# A group of one is that condition for and, or and xor; skip the wrapper.
|
||||
if len(conditions) == 1:
|
||||
return conditions[0]
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
@@ -708,19 +702,6 @@ async def not_condition_to_code(
|
||||
return cg.new_Pvariable(condition_id, template_arg, condition)
|
||||
|
||||
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def xor_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
|
||||
async def lambda_condition_to_code(
|
||||
config: ConfigType,
|
||||
|
||||
@@ -25,6 +25,7 @@ import subprocess
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from esphome.arduino8266.framework import toolchain_tool
|
||||
from esphome.build_helpers.idedata import is_joined_include
|
||||
from esphome.build_helpers.ninja import (
|
||||
escape as _e,
|
||||
quote_path as _q,
|
||||
@@ -36,8 +37,11 @@ from esphome.build_helpers.ninja_gen import (
|
||||
compile_edges,
|
||||
compile_rule_lines,
|
||||
library_edges,
|
||||
pch_edges,
|
||||
pch_rule_lines,
|
||||
tool_lines,
|
||||
)
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import (
|
||||
BOARDS,
|
||||
@@ -50,6 +54,7 @@ from esphome.components.esp8266.const import (
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_SCANF_FLOAT,
|
||||
THROW_STUBS_HEADER,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
@@ -1049,24 +1054,22 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
+ ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"]
|
||||
)
|
||||
|
||||
cxx = (toolchain_tool(paths.toolchain, "g++"),)
|
||||
lines = [
|
||||
*tool_lines(
|
||||
(toolchain_tool(paths.toolchain, "gcc"),),
|
||||
(toolchain_tool(paths.toolchain, "g++"),),
|
||||
ccache,
|
||||
),
|
||||
*compile_rule_lines(),
|
||||
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
|
||||
*compile_rule_lines(ccache),
|
||||
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group",
|
||||
" rspfile = $out.rsp",
|
||||
" rspfile_content = $in_newline",
|
||||
" description = LINK $out",
|
||||
"rule elf2bin",
|
||||
# --flash_size deliberately stays board-derived, as under
|
||||
# PlatformIO (which reads upload.maximum_size, not the ldscript).
|
||||
# --flash_size follows PlatformIO: ldscript filename first, then
|
||||
# upload.maximum_size, so the header always matches the layout.
|
||||
# -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+.
|
||||
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out",
|
||||
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_elf2bin_flash_size(board, flash_ld_name)} --path {_q(toolchain_bin)} --out $out",
|
||||
" description = BIN $out",
|
||||
"rule copy",
|
||||
" command = $python $buildtool copy $in $out",
|
||||
@@ -1085,33 +1088,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
|
||||
core_exclude |= _CORE_EXCLUDE_WAVEFORM
|
||||
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries)
|
||||
archives += lib_archives
|
||||
|
||||
# One source of truth with the PlatformIO path: esp8266/__init__ pins
|
||||
# build_src_flags (the throw_stubs force-include); -include paths
|
||||
# resolve against the source root
|
||||
src_parts: list[str] = []
|
||||
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
|
||||
# the PlatformIO path; -include paths resolve against the source root
|
||||
src_other: list[str] = []
|
||||
src_includes: list[str] = []
|
||||
src_it = iter(
|
||||
lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags")
|
||||
)
|
||||
@@ -1122,15 +1102,71 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
raise EsphomeError(
|
||||
"build_src_flags has a trailing '-include' with no header"
|
||||
)
|
||||
src_parts.append(f"-include {_q(src_dir / header)}")
|
||||
src_includes.append(header)
|
||||
elif is_joined_include(tok):
|
||||
# Left in src_other it would precede the pch include
|
||||
src_includes.append(tok[len("-include") :])
|
||||
else:
|
||||
src_parts.append(_shell_token(tok))
|
||||
src_extra = " ".join(src_parts)
|
||||
src_other.append(_shell_token(tok))
|
||||
# The throw stubs abort: none anywhere with exceptions on, else the core and
|
||||
# libraries get them too (src keeps them after its pch include)
|
||||
if config.exceptions:
|
||||
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
|
||||
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
|
||||
framework_flags = ""
|
||||
if THROW_STUBS_HEADER in src_includes:
|
||||
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
|
||||
lines.append(f"frameworkflags = {stubs_flag}")
|
||||
framework_flags = "$frameworkflags"
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(
|
||||
lines, variant_sources, variant_dir, "variant", flags=framework_flags
|
||||
)
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines,
|
||||
collect_sources(core_dir, core_exclude),
|
||||
core_dir,
|
||||
"core",
|
||||
flags=framework_flags,
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
|
||||
archives += lib_archives
|
||||
|
||||
# One shared variable instead of repeating the flags line on every src
|
||||
# edge (hundreds of edges in a real project)
|
||||
lines.append(f"srcflags = {src_extra}")
|
||||
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
|
||||
src_cxx_override = pch_edges(
|
||||
lines,
|
||||
build_dir,
|
||||
src_dir,
|
||||
(*src_includes, *PCH_DEFAULT_HEADERS),
|
||||
flag_sets.cxxflags,
|
||||
src_other,
|
||||
(str(paths.framework), str(paths.toolchain)),
|
||||
cxx,
|
||||
)
|
||||
src_objs = compile_edges(
|
||||
lines, collect_sources(src_dir), src_dir, "src", flags="$srcflags"
|
||||
lines,
|
||||
collect_sources(src_dir),
|
||||
src_dir,
|
||||
"src",
|
||||
flags="$srcflags",
|
||||
cxx_override=src_cxx_override,
|
||||
)
|
||||
|
||||
ld_deps = [f"ld/{_COMMON_LD_NAME}"]
|
||||
@@ -1168,3 +1204,28 @@ def _flash_size_str(flash_size: int) -> str:
|
||||
"""Flash size argument for elf2bin (e.g. ``4M``, ``512K``)."""
|
||||
mb = 1024 * 1024
|
||||
return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K"
|
||||
|
||||
|
||||
# Same pattern PlatformIO's _get_flash_size applies to the ldscript path
|
||||
_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld")
|
||||
# The framework elf2bin.py's --flash_size choices
|
||||
_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"})
|
||||
|
||||
|
||||
def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str:
|
||||
"""Image-header flash size as PlatformIO derives it: ldscript filename,
|
||||
else board_upload.maximum_size, else the board table. The SDK clamps the
|
||||
chip to the header size at boot, so a header smaller than the linked
|
||||
layout breaks OTA writes on the running device."""
|
||||
if match := _LD_FLASH_SIZE_RE.search(flash_ld_name):
|
||||
token = match.group(1)
|
||||
base = 1024 if token[-1] == "k" else 1024 * 1024
|
||||
return _flash_size_str(int(token[:-1]) * base)
|
||||
if max_size := _pio_option("board_upload.maximum_size", ""):
|
||||
if (
|
||||
not max_size.isdigit()
|
||||
or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES
|
||||
):
|
||||
raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}")
|
||||
return size
|
||||
return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE])
|
||||
|
||||
+250
-1
@@ -3,7 +3,10 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
@@ -18,7 +21,7 @@ from esphome.framework_helpers import (
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +36,83 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
|
||||
list(APPEND esphome_cxx_compile_options "-std={standard}")
|
||||
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
|
||||
|
||||
# Drops the app archive from ldgen's inputs so app-only edits skip the
|
||||
# sections.ld regeneration. Safe: no mapping fragment references it
|
||||
# (run_compile re-checks each build). Filters only the top-level call;
|
||||
# the prior definition stays reachable with an underscore prefix.
|
||||
_LDGEN_OVERRIDE = """\
|
||||
if(COMMAND __ldgen_get_lib_deps_of_target)
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
|
||||
function(__ldgen_get_lib_deps_of_target target out_list_var)
|
||||
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
|
||||
message(FATAL_ERROR "ESPHome ldgen override lost the original "
|
||||
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
|
||||
endif()
|
||||
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
|
||||
if(out_list_var STREQUAL "ldgen_libraries")
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_before)
|
||||
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_after)
|
||||
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
|
||||
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
|
||||
"nothing; app edits will regenerate sections.ld.")
|
||||
endif()
|
||||
endif()
|
||||
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
else()
|
||||
message(@MISSING@ "ESPHome ldgen override target not found; "
|
||||
"app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
# lwip sources that compile to empty objects with the option off (their own
|
||||
# #if guard). (option, regex valid for both Python and CMake); a source is
|
||||
# only dropped when its option is defined and off, so a renamed option
|
||||
# keeps it.
|
||||
LWIP_EMPTY_SOURCES: tuple[tuple[str, str], ...] = (
|
||||
("CONFIG_LWIP_PPP_SUPPORT", "/netif/ppp/"),
|
||||
("CONFIG_LWIP_IPV6", "/core/ipv6/"),
|
||||
("CONFIG_LWIP_AUTOIP", "/core/ipv4/autoip[.]c$"),
|
||||
("CONFIG_LWIP_STATS", "/core/stats[.]c$"),
|
||||
)
|
||||
# Drift guard only: keep every lwip source.
|
||||
LWIP_FULL_SOURCES_ENV = "ESPHOME_LWIP_FULL_SOURCES"
|
||||
|
||||
# Drops the empty objects after project(), once the lwip target exists.
|
||||
_LWIP_EMPTY_SOURCES_FILTER = f"""\
|
||||
idf_build_get_property(esphome_build_components BUILD_COMPONENTS)
|
||||
if(lwip IN_LIST esphome_build_components AND NOT DEFINED ENV{{{LWIP_FULL_SOURCES_ENV}}})
|
||||
idf_component_get_property(esphome_lwip_lib lwip COMPONENT_LIB)
|
||||
get_target_property(esphome_lwip_srcs ${{esphome_lwip_lib}} SOURCES)
|
||||
@FILTERS@
|
||||
set_property(TARGET ${{esphome_lwip_lib}} PROPERTY SOURCES ${{esphome_lwip_srcs}})
|
||||
endif()"""
|
||||
|
||||
|
||||
def lwip_empty_source_gate(option: str, regex: str) -> str:
|
||||
return (
|
||||
f" if(DEFINED {option} AND NOT {option})\n"
|
||||
f' list(FILTER esphome_lwip_srcs EXCLUDE REGEX "{regex}")\n'
|
||||
" endif()"
|
||||
)
|
||||
|
||||
|
||||
def _lwip_empty_sources_filter() -> str:
|
||||
gates = "\n".join(lwip_empty_source_gate(*entry) for entry in LWIP_EMPTY_SOURCES)
|
||||
return _LWIP_EMPTY_SOURCES_FILTER.replace("@FILTERS@", gates)
|
||||
|
||||
|
||||
# Runs after project() so the walk has happened; catches the remaining
|
||||
# silent path where the top-level out-var was renamed.
|
||||
_LDGEN_OVERRIDE_CHECK = """\
|
||||
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
|
||||
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
|
||||
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
|
||||
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
|
||||
"archive; app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""List the built-in ESP-IDF components from ``project_description.json``.
|
||||
@@ -78,6 +158,85 @@ def _cmake_quote(value: str) -> str:
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
|
||||
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
|
||||
# The macro is IDF's __build_process_project_includes plus a few added lines;
|
||||
# the flag is ignored and the bootloader builds as usual if IDF changes it.
|
||||
IDF_BOOTLOADER_OVERRIDE = """\
|
||||
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
|
||||
if(ESPHOME_SKIP_BOOTLOADER)
|
||||
macro(__build_process_project_includes)
|
||||
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
|
||||
include(${sdkconfig_cmake})
|
||||
set(CONFIG_APP_BUILD_BOOTLOADER "")
|
||||
# bt's CMakeLists reads the lowercase idf_target that the (now
|
||||
# skipped) bootloader project_include leaks; keep it defined, or
|
||||
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
|
||||
idf_build_get_property(idf_target IDF_TARGET)
|
||||
# partition_table's V1 ECDSA signing reads this key, which the
|
||||
# skipped bootloader project_include also sets.
|
||||
get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")
|
||||
idf_build_get_property(build_properties __BUILD_PROPERTIES)
|
||||
foreach(build_property ${build_properties})
|
||||
idf_build_get_property(val ${build_property})
|
||||
set(${build_property} "${val}")
|
||||
endforeach()
|
||||
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
|
||||
foreach(component_target ${build_component_targets})
|
||||
__component_get_property(dir ${component_target} COMPONENT_DIR)
|
||||
__component_get_property(_name ${component_target} COMPONENT_NAME)
|
||||
set(COMPONENT_NAME ${_name})
|
||||
set(COMPONENT_DIR ${dir})
|
||||
set(COMPONENT_PATH ${dir})
|
||||
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
|
||||
include(${COMPONENT_DIR}/project_include.cmake)
|
||||
endif()
|
||||
endforeach()
|
||||
endmacro()
|
||||
endif()
|
||||
"""
|
||||
|
||||
# The lines the override adds to IDF's macro; idf_macro_matches() below
|
||||
# strips them before comparing with the live macro.
|
||||
BOOTLOADER_OVERRIDE_ADDED_LINES = (
|
||||
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
|
||||
"idf_build_get_property(idf_target IDF_TARGET)",
|
||||
(
|
||||
"get_filename_component(SECURE_BOOT_SIGNING_KEY"
|
||||
' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")'
|
||||
),
|
||||
)
|
||||
|
||||
_MACRO = re.compile(
|
||||
r"macro\(__build_process_project_includes\)(.*?)endmacro\(\)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _normalized_macro(text: str) -> list[str] | None:
|
||||
"""The macro body as comment-free, whitespace-collapsed lines."""
|
||||
if (match := _MACRO.search(text)) is None:
|
||||
return None
|
||||
return [
|
||||
re.sub(r"\s+", " ", line)
|
||||
for raw in match.group(1).splitlines()
|
||||
if (line := raw.split("#", 1)[0].strip())
|
||||
]
|
||||
|
||||
|
||||
_EXPECTED_MACRO = [
|
||||
line
|
||||
for line in _normalized_macro(IDF_BOOTLOADER_OVERRIDE)
|
||||
if line not in BOOTLOADER_OVERRIDE_ADDED_LINES
|
||||
]
|
||||
|
||||
|
||||
def idf_macro_matches(idf_path: Path) -> bool:
|
||||
"""Whether IDF's macro still matches the copy the override replays."""
|
||||
build_cmake = idf_path / "tools" / "cmake" / "build.cmake"
|
||||
live = _normalized_macro(build_cmake.read_text(encoding="utf-8"))
|
||||
return live == _EXPECTED_MACRO
|
||||
|
||||
|
||||
def get_project_cmakelists(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> str:
|
||||
@@ -123,6 +282,22 @@ def get_project_cmakelists(
|
||||
else ""
|
||||
)
|
||||
|
||||
# Stops the ~3s sections.ld regeneration on app-only edits; see
|
||||
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
|
||||
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
|
||||
# breaks the override instead of degrading to stock deps.
|
||||
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
|
||||
ldgen_override = ""
|
||||
ldgen_override_check = ""
|
||||
else:
|
||||
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
|
||||
severity = "FATAL_ERROR" if strict else "WARNING"
|
||||
missing = "FATAL_ERROR" if strict else "STATUS"
|
||||
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
|
||||
"@MISSING@", missing
|
||||
)
|
||||
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
|
||||
|
||||
# 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
|
||||
@@ -200,6 +375,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||
|
||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{IDF_BOOTLOADER_OVERRIDE}
|
||||
{ldgen_override}
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
@@ -212,14 +390,22 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
{ldgen_override_check}
|
||||
|
||||
{_lwip_empty_sources_filter()}
|
||||
|
||||
# Emit per-memory-type JSON size data for ESPHome to read post-build.
|
||||
# json2 stays small; raw dumps every symbol (~2s on a large map) and
|
||||
# this command runs inside the link edge, blocking everything downstream.
|
||||
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
|
||||
# target depends on the map and can then be built in the same run as all.
|
||||
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
VERBATIM
|
||||
)
|
||||
@@ -282,9 +468,72 @@ idf_component_register(
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
)
|
||||
{_pch_cmake_block()}"""
|
||||
|
||||
|
||||
# Where CMake puts the .gch of the src component; ccache reads the checksum
|
||||
# next to it in place of the .gch
|
||||
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
|
||||
# Where the Windows gate records its choice
|
||||
_PCH_CHOICE_VAR = "ESPHOME_PCH"
|
||||
|
||||
|
||||
def _pch_cmake_block() -> str:
|
||||
"""The CMake block that precompiles the core headers for the C++ sources
|
||||
of the src component; empty when disabled."""
|
||||
if not pch.pch_enabled():
|
||||
return ""
|
||||
headers = "\n".join(
|
||||
f' "$<$<COMPILE_LANGUAGE:CXX>:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"'
|
||||
for header in pch.PCH_DEFAULT_HEADERS
|
||||
)
|
||||
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
|
||||
{headers}
|
||||
)"""
|
||||
if not pch.pch_needs_gcc_check():
|
||||
return f"\n# ESPHome precompiled header\n{block}\n"
|
||||
# Before the first configure only CMake knows the compiler version
|
||||
return f"""
|
||||
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
||||
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
|
||||
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
else()
|
||||
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||
{textwrap.indent(block, " ")}
|
||||
endif()
|
||||
"""
|
||||
|
||||
|
||||
def _read_if_exists(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
|
||||
def write_pch_checksum() -> None:
|
||||
"""Write the checksum ccache uses in place of the .gch: the core headers,
|
||||
the framework version, the sdkconfig and the managed component versions."""
|
||||
from esphome.espidf.toolchain import get_cmake_cache_value
|
||||
|
||||
if not pch.pch_enabled():
|
||||
return
|
||||
# The gate's choice, cached by configure
|
||||
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
|
||||
return
|
||||
pch.log_pch_in_use()
|
||||
checksum = pch.pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
pch.PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(idf_version()),
|
||||
_read_if_exists(CORE.relative_build_path(f"sdkconfig.{CORE.name}")),
|
||||
_read_if_exists(CORE.relative_build_path("dependencies.lock")),
|
||||
),
|
||||
)
|
||||
path = CORE.relative_build_path(_PCH_SUM_PATH)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_file_if_changed(path, checksum + "\n")
|
||||
|
||||
|
||||
def write_project(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from collections.abc import Iterable
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -28,8 +29,11 @@ from esphome.build_helpers.ninja_gen import (
|
||||
compile_edges,
|
||||
compile_rule_lines,
|
||||
library_edges,
|
||||
pch_edges,
|
||||
pch_rule_lines,
|
||||
tool_lines,
|
||||
)
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_enabled
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
@@ -189,6 +193,15 @@ def _file_macro_maps(build_dir: Path) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _compiler_version(cxx: tuple[str, ...]) -> str:
|
||||
"""What the compiler says it is: its path can stay the same across an
|
||||
update (the macOS shims in /usr/bin)."""
|
||||
result = subprocess.run(
|
||||
[*cxx, "--version"], capture_output=True, text=True, check=False
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
"""Write the ninja build for the current configuration.
|
||||
|
||||
@@ -227,7 +240,8 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
|
||||
lines = [
|
||||
*tool_lines(compilers.cc, compilers.cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
*compile_rule_lines(ccache),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
|
||||
" rspfile = $out.rsp",
|
||||
@@ -251,7 +265,24 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
|
||||
archives, direct_objs = library_edges(lines, libraries)
|
||||
|
||||
src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src")
|
||||
src_cxx_override = pch_edges(
|
||||
lines,
|
||||
build_dir,
|
||||
src_dir,
|
||||
PCH_DEFAULT_HEADERS,
|
||||
# The arguments of a CXX override come before the flags
|
||||
[*compilers.cxx[1:], *cxxflags],
|
||||
(),
|
||||
(compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (),
|
||||
compilers.cxx,
|
||||
)
|
||||
src_objs = compile_edges(
|
||||
lines,
|
||||
collect_sources(src_dir),
|
||||
src_dir,
|
||||
"src",
|
||||
cxx_override=src_cxx_override,
|
||||
)
|
||||
if not src_objs:
|
||||
raise EsphomeError(f"No source files found under {src_dir}")
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from esphome.build_helpers.pch import pch_script_enabled
|
||||
from esphome.const import __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_pch_script
|
||||
from esphome.writer import find_begin_end
|
||||
|
||||
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
|
||||
@@ -62,6 +64,8 @@ def get_ini_content():
|
||||
|
||||
# Add extra script for C++ flags
|
||||
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
|
||||
if pch_script_enabled():
|
||||
CORE.add_platformio_option("extra_scripts", ["post:pch.py"])
|
||||
|
||||
# Add CMake args. A user-supplied value (str or list) is deliberately
|
||||
# replaced; this option was always overwritten at FINAL priority.
|
||||
@@ -106,6 +110,8 @@ def write_project():
|
||||
|
||||
# Write extra script for C++ specific flags
|
||||
write_cxx_flags_script()
|
||||
if pch_script_enabled():
|
||||
copy_pch_script()
|
||||
|
||||
|
||||
CXX_FLAGS_FILE_NAME = "cxx_flags.py"
|
||||
|
||||
@@ -102,15 +102,30 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def effective_ccache_basedir() -> str:
|
||||
"""The prefix ccache strips from hashed paths: a usable user
|
||||
CCACHE_BASEDIR, else the resolved build path."""
|
||||
from esphome.core import CORE
|
||||
|
||||
raw = os.environ.get("CCACHE_BASEDIR")
|
||||
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
|
||||
return raw
|
||||
return str(Path(CORE.build_path).resolve())
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]:
|
||||
"""The ccache settings for a build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when
|
||||
disabled; ``tools_cache`` is the backend's tools cache spec, which holds
|
||||
its ccache dir.
|
||||
its ccache dir. The pch settings include ``time_macros``: a cached
|
||||
object can keep an older ``__DATE__`` or ``__TIME__``.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import tools_cache_path
|
||||
|
||||
return ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache")
|
||||
env = ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
return env
|
||||
|
||||
@@ -152,6 +152,15 @@ def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def is_joined_include(tok: str) -> bool:
|
||||
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
|
||||
return (
|
||||
tok.startswith("-include")
|
||||
and tok != "-include"
|
||||
and not tok.startswith("-include-")
|
||||
)
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
|
||||
@@ -13,12 +13,23 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.build_helpers.idedata import is_joined_include
|
||||
from esphome.build_helpers.ninja import (
|
||||
escape as _e,
|
||||
quote_path as _q,
|
||||
shell_token as _shell_token,
|
||||
)
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_GCH_NAME,
|
||||
PCH_SUM_NAME,
|
||||
log_pch_in_use,
|
||||
pch_consumer_flags,
|
||||
pch_identity,
|
||||
pch_usable,
|
||||
write_pch_headers,
|
||||
)
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,37 +74,102 @@ def tool_lines(
|
||||
# path gets
|
||||
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
|
||||
f"buildtool = {_q(_BUILD_TOOL)}",
|
||||
f"ccache = {_q(ccache) if ccache else ''}",
|
||||
*([f"ccache = {_q(ccache)}"] if ccache else []),
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def compile_rule_lines() -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
|
||||
def compile_rule_lines(ccache: str | None) -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
|
||||
|
||||
``ccache`` is the launcher ``tool_lines`` was given; the same test here
|
||||
keeps the variable and the rules in step. It is spliced in rather than
|
||||
left as an empty variable: a leading space on the command line makes
|
||||
CreateProcess fail on Windows.
|
||||
"""
|
||||
launcher = "$ccache " if ccache else ""
|
||||
return [
|
||||
"rule c",
|
||||
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CC $out",
|
||||
"rule cxx",
|
||||
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CXX $out",
|
||||
"rule aspp",
|
||||
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = AS $out",
|
||||
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
|
||||
# depfile and no $flags (defines/includes) either
|
||||
"rule asm",
|
||||
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
|
||||
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
|
||||
" description = AS $out",
|
||||
]
|
||||
|
||||
|
||||
def pch_rule_lines() -> list[str]:
|
||||
"""The precompiled header rule, for a generator that emits one."""
|
||||
return [
|
||||
# No $ccache: the .gch embeds build dir paths
|
||||
"rule pch",
|
||||
" command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = PCH $out",
|
||||
]
|
||||
|
||||
|
||||
def pch_edges(
|
||||
lines: list[str],
|
||||
build_dir: Path,
|
||||
src_dir: Path,
|
||||
headers: Sequence[str],
|
||||
cxxflags: Sequence[str],
|
||||
src_flags: Sequence[str],
|
||||
identity: Sequence[str],
|
||||
cxx: Sequence[Path | str],
|
||||
) -> tuple[str, str] | None:
|
||||
"""Emit the precompiled header for the C++ src edges.
|
||||
|
||||
``headers`` are folded into one prefix header, ``src_flags`` are the
|
||||
flags every src edge carries, ``identity`` names what else the compile
|
||||
depends on, the compiler included, and ``cxx`` is what the host rule
|
||||
asks. Returns the ``cxx_override`` for ``compile_edges``, or None
|
||||
without a pch.
|
||||
"""
|
||||
if not pch_usable(cxx):
|
||||
return None
|
||||
if any(
|
||||
tok == "-include" or tok.startswith("--include") or is_joined_include(tok)
|
||||
for tok in cxxflags
|
||||
):
|
||||
# $cxxflags expands first and GCC only loads a .gch for the first
|
||||
# -include
|
||||
_LOGGER.warning(
|
||||
"A -include in the compiler flags prevents the precompiled header from "
|
||||
"loading; compiling without it"
|
||||
)
|
||||
return None
|
||||
log_pch_in_use()
|
||||
source = write_pch_headers(build_dir, headers)
|
||||
write_file_if_changed(
|
||||
build_dir / PCH_SUM_NAME,
|
||||
pch_identity([*cxxflags, *src_flags], src_dir, tuple(headers), identity) + "\n",
|
||||
)
|
||||
gch = _e(PCH_GCH_NAME)
|
||||
# The checksum file changes with anything the .gch depends on
|
||||
lines.append(f"build {gch}: pch {_e(source)} | {_e(PCH_SUM_NAME)}")
|
||||
if src_flags:
|
||||
lines.append(f" flags = {' '.join(src_flags)}")
|
||||
lines.append(f"srccxxflags = {' '.join([*src_flags, *pch_consumer_flags()])}")
|
||||
return ("$srccxxflags", gch)
|
||||
|
||||
|
||||
def ar_rule_lines(ar: Path | str) -> list[str]:
|
||||
return [
|
||||
"rule ar",
|
||||
@@ -111,20 +187,26 @@ def compile_edges(
|
||||
group: str,
|
||||
flags: str = "",
|
||||
own_includes: str = "",
|
||||
cxx_override: tuple[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Emit compile edges for ``sources``; return the object paths."""
|
||||
"""Emit compile edges for ``sources``; return the object paths.
|
||||
|
||||
``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges
|
||||
only, replacing ``flags`` (used for the precompiled header).
|
||||
"""
|
||||
objects = []
|
||||
for src in sources:
|
||||
rel = src.relative_to(root).as_posix()
|
||||
obj = f"obj/{group}/{rel}.o"
|
||||
escaped_obj = _e(obj)
|
||||
lines.append(
|
||||
f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}"
|
||||
)
|
||||
kind = SOURCE_KIND_FOR_SUFFIX[src.suffix]
|
||||
override = cxx_override if kind == "cxx" else None
|
||||
implicit = f" | {override[1]}" if override else ""
|
||||
lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}")
|
||||
if own_includes:
|
||||
lines.append(f" own_includes = {own_includes}")
|
||||
if flags:
|
||||
lines.append(f" flags = {flags}")
|
||||
if edge_flags := override[0] if override else flags:
|
||||
lines.append(f" flags = {edge_flags}")
|
||||
# Escaped once here: the returned paths only ever appear in build
|
||||
# statements (archive/link inputs), which use ninja escaping
|
||||
objects.append(escaped_obj)
|
||||
@@ -132,10 +214,12 @@ def compile_edges(
|
||||
|
||||
|
||||
def library_edges(
|
||||
lines: list[str], libraries: list[ArduinoLibrary]
|
||||
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Emit every library's compile and archive edges.
|
||||
|
||||
``extra_flags`` (raw ninja text) precede each library's own flags.
|
||||
|
||||
Returns the archive names and the objects that link directly. A
|
||||
library's own include dirs lead its compile lines, as PlatformIO searched
|
||||
them first: the include path is one global list, so another library's
|
||||
@@ -157,7 +241,7 @@ def library_edges(
|
||||
lib.sources,
|
||||
common_parent(lib.sources),
|
||||
f"lib/{lib.name}",
|
||||
flags=" ".join(_shell_token(f) for f in lib.flags),
|
||||
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
|
||||
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
|
||||
)
|
||||
if not lib.lib_archive:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Shared precompiled header policy for the build backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
||||
from esphome.const import PLATFORM_NRF52
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The header and its sidecars live in the build directory
|
||||
PCH_HEADER_NAME = "esphome_pch.h"
|
||||
PCH_GCH_NAME = f"{PCH_HEADER_NAME}.gch"
|
||||
# ccache hashes this instead of the .gch; also the freshness stamp
|
||||
PCH_SUM_NAME = f"{PCH_GCH_NAME}.sum"
|
||||
# The include list the .gch is compiled from
|
||||
PCH_SOURCE_NAME = "esphome_pch_src.h"
|
||||
|
||||
# GCC can skip a .gch without a diagnostic and read the header of the same
|
||||
# name, so that header is an error. Other tools get the include list.
|
||||
PCH_GUARD_TEXT = f"""\
|
||||
#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__)
|
||||
#error "The precompiled header was not loaded"
|
||||
#else
|
||||
#include "{PCH_SOURCE_NAME}"
|
||||
#endif
|
||||
"""
|
||||
|
||||
# The cc1plus wrapper the PlatformIO script writes on arm64 macOS
|
||||
PCH_CC1_DIR = "pch_cc1"
|
||||
|
||||
# What the PlatformIO script leaves in the project root, for cleanup
|
||||
PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME)
|
||||
PCH_ARTIFACT_DIRS = (PCH_CC1_DIR,)
|
||||
|
||||
# The core headers every backend precompiles
|
||||
PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",)
|
||||
|
||||
# PlatformIO platforms that do not take the pch script
|
||||
PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset(
|
||||
{
|
||||
PLATFORM_NRF52,
|
||||
}
|
||||
)
|
||||
|
||||
# What ccache needs to cache compiles that load a .gch
|
||||
_CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros")
|
||||
|
||||
# Both include forms: an angle include resolving under src/ enters the digest
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
|
||||
|
||||
def pch_enabled() -> bool:
|
||||
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
||||
|
||||
|
||||
def pch_forced() -> bool:
|
||||
"""``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no."""
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is True
|
||||
|
||||
|
||||
# GCC bug 14940: before these releases the Windows loader maps a .gch only
|
||||
# at its saved address. First fixed release per major, 16 on always fixed;
|
||||
# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table
|
||||
PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)}
|
||||
PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0)
|
||||
# The same rule for CMake, which alone knows the version before configure
|
||||
PCH_WINDOWS_CMAKE_OLD_GCC = (
|
||||
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
|
||||
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
|
||||
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)"
|
||||
)
|
||||
|
||||
|
||||
def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool:
|
||||
"""Whether a GCC of this version loads a .gch on Windows."""
|
||||
if not version:
|
||||
return False
|
||||
fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT)
|
||||
return tuple(version[:2]) >= fixed
|
||||
|
||||
|
||||
# GCC ends the first --version line with its version; clang names itself
|
||||
_VERSION_RE = re.compile(r"\d+(?:\.\d+)+")
|
||||
|
||||
|
||||
def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None:
|
||||
"""The GCC version from ``--version``: () when it cannot be read, None
|
||||
for a compiler that is not GCC."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[*cxx, "--version"], capture_output=True, text=True, check=False
|
||||
)
|
||||
except OSError as err:
|
||||
_LOGGER.debug("Cannot run %s: %s", cxx[0], err)
|
||||
return ()
|
||||
banner = result.stdout.partition("\n")[0]
|
||||
if "clang" in banner.lower():
|
||||
return None
|
||||
found = _VERSION_RE.findall(banner)
|
||||
return tuple(int(part) for part in found[-1].split(".")) if found else ()
|
||||
|
||||
|
||||
def pch_needs_gcc_check() -> bool:
|
||||
"""Windows host with the knob unset: the compiler version decides."""
|
||||
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
|
||||
|
||||
|
||||
def pch_usable(cxx: Sequence[Path | str]) -> bool:
|
||||
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
|
||||
if not pch_enabled():
|
||||
return False
|
||||
if not pch_needs_gcc_check():
|
||||
return True
|
||||
version = gcc_version(cxx)
|
||||
if version is None or gcc_relocates_pch_on_windows(version):
|
||||
return True
|
||||
_LOGGER.info(
|
||||
"GCC %s cannot load a precompiled header on Windows (GCC bug 14940, "
|
||||
"fixed in 14.4, 15.3 and 16); compiling without it "
|
||||
"(set ESPHOME_PCH_ENABLE=1 to force)",
|
||||
".".join(map(str, version)) or "of unknown version",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def pch_consumer_flags() -> list[str]:
|
||||
"""Flags a C++ src compile loads the pch with. The -include stays
|
||||
relative: an absolute path would enter the ccache key."""
|
||||
return ["-Winvalid-pch", "-Werror=invalid-pch", "-include", PCH_HEADER_NAME]
|
||||
|
||||
|
||||
def ccache_pch_env() -> dict[str, str]:
|
||||
"""What ccache needs to cache compiles that load a .gch, added to what
|
||||
the user already set."""
|
||||
if not pch_enabled():
|
||||
return {}
|
||||
sloppiness = [
|
||||
item.strip()
|
||||
for item in os.environ.get("CCACHE_SLOPPINESS", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
sloppiness += [item for item in _CCACHE_PCH_SLOPPINESS if item not in sloppiness]
|
||||
env = {"CCACHE_SLOPPINESS": ",".join(sloppiness)}
|
||||
if "CCACHE_PCH_EXTSUM" not in os.environ:
|
||||
env["CCACHE_PCH_EXTSUM"] = "true"
|
||||
return env
|
||||
|
||||
|
||||
def pch_script_enabled() -> bool:
|
||||
"""Whether this PlatformIO build takes the pch script."""
|
||||
from esphome.core import CORE
|
||||
|
||||
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
"""The prefix-header source: exactly these includes, in order."""
|
||||
return "".join(f'#include "{name}"\n' for name in include_headers)
|
||||
|
||||
|
||||
def write_pch_headers(build_dir: Path, include_headers: Iterable[str]) -> Path:
|
||||
"""Write the guard header and the include list; return the latter,
|
||||
which is what the .gch compiles from."""
|
||||
write_file_if_changed(build_dir / PCH_HEADER_NAME, PCH_GUARD_TEXT)
|
||||
source = build_dir / PCH_SOURCE_NAME
|
||||
write_file_if_changed(source, pch_header_text(include_headers))
|
||||
return source
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
|
||||
"""Include closure of ``roots``: src-relative name -> contents.
|
||||
|
||||
Resolution mirrors the compiler (includer's dir, then src root). No
|
||||
#ifdef evaluation: including too much is the safe direction. Headers
|
||||
outside ``src_dir`` are covered by the version strings of the caller.
|
||||
"""
|
||||
seen: dict[str, bytes] = {}
|
||||
stack: list[tuple[str, str]] = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and (src_dir / rel).is_file():
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
data = seen[rel] = (src_dir / rel).read_bytes()
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data))
|
||||
return seen
|
||||
|
||||
|
||||
def pch_checksum(
|
||||
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
|
||||
) -> str:
|
||||
"""Digest of the prefix header's include closure plus ``extra``."""
|
||||
digest = hashlib.sha256()
|
||||
closure = _include_closure(src_dir, include_headers)
|
||||
for name in sorted(closure):
|
||||
digest.update(name.encode())
|
||||
digest.update(closure[name])
|
||||
digest.update(b"\0")
|
||||
for item in extra:
|
||||
digest.update(item.encode())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def pch_identity(
|
||||
tokens: Iterable[str],
|
||||
src_dir: Path,
|
||||
include_headers: tuple[str, ...],
|
||||
extra: Iterable[str],
|
||||
) -> str:
|
||||
"""The .sum digest: include closure, header text, ``extra`` and the
|
||||
compile flags with the build path stripped, as ccache does."""
|
||||
from esphome.core import CORE
|
||||
|
||||
flags = (
|
||||
" ".join(tokens)
|
||||
.replace(str(CORE.build_path), "")
|
||||
.replace(effective_ccache_basedir(), "")
|
||||
)
|
||||
# The closure is sorted, so header order only enters via the text
|
||||
return pch_checksum(
|
||||
src_dir, include_headers, (pch_header_text(include_headers), *extra, flags)
|
||||
)
|
||||
|
||||
|
||||
_DISABLE_HINT = " (set ESPHOME_PCH_ENABLE=0 to disable)"
|
||||
|
||||
|
||||
def log_pch_in_use() -> None:
|
||||
_LOGGER.info("Compiling with a precompiled header%s", _DISABLE_HINT)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Run a native build tool (cmake, ninja) and relay its output.
|
||||
|
||||
Output is read from a pipe so it can be filtered here: a child that inherits
|
||||
our stdout writes straight to the file descriptor, past any Python wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, TextIO
|
||||
|
||||
from esphome.util import ANSI_ESCAPE, RedirectText, shlex_quote
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Windows code page identifier for UTF-8, as used by ``chcp 65001``.
|
||||
UTF8_CODEPAGE = 65001
|
||||
|
||||
# Same pattern idf.py uses to spot ninja status lines (``is_progression``).
|
||||
_PROGRESS = re.compile(r"^\[\d+/\d+\]|.*\(\d+ \%\)$")
|
||||
_READ_SIZE = 65536
|
||||
|
||||
|
||||
def _get_kernel32() -> Any | None:
|
||||
"""Return the Windows kernel32 module, or None on any other platform."""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
import ctypes
|
||||
|
||||
return ctypes.windll.kernel32
|
||||
|
||||
|
||||
class Utf8Console:
|
||||
"""Keep an attached Windows console on UTF-8 while a build tool runs.
|
||||
|
||||
esp_idf_size draws its table with Unicode box characters, and CMake
|
||||
re-decodes a child's output with the console code page, which garbles the
|
||||
table on any page but UTF-8. A console already on UTF-8 is left alone so
|
||||
an overlapping build never records UTF-8 as the page to go back to.
|
||||
"""
|
||||
|
||||
def __init__(self, kernel32: Any | None) -> None:
|
||||
self._kernel32 = kernel32
|
||||
self._codepages: tuple[int, int] | None = None
|
||||
|
||||
def __enter__(self) -> None:
|
||||
kernel32 = self._kernel32
|
||||
if kernel32 is None:
|
||||
return
|
||||
old_in = kernel32.GetConsoleCP()
|
||||
old_out = kernel32.GetConsoleOutputCP()
|
||||
# Both calls return 0 when no console is attached.
|
||||
if not old_in or not old_out:
|
||||
return
|
||||
if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE:
|
||||
return
|
||||
# Record first so a switch that fails part way is still undone.
|
||||
self._codepages = (old_in, old_out)
|
||||
kernel32.SetConsoleCP(UTF8_CODEPAGE)
|
||||
kernel32.SetConsoleOutputCP(UTF8_CODEPAGE)
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
if self._codepages is None:
|
||||
return
|
||||
old_in, old_out = self._codepages
|
||||
self._codepages = None
|
||||
self._kernel32.SetConsoleCP(old_in)
|
||||
self._kernel32.SetConsoleOutputCP(old_out)
|
||||
|
||||
|
||||
def _fit_terminal(text: str) -> str:
|
||||
"""Elide the middle of ``text`` to fit the terminal, as idf.py does.
|
||||
|
||||
A width of 0 (a pipe, the dashboard) leaves the text whole.
|
||||
"""
|
||||
width = shutil.get_terminal_size((0, 0)).columns
|
||||
if not width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return "." * width
|
||||
if len(text) >= width:
|
||||
keep = (width - 3) // 2
|
||||
return f"{text[:keep]}...{text[len(text) - keep :]}"
|
||||
return text
|
||||
|
||||
|
||||
class ToolOutput(RedirectText):
|
||||
"""RedirectText that can collapse ninja status lines into one line.
|
||||
|
||||
With ``progress`` each ``[n/m]`` line overwrites the previous one, the
|
||||
way idf.py shows a build.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, out: TextIO, filter_lines: list[str] | None, progress: bool
|
||||
) -> None:
|
||||
super().__init__(out, filter_lines=filter_lines)
|
||||
self._progress = progress
|
||||
self._on_progress_line = False
|
||||
|
||||
def _splits_lines(self) -> bool:
|
||||
return self._progress or super()._splits_lines()
|
||||
|
||||
def _emit_line(self, line: str) -> None:
|
||||
if self._progress and _PROGRESS.match(line):
|
||||
if not self._is_filtered(line):
|
||||
text = _fit_terminal(line.strip("\r\n"))
|
||||
self._write_color_replace(f"\r{text}\x1b[K")
|
||||
self._on_progress_line = True
|
||||
return
|
||||
self._end_progress_line()
|
||||
super()._emit_line(line)
|
||||
|
||||
def _end_progress_line(self) -> None:
|
||||
if self._on_progress_line:
|
||||
self._on_progress_line = False
|
||||
self._write_color_replace(os.linesep)
|
||||
|
||||
def drain(self) -> None:
|
||||
super().drain()
|
||||
# Called from cleanup, so a broken stream must not hide the exit code.
|
||||
with suppress(OSError, ValueError):
|
||||
self._end_progress_line()
|
||||
self._out.flush()
|
||||
|
||||
|
||||
def run_build_tool(
|
||||
cmd: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str],
|
||||
filter_lines: list[str] | None = None,
|
||||
progress: bool = False,
|
||||
log_path: Path | None = None,
|
||||
) -> int:
|
||||
"""Run ``cmd`` and relay stdout and stderr, merged, to our stdout.
|
||||
|
||||
``log_path`` also gets the full, unfiltered output without color codes, as
|
||||
idf.py wrote its logs (its hint patterns expect plain text). Returns the
|
||||
exit code.
|
||||
"""
|
||||
_LOGGER.debug("Running: %s", " ".join(shlex_quote(arg) for arg in cmd))
|
||||
_LOGGER.debug(" in directory: %s", cwd)
|
||||
output = ToolOutput(sys.stdout, filter_lines, progress)
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
if log_path is not None:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
Path(log_path or os.devnull).open("w", encoding="utf-8", newline="") as log,
|
||||
Utf8Console(_get_kernel32()),
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
close_fds=False,
|
||||
) as proc,
|
||||
):
|
||||
try:
|
||||
# read1 returns as soon as anything is available, so output
|
||||
# streams while the tool runs.
|
||||
while chunk := proc.stdout.read1(_READ_SIZE):
|
||||
text = decoder.decode(chunk)
|
||||
log.write(ANSI_ESCAPE.sub("", text))
|
||||
output.write(text)
|
||||
if tail := decoder.decode(b"", final=True):
|
||||
log.write(ANSI_ESCAPE.sub("", tail))
|
||||
output.write(tail)
|
||||
finally:
|
||||
output.drain()
|
||||
return proc.returncode
|
||||
@@ -16,8 +16,8 @@ def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
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
|
||||
# resolve(): a symlinked prefix would otherwise record a second
|
||||
# spelling of the same paths in the build tree
|
||||
return Path(prefix).expanduser().resolve()
|
||||
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||
# deep IDF trees run into MAX_PATH otherwise
|
||||
@@ -35,9 +35,12 @@ SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
# The host backend installs nothing; the entry only holds its ccache dir
|
||||
HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host")
|
||||
# PlatformIO installs into its own dirs; the entry is its ccache dir itself
|
||||
PLATFORMIO_CCACHE = ("ESPHOME_PLATFORMIO_CCACHE_DIR", "platformio-ccache")
|
||||
TOOLS_CACHE_SPECS = (
|
||||
IDF_TOOLS_CACHE,
|
||||
SDK_NRF_TOOLS_CACHE,
|
||||
ARDUINO8266_TOOLS_CACHE,
|
||||
HOST_TOOLS_CACHE,
|
||||
PLATFORMIO_CCACHE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Commands of the esphome command line, one module each, imported by
|
||||
__main__ only when they run so that startup stays light."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""``esphome rename``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome import yaml_edit, yaml_util
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
CONF_ESPHOME,
|
||||
CONF_NAME,
|
||||
CONF_SUBSTITUTIONS,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.log import AnsiFore, color
|
||||
from esphome.types import ConfigType
|
||||
from esphome.util import ESPHOME_COMMAND, run_external_process, safe_print
|
||||
|
||||
|
||||
def _revert(new_path: Path, why: str) -> int:
|
||||
"""Say why the rename stopped and take the new file back; an orphan the
|
||||
next attempt would trip over is reported."""
|
||||
safe_print(color(AnsiFore.BOLD_RED, f"Rename failed: {why}"))
|
||||
try:
|
||||
new_path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
safe_print(color(AnsiFore.BOLD_RED, f"Could not remove {new_path}: {err}"))
|
||||
return 1
|
||||
|
||||
|
||||
def command_rename(args: argparse.Namespace, config: ConfigType) -> int | None:
|
||||
"""Rename the device: a new file with the name line rewritten, validated
|
||||
and installed, then the old file removed."""
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
|
||||
def name_edit() -> tuple[str, yaml_edit.LineEdit]:
|
||||
"""The name and the line to rewrite: the name's own line, or the
|
||||
substitution's line it comes from, as a plain value in this file."""
|
||||
esphome_conf = yaml.get(CONF_ESPHOME)
|
||||
if not isinstance(esphome_conf, dict) or CONF_NAME not in esphome_conf:
|
||||
raise EsphomeError(f"no '{CONF_ESPHOME}: {CONF_NAME}:' in the file")
|
||||
old_name = str(esphome_conf[CONF_NAME])
|
||||
mapping, field = esphome_conf, CONF_NAME
|
||||
if match := re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name):
|
||||
mapping, field = yaml.get(CONF_SUBSTITUTIONS), match.group(1)
|
||||
if not isinstance(mapping, dict) or field not in mapping:
|
||||
raise EsphomeError(f"the substitution '{field}' is not in the file")
|
||||
old_name = str(mapping[field])
|
||||
# Only read here; the rewritten text goes to a new file, so the
|
||||
# source may live anywhere the config path points to
|
||||
source = yaml_edit.source_of(mapping, field)
|
||||
if source is None or source[0].resolve() != CORE.config_path.resolve():
|
||||
raise EsphomeError(f"'{field}' was not read from {CORE.config_path}")
|
||||
doc, line_no = source
|
||||
text = yaml_edit.line_at(doc, line_no)
|
||||
if (line_match := yaml_edit.field_line_re(field, old_name).match(text)) is None:
|
||||
raise EsphomeError(f"'{field}' is not a plain value on {doc}:{line_no + 1}")
|
||||
# The new value is always quoted, whatever the old line had
|
||||
return old_name, yaml_edit.LineEdit(
|
||||
doc, line_no, text, yaml_edit.rewrite(line_match, new_name, '"')
|
||||
)
|
||||
|
||||
try:
|
||||
old_name, edit = name_edit()
|
||||
except EsphomeError as err:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Complex YAML files cannot be automatically renamed: {err}",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
# ``new_name == old_name`` (after substitution resolution) is
|
||||
# a no-op rewrite that would still queue a pointless re-flash.
|
||||
# Catch it before the path-equality check below — covers the
|
||||
# case where the config filename doesn't match the device name
|
||||
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
"Refusing to overwrite an existing configuration.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
|
||||
try:
|
||||
yaml_edit.write_keeping_mode(
|
||||
new_path,
|
||||
yaml_edit.rewritten_text(yaml_edit.read_text(CORE.config_path), [edit]),
|
||||
like=CORE.config_path,
|
||||
)
|
||||
except EsphomeError as err:
|
||||
return _revert(new_path, str(err))
|
||||
|
||||
if run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) != 0:
|
||||
return _revert(new_path, "the new configuration does not validate")
|
||||
|
||||
cli_args = [
|
||||
"run",
|
||||
str(new_path),
|
||||
"--no-logs",
|
||||
"--device",
|
||||
CORE.address,
|
||||
]
|
||||
|
||||
if args.dashboard:
|
||||
cli_args.insert(0, "--dashboard")
|
||||
|
||||
try:
|
||||
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
|
||||
except KeyboardInterrupt:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
return _revert(
|
||||
new_path,
|
||||
"the install did not finish; the device may already run the new name",
|
||||
)
|
||||
|
||||
CORE.config_path.unlink()
|
||||
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
@@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add_global,
|
||||
add_library,
|
||||
add_platformio_option,
|
||||
extern_progmem_array,
|
||||
get_variable,
|
||||
get_variable_with_full_id,
|
||||
is_template,
|
||||
@@ -40,6 +41,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
progmem_array,
|
||||
safe_exp,
|
||||
set_cpp_standard,
|
||||
shared_progmem_array,
|
||||
statement,
|
||||
static_const_array,
|
||||
static_function,
|
||||
|
||||
@@ -62,16 +62,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
sens = await sensor.new_sensor(config[CONF_TVOC])
|
||||
cg.add(var.set_tvoc(sens))
|
||||
|
||||
if version_config := config.get(CONF_VERSION):
|
||||
sens = await sensor.new_sensor(version_config)
|
||||
cg.add(var.set_version(sens))
|
||||
|
||||
if resistance_config := config.get(CONF_RESISTANCE):
|
||||
sens = await sensor.new_sensor(resistance_config)
|
||||
cg.add(var.set_resistance(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TVOC, var.set_tvoc)
|
||||
await sensors(CONF_VERSION, var.set_version)
|
||||
await sensors(CONF_RESISTANCE, var.set_resistance)
|
||||
|
||||
|
||||
AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
|
||||
@@ -57,10 +57,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await i2c.register_i2c_device(var, config)
|
||||
cg.add(var.set_variant(config[CONF_VARIANT]))
|
||||
|
||||
if temperature := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -74,26 +74,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
if flow_config := config.get(CONF_FLOW):
|
||||
sens = await sensor.new_sensor(flow_config)
|
||||
cg.add(var.set_flow_sensor(sens))
|
||||
|
||||
if head_config := config.get(CONF_HEAD):
|
||||
sens = await sensor.new_sensor(head_config)
|
||||
cg.add(var.set_head_sensor(sens))
|
||||
|
||||
if power_config := config.get(CONF_POWER):
|
||||
sens = await sensor.new_sensor(power_config)
|
||||
cg.add(var.set_power_sensor(sens))
|
||||
|
||||
if current_config := config.get(CONF_CURRENT):
|
||||
sens = await sensor.new_sensor(current_config)
|
||||
cg.add(var.set_current_sensor(sens))
|
||||
|
||||
if speed_config := config.get(CONF_SPEED):
|
||||
sens = await sensor.new_sensor(speed_config)
|
||||
cg.add(var.set_speed_sensor(sens))
|
||||
|
||||
if voltage_config := config.get(CONF_VOLTAGE):
|
||||
sens = await sensor.new_sensor(voltage_config)
|
||||
cg.add(var.set_voltage_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_FLOW, var.set_flow_sensor)
|
||||
await sensors(CONF_HEAD, var.set_head_sensor)
|
||||
await sensors(CONF_POWER, var.set_power_sensor)
|
||||
await sensors(CONF_CURRENT, var.set_current_sensor)
|
||||
await sensors(CONF_SPEED, var.set_speed_sensor)
|
||||
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
|
||||
|
||||
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity_config := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity_config)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
|
||||
if humidity_config := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity_config)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
if battery_level_config := config.get(CONF_BATTERY_LEVEL):
|
||||
sens = await sensor.new_sensor(battery_level_config)
|
||||
cg.add(var.set_battery(sens))
|
||||
|
||||
if illuminance_config := config.get(CONF_ILLUMINANCE):
|
||||
sens = await sensor.new_sensor(illuminance_config)
|
||||
cg.add(var.set_illuminance(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery)
|
||||
await sensors(CONF_ILLUMINANCE, var.set_illuminance)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
@@ -5,7 +6,7 @@ from typing import Any
|
||||
from esphome import automation
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
|
||||
from esphome.components.logger import request_log_listener
|
||||
|
||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||
@@ -13,6 +14,7 @@ from esphome.components.logger import request_log_listener
|
||||
from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
enable_spare_ephemeral,
|
||||
encryption_schema,
|
||||
new_psk_progmem,
|
||||
validate_encryption_key,
|
||||
@@ -25,6 +27,8 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_DELAY,
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
@@ -47,10 +51,14 @@ from esphome.const import (
|
||||
CONF_VARIABLES,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.helpers import fnv1_hash
|
||||
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import cpp_string_escape, fnv1_hash
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
|
||||
from . import wizard
|
||||
|
||||
# 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
|
||||
@@ -85,6 +93,7 @@ APIServer = api_ns.class_("APIServer", cg.Component)
|
||||
HomeAssistantServiceCallAction = api_ns.class_(
|
||||
"HomeAssistantServiceCallAction", automation.Action
|
||||
)
|
||||
HomeAssistantField = api_ns.struct("HomeAssistantField")
|
||||
ActionResponse = api_ns.class_("ActionResponse")
|
||||
HomeAssistantActionResponseTrigger = api_ns.class_(
|
||||
"HomeAssistantActionResponseTrigger", automation.Trigger
|
||||
@@ -134,6 +143,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
CONF_LISTEN_BACKLOG = "listen_backlog"
|
||||
CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_OUTGOING_CONNECTION = "outgoing_connection"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
# Schema defaults that also match the C++ initializers in api_server.h; codegen
|
||||
@@ -141,6 +151,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
DEFAULT_PORT = 6053
|
||||
DEFAULT_REBOOT_TIMEOUT = "15min"
|
||||
DEFAULT_BATCH_DELAY = "100ms"
|
||||
DEFAULT_LISTEN_BACKLOG = 4
|
||||
|
||||
|
||||
def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
@@ -291,9 +302,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
# (not max_connections, which is the upper limit rarely reached)
|
||||
socket.consume_sockets(3, "api")(config)
|
||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||
if CONF_OUTGOING_CONNECTION in config:
|
||||
socket.consume_sockets(1, "api_outgoing_connection")(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
|
||||
return config
|
||||
if CONF_ENCRYPTION not in config:
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection requires 'encryption' so the peer is verified by key",
|
||||
path=[CONF_OUTGOING_CONNECTION],
|
||||
)
|
||||
# A device with no client reboots once reboot_timeout passes, so a delay that
|
||||
# reaches it would reboot the device before it ever dials
|
||||
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
|
||||
delay = outgoing[CONF_DELAY]
|
||||
if reboot_timeout.total_milliseconds and delay >= reboot_timeout:
|
||||
raise cv.Invalid(
|
||||
f"delay must be shorter than reboot_timeout ({reboot_timeout}), "
|
||||
"otherwise the device reboots before it dials",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_DELAY],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
|
||||
"""Only accept an address the device itself can parse.
|
||||
|
||||
Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton`
|
||||
takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build.
|
||||
"""
|
||||
address = cv.ipaddress(value)
|
||||
if isinstance(address, IPv6Address):
|
||||
if address.scope_id is not None:
|
||||
raise cv.Invalid(
|
||||
f"{value} carries a scope id, which the device cannot parse; "
|
||||
"give the address without the '%' part"
|
||||
)
|
||||
if (mapped := address.ipv4_mapped) is not None:
|
||||
return mapped
|
||||
return address
|
||||
|
||||
|
||||
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_HOST): _validate_outgoing_host,
|
||||
cv.Optional(CONF_PORT, default=6054): cv.port,
|
||||
# Bounded against reboot_timeout in _validate_outgoing_connection
|
||||
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
|
||||
# A bare `outgoing_connection:` block is valid; without a host the device
|
||||
# dials the remembered last dial-back client
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Let the language-schema dumper walk host, port and delay
|
||||
return _OUTGOING_CONNECTION_SCHEMA
|
||||
if config is None:
|
||||
config = {}
|
||||
return _OUTGOING_CONNECTION_SCHEMA(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -318,6 +392,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
): ACTIONS_SCHEMA,
|
||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA,
|
||||
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
|
||||
cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
@@ -373,6 +449,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_validate_outgoing_connection,
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
@@ -429,7 +506,34 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
|
||||
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
|
||||
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
|
||||
if (
|
||||
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
|
||||
or (host := outgoing.get(CONF_HOST)) is None
|
||||
or host.version != 6
|
||||
):
|
||||
return config
|
||||
network_conf = fv.full_config.get().get("network") or {}
|
||||
if not network_conf.get(CONF_ENABLE_IPV6):
|
||||
raise cv.Invalid(
|
||||
"outgoing_connection host is an IPv6 address but IPv6 is not "
|
||||
"enabled; set 'network: enable_ipv6: true'",
|
||||
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_wizard(config: ConfigType) -> ConfigType:
|
||||
wizard.final_validate(config)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
_validate_esp8266_action_strings,
|
||||
_validate_outgoing_host_ipv6,
|
||||
_validate_wizard,
|
||||
)
|
||||
|
||||
|
||||
def _add_action_strings(
|
||||
@@ -476,8 +580,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_reboot_timeout(reboot_timeout))
|
||||
if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY):
|
||||
cg.add(var.set_batch_delay(batch_delay))
|
||||
if CONF_LISTEN_BACKLOG in config:
|
||||
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
|
||||
if (
|
||||
listen_backlog := config.get(CONF_LISTEN_BACKLOG)
|
||||
) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG:
|
||||
cg.add(var.set_listen_backlog(listen_backlog))
|
||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||
|
||||
@@ -582,6 +688,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
# 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 (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
|
||||
await wizard.to_code(wizard_config)
|
||||
|
||||
if CONF_ON_CLIENT_CONNECTED in config:
|
||||
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
|
||||
await automation.build_automation(
|
||||
@@ -600,7 +709,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
|
||||
cg.add(var.set_noise_psk(new_psk_progmem(key)))
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
@@ -613,9 +722,17 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
cg.add_define("USE_API_NOISE")
|
||||
enable_spare_ephemeral()
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
|
||||
cg.add_define("USE_API_OUTGOING_CONNECTION")
|
||||
if (host := outgoing.get(CONF_HOST)) is not None:
|
||||
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
|
||||
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
|
||||
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
|
||||
|
||||
cg.add_define("USE_API")
|
||||
cg.add_global(api_ns.using)
|
||||
|
||||
@@ -656,6 +773,11 @@ VARIABLES_SCHEMA = cv.Schema(
|
||||
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||
)
|
||||
|
||||
# The action stores each map's entry count in a uint8_t
|
||||
_FIELD_MAP_MAX = 255
|
||||
DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
|
||||
VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
|
||||
|
||||
|
||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||
# Validate dependencies:
|
||||
@@ -690,9 +812,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable(
|
||||
cv.string
|
||||
),
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||
@@ -705,6 +827,62 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _field_string(value: str) -> Expression:
|
||||
# ESP8266 can only keep a string in flash as its own PROGMEM array
|
||||
literal = cg.RawExpression(cpp_string_escape(value))
|
||||
if CORE.is_esp8266:
|
||||
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
|
||||
return literal
|
||||
|
||||
|
||||
async def _new_service_call_action(
|
||||
server_id: ID,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
is_event: bool,
|
||||
service: Any,
|
||||
data: dict[str, Any],
|
||||
data_template: dict[str, Any],
|
||||
variables: dict[str, Any],
|
||||
) -> MockObj:
|
||||
"""Create the action with its name and fields in one shared flash table."""
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(server_id)
|
||||
field_type = HomeAssistantField.template(template_arg)
|
||||
groups = [data, data_template, variables]
|
||||
# A lambda may keep static state, so a table with lambdas is never shared
|
||||
has_lambda = cg.is_template(service) or any(
|
||||
cg.is_template(value) for group in groups for value in group.values()
|
||||
)
|
||||
|
||||
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
|
||||
key_exp = cg.nullptr if key is None else _field_string(key)
|
||||
if cg.is_template(value):
|
||||
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
|
||||
lam = await cg.process_lambda(value, args, return_type=output_type)
|
||||
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
|
||||
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
|
||||
|
||||
entries = [await field(None, service, cg.std_string)]
|
||||
for group in groups:
|
||||
for key, value in group.items():
|
||||
entries.append(await field(key, value))
|
||||
table = cg.shared_progmem_array(
|
||||
"ha_action_fields",
|
||||
field_type,
|
||||
cg.ArrayInitializer(*entries, multiline=True),
|
||||
share=not has_lambda,
|
||||
)
|
||||
return cg.new_Pvariable(
|
||||
action_id, template_arg, serv, is_event, table, *(len(g) for g in groups)
|
||||
)
|
||||
|
||||
|
||||
def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]:
|
||||
return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES]
|
||||
|
||||
|
||||
# synchronous=False: when on_success/on_error is configured, play() stores the
|
||||
# trigger args until the HomeassistantActionResponse arrives, so non-owning args
|
||||
# (StringRef into the API receive buffer) must not be used.
|
||||
@@ -726,36 +904,15 @@ async def homeassistant_service_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
||||
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
|
||||
cg.add(var.set_service(templ))
|
||||
|
||||
# Initialize FixedVectors with exact sizes from config
|
||||
cg.add(var.init_data(len(config[CONF_DATA])))
|
||||
for key, value in config[CONF_DATA].items():
|
||||
# output_type=None because lambdas can return non-string types (int,
|
||||
# float, char*) that TemplatableStringValue converts via to_string.
|
||||
# Static strings are manually wrapped for PROGMEM on ESP8266.
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
|
||||
for key, value in config[CONF_DATA_TEMPLATE].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
var = await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
False,
|
||||
config[CONF_ACTION],
|
||||
*_service_call_fields(config),
|
||||
)
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
|
||||
@@ -807,9 +964,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(APIServer),
|
||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -828,38 +985,15 @@ async def homeassistant_event_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
|
||||
cg.add(var.set_service(templ))
|
||||
|
||||
# Initialize FixedVectors with exact sizes from config
|
||||
cg.add(var.init_data(len(config[CONF_DATA])))
|
||||
for key, value in config[CONF_DATA].items():
|
||||
# output_type=None because lambdas can return non-string types (int,
|
||||
# float, char*) that TemplatableStringValue converts via to_string.
|
||||
# Static strings are manually wrapped for PROGMEM on ESP8266.
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
|
||||
for key, value in config[CONF_DATA_TEMPLATE].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
return await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
True,
|
||||
config[CONF_EVENT],
|
||||
*_service_call_fields(config),
|
||||
)
|
||||
|
||||
|
||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
@@ -883,15 +1017,17 @@ async def homeassistant_tag_scanned_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
|
||||
# Initialize FixedVector with exact size (1 data field)
|
||||
cg.add(var.init_data(1))
|
||||
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
|
||||
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
|
||||
return var
|
||||
return await _new_service_call_action(
|
||||
config[CONF_ID],
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
True,
|
||||
"esphome.tag_scanned",
|
||||
{"tag_id": config[CONF_TAG]},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
CONF_SUCCESS = "success"
|
||||
@@ -1002,6 +1138,8 @@ _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",
|
||||
"api_wizard.cpp": "USE_API_WIZARD",
|
||||
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ service APIConnection {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {}
|
||||
rpc wizard_input_set (WizardInputSetRequest) returns (void) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -76,8 +78,11 @@ service APIConnection {
|
||||
rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {}
|
||||
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
|
||||
rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
|
||||
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +118,11 @@ message HelloRequest {
|
||||
string client_info = 1;
|
||||
uint32 api_version_major = 2;
|
||||
uint32 api_version_minor = 3;
|
||||
|
||||
// Set by clients that can accept connections the device opens to them
|
||||
// (see api: outgoing_connection:). The device remembers this client's
|
||||
// address as the target to dial when no such client is connected.
|
||||
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// Confirmation of successful connection request.
|
||||
@@ -228,6 +238,7 @@ enum SerialProxyPortType {
|
||||
SERIAL_PROXY_PORT_TYPE_TTL = 0;
|
||||
SERIAL_PROXY_PORT_TYPE_RS232 = 1;
|
||||
SERIAL_PROXY_PORT_TYPE_RS485 = 2;
|
||||
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; // since API 1.18
|
||||
}
|
||||
|
||||
message SerialProxyInfo {
|
||||
@@ -332,6 +343,10 @@ message DeviceInfoResponse {
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
|
||||
// Device is built with the api outgoing_connection option and can open
|
||||
// the TCP connection to a dial-back target itself
|
||||
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
@@ -380,6 +395,16 @@ message ZWaveProxyCapabilities {
|
||||
uint32 home_id = 2;
|
||||
}
|
||||
|
||||
message WizardCapabilities {
|
||||
// True when the device has a wizard configured, so DeviceWizardRequest will be answered
|
||||
bool configured = 1;
|
||||
}
|
||||
|
||||
message SendspinCapabilities {
|
||||
// Bitmask of the Sendspin features this device supports
|
||||
uint32 feature_flags = 1;
|
||||
}
|
||||
|
||||
message DeviceCapabilitiesResponse {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -389,6 +414,102 @@ message DeviceCapabilitiesResponse {
|
||||
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
repeated SerialProxyInfo serial_proxies = 4
|
||||
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
|
||||
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE WIZARD ====================
|
||||
|
||||
// Asks the device for the onboarding wizard that Home Assistant presents when the
|
||||
// device is added.
|
||||
//
|
||||
// The wizard is read once, when the device is added, whereas capabilities are read
|
||||
// on every connect, so it is a separate message rather than part of
|
||||
// DeviceCapabilitiesResponse. It is only served on an authenticated connection.
|
||||
//
|
||||
// A device without a wizard does not have this message at all and never answers it,
|
||||
// as a device ignores messages it does not know. Clients therefore check
|
||||
// DeviceCapabilitiesResponse.wizard.configured before sending it.
|
||||
message DeviceWizardRequest {
|
||||
option (id) = 156;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_API_WIZARD";
|
||||
// Empty
|
||||
}
|
||||
|
||||
// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it
|
||||
// when it is compiled and sends it unchanged, so its size varies with the wizard and
|
||||
// its format can grow without new fields here. Clients decompress it, then read:
|
||||
//
|
||||
// {"version": 1,
|
||||
// "pages": [{"title": "...", "description": "...",
|
||||
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
// "entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
//
|
||||
// Values that are empty or unset are left out, and so are empty lists. Strings may be
|
||||
// Home Assistant translation placeholders such as "[%key:component::domain::name%]",
|
||||
// passed through verbatim. The pages are shown in the order listed. A client that does
|
||||
// not know the version must not use the wizard.
|
||||
//
|
||||
// An entity is an entity of this device that the page shows to the user (for example to
|
||||
// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its
|
||||
// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0.
|
||||
//
|
||||
// An input is filled in by the user with the id of a Home Assistant entity. It either
|
||||
// stands for one entity of the device's homeassistant platform (sensor, binary_sensor,
|
||||
// text_sensor, number, switch, text, select or button) that sets no entity_id of its own,
|
||||
// as Home Assistant supplies it, or it is a standalone input that only stores the entity
|
||||
// id for the device's own automations. Its key is the FNV-1 hash
|
||||
// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to
|
||||
// the device with a WizardInputSetRequest. entity_filters says which Home Assistant
|
||||
// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig:
|
||||
// an entity matches a filter when it satisfies every field that is set, and an entity
|
||||
// that matches any one of the filters is accepted.
|
||||
//
|
||||
// An input with "multiple": true lets the user pick several entities. The client sends
|
||||
// them in one WizardInputSetRequest as a comma separated list with no spaces
|
||||
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
|
||||
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
|
||||
// longest list in bytes the device accepts. The version stays 1: a client that does not
|
||||
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
|
||||
// multiple input accepts.
|
||||
//
|
||||
// The device keeps the entity id of an input in RAM only and does not store it across
|
||||
// restarts. The client therefore stores the choice itself and, after every connection
|
||||
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
|
||||
// for each input. It sends one again whenever the user changes the choice.
|
||||
message DeviceWizardResponse {
|
||||
option (id) = 157;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_API_WIZARD";
|
||||
|
||||
// The data is in flash, and the device sends it from there
|
||||
bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true];
|
||||
}
|
||||
|
||||
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
|
||||
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
|
||||
// reply. The device ignores a request whose key matches no input. A single input is
|
||||
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
|
||||
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
|
||||
// spaces, the device trims spaces around each id and stores the list without them. The
|
||||
// whole request is ignored when any id is empty or has no '.', or when the list without
|
||||
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
|
||||
// or list in RAM,
|
||||
// not across restarts, and when the input belongs to a homeassistant platform entity
|
||||
// it subscribes to that entity's state. Clients send this for every input right
|
||||
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
|
||||
// arrives later still works: the device then sends the affected state subscriptions
|
||||
// again.
|
||||
message WizardInputSetRequest {
|
||||
option (id) = 158;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_API_WIZARD_INPUTS";
|
||||
|
||||
fixed32 key = 1;
|
||||
string entity_id = 2 [(max_data_length) = 4095];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
@@ -1439,7 +1560,7 @@ message ListEntitiesSelectResponse {
|
||||
reserved 4; // Deprecated: was string unique_id
|
||||
|
||||
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
|
||||
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
|
||||
bool disabled_by_default = 7;
|
||||
EntityCategory entity_category = 8;
|
||||
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
|
||||
@@ -2683,7 +2804,7 @@ message ListEntitiesInfraredResponse {
|
||||
message InfraredRFTransmitRawTimingsRequest {
|
||||
option (id) = 136;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
@@ -2697,7 +2818,7 @@ message InfraredRFTransmitRawTimingsRequest {
|
||||
message InfraredRFReceiveEvent {
|
||||
option (id) = 137;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
@@ -2706,6 +2827,28 @@ message InfraredRFReceiveEvent {
|
||||
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
|
||||
}
|
||||
|
||||
// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the
|
||||
// transmitter reports that the transmission (all repeats) has finished, or immediately with
|
||||
// success=false if it was refused before reaching the transmitter (unknown key, no transmitter,
|
||||
// no or invalid timings) or the transmitter could not send it (not set up, hardware error).
|
||||
// success=false also answers a request superseded by a newer request on the same entity, from
|
||||
// any client, and a transmit that reported nothing within 30 s past its expected air time, so a
|
||||
// client should not retry on it blindly. The device serializes transmits per transmitter and
|
||||
// several entities may share one, so a client should keep at most one transmit outstanding per
|
||||
// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost
|
||||
// when the device's send buffer is full, so a client should also stop waiting on its own after the
|
||||
// expected air time plus a margin. Lets clients pace requests instead of estimating durations (since API 1.18)
|
||||
message InfraredRFTransmitCompleteResponse {
|
||||
option (id) = 153;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (no_delay) = true;
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request
|
||||
bool success = 3; // false if the request was refused, not sent, superseded, or never reported
|
||||
}
|
||||
|
||||
// ==================== RADIO FREQUENCY ====================
|
||||
|
||||
// Lists available radio frequency entity instances
|
||||
@@ -2876,6 +3019,56 @@ message SerialProxySetModeRequest {
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// Subscribe to the identity of every serial proxy port. The device answers with one
|
||||
// SerialProxyIdentity per port, then sends another whenever a port's identity changes,
|
||||
// for the life of the connection (since API 1.18).
|
||||
message SubscribeSerialProxyIdentityRequest {
|
||||
option (id) = 154;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
}
|
||||
|
||||
// Where a port's identity comes from
|
||||
enum SerialProxyIdentitySource {
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet
|
||||
SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port;
|
||||
// changes when a device is attached or removed
|
||||
}
|
||||
|
||||
enum SerialProxyIdentityFlag {
|
||||
SERIAL_PROXY_IDENTITY_FLAG_NONE = 0;
|
||||
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port
|
||||
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and
|
||||
// IDs below are empty
|
||||
}
|
||||
|
||||
// The descriptor fields of a USB device as seen by the host stack
|
||||
message UsbDeviceDescriptor {
|
||||
uint32 vendor_id = 1;
|
||||
uint32 product_id = 2;
|
||||
uint32 bcd_device = 3;
|
||||
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
|
||||
}
|
||||
|
||||
// The identity of the device behind a port. Identifies one physical device among others of
|
||||
// the same kind, so a client matches on manufacturer and product regardless of source. The
|
||||
// strings are empty for source NONE, and for source USB while nothing is connected
|
||||
// (since API 1.18).
|
||||
message SerialProxyIdentity {
|
||||
option (id) = 155;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyIdentitySource source = 2;
|
||||
uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag
|
||||
string manufacturer = 4;
|
||||
string product = 5;
|
||||
string serial_number = 6;
|
||||
UsbDeviceDescriptor usb = 7; // Only sent for source USB
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
@@ -2897,3 +3090,34 @@ message BluetoothSetConnectionParamsResponse {
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
}
|
||||
|
||||
// ==================== SENDSPIN ====================
|
||||
enum SendspinPairingTokenStatus {
|
||||
// The Sendspin client has not started yet, so the token does not exist yet. Ask again later.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0;
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1;
|
||||
// The device has no API encryption key, so it will not send the token. Ask again once a key is set.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2;
|
||||
// Sendspin is turned off and has not started since boot, so the token does not exist yet.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3;
|
||||
// The Sendspin client failed to start, and will not start until the device reboots.
|
||||
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4;
|
||||
}
|
||||
|
||||
// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device
|
||||
message SendspinPairingTokenRequest {
|
||||
option (id) = 159;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SENDSPIN";
|
||||
// Empty
|
||||
}
|
||||
|
||||
message SendspinPairingTokenResponse {
|
||||
option (id) = 160;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SENDSPIN";
|
||||
|
||||
SendspinPairingTokenStatus status = 1;
|
||||
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
|
||||
string token = 2 [(max_data_length) = 127];
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
#include "esphome/components/zwave_proxy/zwave_proxy.h"
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
#include "esphome/components/water_heater/water_heater.h"
|
||||
#endif
|
||||
@@ -198,6 +201,17 @@ APIConnection::~APIConnection() {
|
||||
proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// entities holding a transmit reply for this client must not answer into a freed connection
|
||||
#ifdef USE_INFRARED
|
||||
for (auto *infrared : App.get_infrareds()) {
|
||||
infrared->on_api_connection_closed(this);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
for (auto *radio_frequency : App.get_radio_frequencies()) {
|
||||
radio_frequency->on_api_connection_closed(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -990,7 +1004,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
|
||||
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *select = static_cast<select::Select *>(entity);
|
||||
ListEntitiesSelectResponse msg;
|
||||
msg.options = &select->traits.get_options();
|
||||
const auto &opts = select->traits.get_options();
|
||||
const std::span<const char *const> options(opts.data(), opts.size());
|
||||
msg.options = &options;
|
||||
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
|
||||
}
|
||||
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
|
||||
@@ -1389,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
bool APIConnection::send_sendspin_pairing_token_response_() {
|
||||
SendspinPairingTokenResponse resp;
|
||||
#ifdef USE_API_NOISE
|
||||
// has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions
|
||||
// never reach hello, and activating a key closes every session opened before it.
|
||||
if (!this->parent_->get_noise_ctx().has_psk()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
auto token = sendspin_::global_sendspin_hub->get_pairing_token();
|
||||
if (token.has_value()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK;
|
||||
resp.token = StringRef(*token);
|
||||
} else if (sendspin_::global_sendspin_hub->status_has_error()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED;
|
||||
} else if (sendspin_::global_sendspin_hub->is_disabled()) {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED;
|
||||
} else {
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY;
|
||||
}
|
||||
#else
|
||||
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
void APIConnection::on_sendspin_pairing_token_request() {
|
||||
if (!this->send_sendspin_pairing_token_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
|
||||
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
|
||||
@@ -1515,8 +1565,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
|
||||
// Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply
|
||||
const bool want_reply = this->client_supports_api_version(1, 18);
|
||||
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
|
||||
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1526,6 +1578,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_api_connection(want_reply ? this : nullptr);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
@@ -1538,13 +1591,38 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_api_connection(want_reply ? this : nullptr);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key);
|
||||
if (want_reply) {
|
||||
// nothing will ever report for an unknown key, so answer as not started right away
|
||||
#ifdef USE_DEVICES
|
||||
const uint32_t device_id = msg.device_id;
|
||||
#else
|
||||
const uint32_t device_id = 0;
|
||||
#endif
|
||||
if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "IR/RF reply");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
bool success) {
|
||||
InfraredRFTransmitCompleteResponse resp{};
|
||||
#ifdef USE_DEVICES
|
||||
resp.device_id = device_id;
|
||||
#endif
|
||||
resp.key = key;
|
||||
resp.success = success;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
@@ -1552,6 +1630,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -1643,6 +1722,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_subscribe_serial_proxy_identity_request() {
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
// Only USB ports change identity after this snapshot
|
||||
this->flags_.serial_proxy_identity_subscription = true;
|
||||
#endif
|
||||
for (auto *proxy : App.get_serial_proxies()) {
|
||||
proxy->send_identity(this);
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy identity");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
@@ -1814,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 17;
|
||||
resp.api_version_minor = 19;
|
||||
// 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.name = StringRef(App.get_name());
|
||||
@@ -1837,6 +1932,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// With a PSK set only key-verified transports reach hello: plaintext and
|
||||
// zero-PSK are rejected, and pre-activation sessions are force-closed
|
||||
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
|
||||
if (this->parent_->get_noise_ctx().has_psk()) {
|
||||
this->flags_.outgoing_connection_target = true;
|
||||
this->parent_->on_outgoing_target_client(this);
|
||||
} else {
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
@@ -1959,6 +2067,9 @@ bool APIConnection::send_device_info_response_() {
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
resp.api_outgoing_connection_supported = true;
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
@@ -2011,6 +2122,12 @@ bool APIConnection::send_device_capabilities_response_() {
|
||||
info.port_type = proxy->get_port_type();
|
||||
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
resp.wizard.configured = true;
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
@@ -2225,7 +2342,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; }
|
||||
void APIConnection::on_subscribe_home_assistant_states_request() {
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set
|
||||
this->flags_.home_assistant_states = true;
|
||||
#endif
|
||||
state_subs_at_ = 0;
|
||||
}
|
||||
#endif
|
||||
bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
delay(0);
|
||||
@@ -2269,8 +2392,7 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
|
||||
#endif
|
||||
// Capacity reserved above, cannot fail
|
||||
(void) shared_buf.resize(write_start + payload_size);
|
||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end = encode_fn(msg, shared_buf.data() + write_start PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
|
||||
#else
|
||||
@@ -2668,6 +2790,13 @@ void APIConnection::process_state_subscriptions_() {
|
||||
}
|
||||
|
||||
const auto &it = subs[this->state_subs_at_];
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set
|
||||
if (it.entity_id[0] == '\0') {
|
||||
this->state_subs_at_++;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
SubscribeHomeAssistantStateResponse resp;
|
||||
resp.entity_id = StringRef(it.entity_id);
|
||||
|
||||
|
||||
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
void on_sendspin_pairing_token_request();
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
|
||||
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
|
||||
@@ -233,9 +237,12 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
|
||||
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
|
||||
// Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is
|
||||
// full, the entity that owns the reply retries it then
|
||||
[[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -243,6 +250,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
|
||||
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
|
||||
void on_subscribe_serial_proxy_identity_request();
|
||||
/// Send a port identity to this client
|
||||
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
|
||||
void on_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
@@ -273,6 +283,12 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_device_capabilities_request();
|
||||
#ifdef USE_API_WIZARD
|
||||
void on_device_wizard_request();
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void on_wizard_input_set_request(const WizardInputSetRequest &msg);
|
||||
#endif
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() {
|
||||
this->flags_.state_subscription = true;
|
||||
@@ -302,6 +318,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void on_subscribe_home_assistant_states_request();
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
/// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed
|
||||
void resend_state_subscriptions(const char *entity_id);
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
void on_execute_service_request(const ExecuteServiceRequest &msg);
|
||||
@@ -317,9 +337,15 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
|
||||
// How long a new connection holds off the spare ephemeral refill
|
||||
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
|
||||
bool is_authenticated() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
|
||||
}
|
||||
// An older unauthenticated connection is a stale half open client and does not count
|
||||
bool is_still_connecting(uint32_t now) {
|
||||
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
|
||||
}
|
||||
bool is_connection_setup() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
|
||||
this->is_authenticated();
|
||||
@@ -339,7 +365,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_no_setup_connection();
|
||||
|
||||
// Function pointer type for type-erased message encoding
|
||||
using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
|
||||
using MessageEncodeFn = ProtoEncodeFn;
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
@@ -372,6 +398,23 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return this->helper_->get_peername_to(buf);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
/// Get the peer address itself, for remembering a dial-back target
|
||||
int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); }
|
||||
/// Outgoing connection: send our server hello immediately so the peer can
|
||||
/// pick the matching key. Outgoing connections are only dialed when a PSK
|
||||
/// is set, so the helper is always the noise helper. Call after start().
|
||||
void mark_outgoing() {
|
||||
if (this->flags_.remove) {
|
||||
return; // start() failed; the connection is already being torn down
|
||||
}
|
||||
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||
|
||||
@@ -393,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg);
|
||||
#endif
|
||||
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
|
||||
bool send_sendspin_pairing_token_response_();
|
||||
#endif
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
void try_send_camera_image_();
|
||||
@@ -728,12 +774,21 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_first_message : 1; // For batch buffer allocation
|
||||
uint8_t should_try_send_immediately : 1; // True after initial states are sent
|
||||
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
|
||||
#endif
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
} flags_{}; // 2 bytes total
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
uint8_t serial_proxy_identity_subscription : 1;
|
||||
#endif
|
||||
} flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY
|
||||
|
||||
// 2-byte type immediately after flags_ (no padding between them)
|
||||
// 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes)
|
||||
uint16_t batch_message_type_{0}; // Current message type during batch encoding
|
||||
// 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_)
|
||||
|
||||
@@ -45,8 +45,8 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
||||
conn->fatal_out_of_memory_();
|
||||
return 0;
|
||||
}
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end =
|
||||
encode_fn(msg, shared_buf.data() + shared_buf.size() - calculated_size PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
// A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes
|
||||
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
|
||||
|
||||
@@ -285,7 +285,8 @@ class APIFrameHelper {
|
||||
DATA = 5,
|
||||
CLOSED = 6,
|
||||
FAILED = 7,
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
|
||||
};
|
||||
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
|
||||
@@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIError APINoiseFrameHelper::send_server_hello_first() {
|
||||
// The peer needs our name and MAC to pick the key before its first message
|
||||
this->state_ = State::CLIENT_HELLO_OUTGOING;
|
||||
return this->send_server_hello_frame_();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
@@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() {
|
||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||
return APIError::BAD_STATE;
|
||||
case State::CLIENT_HELLO:
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
case State::CLIENT_HELLO_OUTGOING:
|
||||
#endif
|
||||
return this->state_action_client_hello_();
|
||||
case State::SERVER_HELLO:
|
||||
return this->state_action_server_hello_();
|
||||
@@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
|
||||
// Server hello already went out at handoff
|
||||
return this->start_handshake_();
|
||||
}
|
||||
#endif
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// send server hello
|
||||
APIError APINoiseFrameHelper::send_server_hello_frame_() {
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
@@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
return write_frame_(msg, total_size);
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
APIError aerr = this->send_server_hello_frame_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
return this->start_handshake_();
|
||||
}
|
||||
APIError APINoiseFrameHelper::start_handshake_() {
|
||||
APIError aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Send the server hello immediately so the peer can pick the key before
|
||||
// its PSK-mixed message. Call after init(); the mode is tracked in state_
|
||||
// so the helper does not grow.
|
||||
APIError send_server_hello_first();
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
APIError send_server_hello_frame_();
|
||||
APIError start_handshake_();
|
||||
APIError state_action_handshake_();
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
|
||||
@@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions {
|
||||
// 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];
|
||||
|
||||
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
|
||||
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
|
||||
// cannot read flash with ordinary loads.
|
||||
optional bool progmem = 50021 [default=false];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#include "api_outgoing_connection.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.outgoing";
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL;
|
||||
#endif
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Read the connection's peer address into target; false when unavailable or
|
||||
// of a family this build cannot dial
|
||||
static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
|
||||
// Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched
|
||||
struct sockaddr_storage peer = {};
|
||||
socklen_t peer_len = sizeof(peer);
|
||||
if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) {
|
||||
return false;
|
||||
}
|
||||
const sa_family_t family = ((struct sockaddr *) &peer)->sa_family;
|
||||
#if USE_NETWORK_IPV6
|
||||
if (family == AF_INET6) {
|
||||
const auto *addr6 = reinterpret_cast<const struct sockaddr_in6 *>(&peer);
|
||||
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
|
||||
uint32_t prefix[3];
|
||||
memcpy(prefix, bytes, sizeof(prefix));
|
||||
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
|
||||
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
|
||||
return true;
|
||||
}
|
||||
// A link-local target is only reachable through the interface it came in
|
||||
// on. Device platforms number interfaces from one; a host build can hand
|
||||
// out an index too large to store, and a truncated one dials the wrong
|
||||
// interface, so that target is not remembered at all.
|
||||
if (addr6->sin6_scope_id > UINT8_MAX) {
|
||||
return false;
|
||||
}
|
||||
target.family = AF_INET6;
|
||||
memcpy(target.addr, bytes, sizeof(target.addr));
|
||||
target.scope_id = static_cast<uint8_t>(addr6->sin6_scope_id);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (family != AF_INET) {
|
||||
return false;
|
||||
}
|
||||
const auto *addr4 = reinterpret_cast<const struct sockaddr_in *>(&peer);
|
||||
target.family = AF_INET;
|
||||
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const {
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
// Validation only lets through a literal both inet_pton and inet6_aton
|
||||
// accept, so this cannot fail
|
||||
return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST,
|
||||
API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
#if USE_NETWORK_IPV6
|
||||
if (this->saved_.family == AF_INET6) {
|
||||
auto *addr6 = reinterpret_cast<struct sockaddr_in6 *>(addr);
|
||||
memset(addr6, 0, sizeof(*addr6));
|
||||
addr6->sin6_family = AF_INET6;
|
||||
addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr));
|
||||
addr6->sin6_scope_id = this->saved_.scope_id;
|
||||
return sizeof(*addr6);
|
||||
}
|
||||
#endif
|
||||
if (this->saved_.family != AF_INET) {
|
||||
return 0;
|
||||
}
|
||||
auto *addr4 = reinterpret_cast<struct sockaddr_in *>(addr);
|
||||
memset(addr4, 0, sizeof(*addr4));
|
||||
addr4->sin_family = AF_INET;
|
||||
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
|
||||
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
|
||||
return sizeof(*addr4);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
void OutgoingConnectionManager::format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
if (addr_len == 0) {
|
||||
buf[0] = '\0';
|
||||
return;
|
||||
}
|
||||
// Clears buf itself if it cannot format the address
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf);
|
||||
}
|
||||
#endif
|
||||
|
||||
void OutgoingConnectionManager::setup() {
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(OUTGOING_TARGET_PREF_HASH, true);
|
||||
struct sockaddr_storage addr;
|
||||
// dump_config() prints whichever target this leaves in place
|
||||
if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) {
|
||||
this->host_persisted_ = true;
|
||||
} else {
|
||||
// Never saved, failed its size or CRC check, or holds an unknown family
|
||||
this->saved_ = {};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::loop(APIServer *server) {
|
||||
if (server->has_outgoing_target_client_()) {
|
||||
return; // on_target_client() already reset the dial state
|
||||
}
|
||||
if (this->dialed_conn_ != nullptr) {
|
||||
// A live dialed session (flagged or not, e.g. a host: peer) is the
|
||||
// target; a silent one dies on the handshake timeout
|
||||
return;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
switch (this->state_) {
|
||||
case DialState::DIAL_STATE_IDLE:
|
||||
// Target went away; give it the configured delay to reconnect first
|
||||
this->schedule_wait_(now, IDLE_WAIT_MS);
|
||||
break;
|
||||
case DialState::DIAL_STATE_WAITING:
|
||||
if (now - this->state_ts_ >= this->wait_) {
|
||||
this->try_dial_(server, now);
|
||||
}
|
||||
break;
|
||||
case DialState::DIAL_STATE_CONNECTING:
|
||||
this->poll_connect_(server, now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
|
||||
if (!network::is_connected()) {
|
||||
// Flips within seconds of boot; recheck fast so a deep sleep wake
|
||||
// window is not spent waiting
|
||||
this->schedule_wait_(now, NETWORK_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
struct sockaddr_storage addr;
|
||||
socklen_t addr_len = this->target_sockaddr_(&addr);
|
||||
const bool at_limit = server->at_client_limit_();
|
||||
// No target is the steady state until a dial-back client has ever connected
|
||||
if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) {
|
||||
// Repeats for as long as the reason holds, so keep it out of debug logs
|
||||
ESP_LOGV(TAG, "Not dialing: %s",
|
||||
addr_len == 0 ? LOG_STR_LITERAL("no target")
|
||||
: (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")));
|
||||
// Not a dial failure; retry without escalating the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
|
||||
ESP_LOGW(TAG, "Socket %s failed: errno %d",
|
||||
this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT);
|
||||
#else
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host);
|
||||
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
|
||||
#endif
|
||||
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
|
||||
if (err == 0) {
|
||||
// Immediate success (possible for localhost)
|
||||
this->handoff_(server, now);
|
||||
return;
|
||||
}
|
||||
if (errno != EINPROGRESS) {
|
||||
ESP_LOGW(TAG, "Connect failed: %d", errno);
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_CONNECTING;
|
||||
this->state_ts_ = now;
|
||||
this->last_poll_ = now;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
|
||||
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
this->schedule_retry_(now);
|
||||
return;
|
||||
}
|
||||
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this->last_poll_ = now;
|
||||
int err = 0;
|
||||
switch (socket::poll_connect(*this->dial_socket_, err)) {
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
|
||||
this->handoff_(server, now);
|
||||
break;
|
||||
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
|
||||
ESP_LOGW(TAG, "Connect failed: %d", err);
|
||||
this->schedule_retry_(now);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
|
||||
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
|
||||
if (this->dialed_conn_ == nullptr) {
|
||||
// Only preconditions (slot limit, key cleared) refuse the handoff; the
|
||||
// peer is reachable, so do not escalate the backoff
|
||||
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
// Connected; dialed_conn_ gates further dialing until the session settles
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
|
||||
this->dial_socket_.reset(); // no-op when the socket was handed off
|
||||
this->state_ = DialState::DIAL_STATE_WAITING;
|
||||
this->state_ts_ = now;
|
||||
this->wait_ = wait;
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
|
||||
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
|
||||
const uint32_t jitter_span = this->backoff_ / 5;
|
||||
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
|
||||
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
|
||||
if (conn != this->dialed_conn_) {
|
||||
return;
|
||||
}
|
||||
this->dialed_conn_ = nullptr;
|
||||
if (was_authenticated) {
|
||||
// A working peer (e.g. a host: target that never sends the flag)
|
||||
// disconnected normally; state is IDLE, so loop() applies the delay
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
} else {
|
||||
this->schedule_retry_(App.get_loop_component_start_time());
|
||||
}
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
|
||||
// The target is connected; stop any dial in flight and reset the backoff.
|
||||
// A dialed connection stays tracked unless it is this one: an inbound
|
||||
// target must not orphan a still-open dial.
|
||||
this->dial_socket_.reset();
|
||||
if (conn == this->dialed_conn_) {
|
||||
this->dialed_conn_ = nullptr;
|
||||
}
|
||||
this->state_ = DialState::DIAL_STATE_IDLE;
|
||||
this->backoff_ = BACKOFF_MIN_MS;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget target{};
|
||||
if (!peer_to_target(conn, target)) {
|
||||
ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed");
|
||||
return;
|
||||
}
|
||||
if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) {
|
||||
return; // unchanged and already on flash; avoid flash wear
|
||||
}
|
||||
// Use the fresh address this boot even if the flash write fails; a failed
|
||||
// write is retried on the next flagged hello via host_persisted_
|
||||
this->saved_ = target;
|
||||
if (!this->persist_target_()) {
|
||||
ESP_LOGW(TAG, "Failed to save target");
|
||||
return;
|
||||
}
|
||||
char host[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(host);
|
||||
ESP_LOGD(TAG, "Remembered %s as the dial target", host);
|
||||
#endif
|
||||
}
|
||||
|
||||
void OutgoingConnectionManager::dump_config() const {
|
||||
// The boot delay differs from delay: on deep sleep builds, so print the
|
||||
// value that actually applies
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Outgoing connection port: %u\n"
|
||||
" Outgoing connection boot delay: %" PRIu32 "ms",
|
||||
API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS);
|
||||
// Both forms keep their text out of RAM on ESP8266: in the format string,
|
||||
// or through LOG_STR_LITERAL
|
||||
#ifdef API_OUTGOING_CONNECTION_HOST
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST);
|
||||
#else
|
||||
char buf[socket::SOCKADDR_STR_LEN];
|
||||
this->format_target_(buf);
|
||||
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
|
||||
|
||||
#ifndef USE_API_NOISE
|
||||
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
|
||||
#endif
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class APIServer;
|
||||
class APIConnection;
|
||||
|
||||
// Room for an IPv6 address in every build, so a remembered IPv4 target is
|
||||
// still dialed after enable_ipv6 is turned on. A size that followed the build
|
||||
// would also shift every preference registered after this one on ESP8266,
|
||||
// where slots are positional. An IPv6 target on a build without IPv6 is
|
||||
// dropped by target_sockaddr_() and relearned.
|
||||
// Bytes in an IPv6 address
|
||||
static constexpr size_t TARGET_ADDR_LEN = 16;
|
||||
|
||||
struct SavedOutgoingTarget {
|
||||
// 0 when none is remembered, else AF_INET or AF_INET6
|
||||
uint8_t family;
|
||||
// Network order, IPv4 in the first four bytes and the rest zero
|
||||
uint8_t addr[TARGET_ADDR_LEN];
|
||||
// Interface a link-local IPv6 target is reachable on, 0 when it needs none.
|
||||
// Free in flash: the record still rounds up to the same five words.
|
||||
uint8_t scope_id;
|
||||
} PACKED; // NOLINT
|
||||
|
||||
/// Dials out when no dial-back target client is connected. Only the TCP
|
||||
/// direction flips: the device stays the Noise responder, so both sides
|
||||
/// still verify by key. Targets the YAML host or the last remembered client.
|
||||
class OutgoingConnectionManager {
|
||||
public:
|
||||
void setup();
|
||||
void loop(APIServer *server);
|
||||
/// A key-verified client declared itself a dial-back target; last one wins
|
||||
void on_target_client(APIConnection *conn);
|
||||
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
|
||||
void on_client_removed(APIConnection *conn, bool was_authenticated);
|
||||
void on_shutdown() { this->dial_socket_.reset(); }
|
||||
void dump_config() const;
|
||||
|
||||
protected:
|
||||
enum class DialState : uint8_t {
|
||||
DIAL_STATE_IDLE,
|
||||
DIAL_STATE_WAITING,
|
||||
DIAL_STATE_CONNECTING,
|
||||
};
|
||||
|
||||
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
|
||||
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
|
||||
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
|
||||
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
|
||||
static constexpr uint32_t NETWORK_RETRY_MS = 500;
|
||||
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
|
||||
// A deep sleep wake window is too short to spend on the delay, so those
|
||||
// builds dial out as soon as the target is gone
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
static constexpr uint32_t BOOT_WAIT_MS = 0;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS;
|
||||
#else
|
||||
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
|
||||
#endif
|
||||
|
||||
void try_dial_(APIServer *server, uint32_t now);
|
||||
void poll_connect_(APIServer *server, uint32_t now);
|
||||
// Hand the connected socket to the server and gate on the new connection
|
||||
void handoff_(APIServer *server, uint32_t now);
|
||||
// Close any half-open dial and wait a jittered backoff before retrying
|
||||
void schedule_retry_(uint32_t now);
|
||||
// Wait without escalating the backoff (used for unmet preconditions)
|
||||
void schedule_wait_(uint32_t now, uint32_t wait);
|
||||
/// Fill addr with the target and return its length, or 0 when there is none
|
||||
socklen_t target_sockaddr_(struct sockaddr_storage *addr) const;
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
// Write saved_ to flash, tracking success in host_persisted_
|
||||
bool persist_target_() {
|
||||
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
|
||||
return this->host_persisted_;
|
||||
}
|
||||
/// Format the remembered target for a log line; empty when there is none
|
||||
void format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
|
||||
#endif
|
||||
|
||||
// Pointers first (4 bytes each on 32-bit)
|
||||
std::unique_ptr<socket::Socket> dial_socket_;
|
||||
// Compared only, never dereferenced
|
||||
APIConnection *dialed_conn_{nullptr};
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
ESPPreferenceObject target_pref_;
|
||||
#endif
|
||||
|
||||
// 4-byte types
|
||||
uint32_t backoff_{BACKOFF_MIN_MS};
|
||||
uint32_t wait_{BOOT_WAIT_MS};
|
||||
uint32_t state_ts_{0};
|
||||
uint32_t last_poll_{0};
|
||||
|
||||
// Byte-aligned types last
|
||||
#ifndef API_OUTGOING_CONNECTION_HOST
|
||||
SavedOutgoingTarget saved_{};
|
||||
// False while saved_ holds a value the flash write failed for; retried on
|
||||
// the next flagged hello
|
||||
bool host_persisted_{false};
|
||||
#endif
|
||||
DialState state_{DialState::DIAL_STATE_WAITING};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_API && USE_API_OUTGOING_CONNECTION
|
||||
+278
-218
File diff suppressed because it is too large
Load Diff
+404
-203
File diff suppressed because it is too large
Load Diff
@@ -114,15 +114,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
|
||||
out.append("\n");
|
||||
}
|
||||
|
||||
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
|
||||
static constexpr size_t DUMP_BYTES_MAX = 160;
|
||||
|
||||
// Helper for bytes fields - uses stack buffer to avoid heap allocation
|
||||
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
|
||||
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
|
||||
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
|
||||
char hex_buf[format_hex_pretty_size(160)];
|
||||
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
|
||||
append_field_prefix(out, field_name, indent);
|
||||
format_hex_pretty_to(hex_buf, data, len);
|
||||
out.append(hex_buf).append("\n");
|
||||
}
|
||||
|
||||
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
|
||||
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
|
||||
int indent = 2) {
|
||||
uint8_t data_buf[DUMP_BYTES_MAX];
|
||||
len = std::min(len, sizeof(data_buf));
|
||||
progmem_memcpy(data_buf, data, len);
|
||||
dump_bytes_field(out, field_name, data_buf, len, indent);
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
|
||||
@@ -143,6 +154,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::S
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232");
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_RS485:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485");
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
@@ -890,6 +903,49 @@ template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::Seria
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyIdentitySource>(enums::SerialProxyIdentitySource value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE");
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED");
|
||||
case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enums::SerialProxyIdentityFlag value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE");
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED");
|
||||
case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#ifdef USE_SENDSPIN
|
||||
template<>
|
||||
const char *proto_enum_to_string<enums::SendspinPairingTokenStatus>(enums::SendspinPairingTokenStatus value) {
|
||||
switch (value) {
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED");
|
||||
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED:
|
||||
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -897,6 +953,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
|
||||
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1020,6 +1079,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
@@ -1046,6 +1108,20 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("configured"), this->configured);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
@@ -1069,9 +1145,34 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": ");
|
||||
this->wizard.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
|
||||
this->sendspin.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
#ifdef USE_API_WIZARD
|
||||
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
|
||||
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
|
||||
return out.c_str();
|
||||
@@ -2714,7 +2815,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
|
||||
#ifdef USE_DEVICES
|
||||
@@ -2743,6 +2844,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
}
|
||||
return out.c_str();
|
||||
}
|
||||
const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse"));
|
||||
#ifdef USE_DEVICES
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("success"), this->success);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -2826,6 +2936,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor"));
|
||||
dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id);
|
||||
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
|
||||
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
|
||||
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("source"), static_cast<enums::SerialProxyIdentitySource>(this->source));
|
||||
dump_field(out, ESPHOME_PSTR("flags"), this->flags);
|
||||
dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
|
||||
dump_field(out, ESPHOME_PSTR("product"), this->product);
|
||||
dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number);
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": ");
|
||||
this->usb.dump_to(out);
|
||||
out.append("\n");
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -2844,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SendspinPairingTokenStatus>(this->status));
|
||||
dump_field(out, ESPHOME_PSTR("token"), this->token);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
// Standard library includes that might be needed
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
|
||||
InfraredRFTransmitRawTimingsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -722,6 +722,44 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case 154 /* SubscribeSerialProxyIdentityRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request"));
|
||||
#endif
|
||||
this->on_subscribe_serial_proxy_identity_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD
|
||||
case 156 /* DeviceWizardRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_device_wizard_request"));
|
||||
#endif
|
||||
this->on_device_wizard_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
case WizardInputSetRequest::MESSAGE_TYPE: {
|
||||
WizardInputSetRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg);
|
||||
#endif
|
||||
this->on_wizard_input_set_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN
|
||||
case 159 /* SendspinPairingTokenRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request"));
|
||||
#endif
|
||||
this->on_sendspin_pairing_token_request();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -29,6 +29,13 @@ class APIServerConnectionBase {
|
||||
|
||||
void on_device_capabilities_request(){};
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
void on_device_wizard_request(){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void on_wizard_input_set_request(const WizardInputSetRequest &value){};
|
||||
#endif
|
||||
void on_list_entities_request(){};
|
||||
|
||||
void on_subscribe_states_request(){};
|
||||
@@ -213,7 +220,7 @@ class APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_IR_RF
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
|
||||
#endif
|
||||
|
||||
@@ -238,9 +245,17 @@ class APIServerConnectionBase {
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_subscribe_serial_proxy_identity_request(){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN
|
||||
void on_sendspin_pairing_token_request(){};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -56,7 +56,12 @@ APIServer::APIServer() { global_api_server = this; }
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Dial-out needs no listener; degrade instead of stopping the component
|
||||
this->status_set_error(LOG_STR("listen socket failed"));
|
||||
#else
|
||||
this->mark_failed();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::setup() {
|
||||
@@ -72,43 +77,6 @@ void APIServer::setup() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
if (logger::global_logger != nullptr) {
|
||||
logger::global_logger->add_log_callback(
|
||||
@@ -154,6 +122,47 @@ void APIServer::setup() {
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.setup();
|
||||
#endif
|
||||
|
||||
// Listener last: on failure socket_failed_() returns early, and an
|
||||
// outgoing_connection build keeps dialing out without one
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->socket_ == nullptr) {
|
||||
this->socket_failed_(LOG_STR("creation"));
|
||||
return;
|
||||
}
|
||||
int enable = 1;
|
||||
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
|
||||
// we can still continue
|
||||
}
|
||||
err = this->socket_->setblocking(false);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("nonblocking"));
|
||||
return;
|
||||
}
|
||||
|
||||
struct sockaddr_storage server;
|
||||
|
||||
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
|
||||
if (sl == 0) {
|
||||
this->socket_failed_(LOG_STR("set sockaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->bind((struct sockaddr *) &server, sl);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("bind"));
|
||||
return;
|
||||
}
|
||||
|
||||
err = this->socket_->listen(this->listen_backlog_);
|
||||
if (err != 0) {
|
||||
this->socket_failed_(LOG_STR("listen"));
|
||||
}
|
||||
}
|
||||
|
||||
void APIServer::loop() {
|
||||
@@ -162,6 +171,19 @@ void APIServer::loop() {
|
||||
this->accept_new_connections_();
|
||||
}
|
||||
|
||||
const bool connected = network::is_connected();
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
if (connected && !noise::has_spare_ephemeral()) {
|
||||
this->refill_spare_ephemeral_();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (!this->shutting_down_) {
|
||||
this->outgoing_conn_.loop(this);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
@@ -178,8 +200,7 @@ void APIServer::loop() {
|
||||
}
|
||||
|
||||
// Process clients and remove disconnected ones in a single pass
|
||||
// Check network connectivity once for all clients
|
||||
if (!network::is_connected()) {
|
||||
if (!connected) {
|
||||
// Network is down - disconnect all clients
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->on_fatal_error();
|
||||
@@ -207,6 +228,19 @@ void APIServer::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
// An OTA handshake is not visible here and just pays the refill it triggered
|
||||
void APIServer::refill_spare_ephemeral_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
for (auto &client : this->active_clients()) {
|
||||
if (client->is_still_connecting(now)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
noise::prepare_spare_ephemeral();
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::remove_client_(uint8_t client_index) {
|
||||
auto &client = this->clients_[client_index];
|
||||
|
||||
@@ -222,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
std::string client_peername(client->get_peername_to(peername_buf));
|
||||
#endif
|
||||
|
||||
// Read before the swap-and-reset below destroys the connection
|
||||
const bool was_authenticated = client->is_authenticated();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
if (client->flags_.outgoing_connection_target) {
|
||||
this->outgoing_target_count_--;
|
||||
}
|
||||
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
|
||||
#endif
|
||||
|
||||
// Close socket now (was deferred from on_fatal_error to allow getpeername)
|
||||
client->helper_->close();
|
||||
|
||||
@@ -240,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
// Refresh on every authenticated removal, not just the last one, so an
|
||||
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
|
||||
// a host that accepts TCP but never speaks the API) cannot discard a
|
||||
// healthy session's timestamp and trigger a spurious reboot
|
||||
if (was_authenticated) {
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
@@ -264,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
sock->getpeername_to(peername);
|
||||
|
||||
// Check if we're at the connection limit
|
||||
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
|
||||
if (this->at_client_limit_()) {
|
||||
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
|
||||
// Immediately close - socket destructor will handle cleanup
|
||||
sock.reset();
|
||||
@@ -273,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
|
||||
ESP_LOGD(TAG, "Accept %s", peername);
|
||||
|
||||
auto *conn = new APIConnection(std::move(sock), this);
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
this->add_client_(std::move(sock));
|
||||
}
|
||||
}
|
||||
|
||||
APIConnection *APIServer::add_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
this->clients_[this->api_connection_count_++].reset(conn);
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning. The reboot watchdog timestamp is
|
||||
// refreshed when an authenticated client is removed (see remove_client_),
|
||||
// never on bare TCP connects.
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
|
||||
// Re-check at the handoff: inbound clients may have taken the last slot and
|
||||
// the PSK may have been cleared since the dial started (mark_outgoing()
|
||||
// needs the noise helper)
|
||||
const bool at_limit = this->at_client_limit_();
|
||||
if (at_limit || !this->noise_ctx_.has_psk()) {
|
||||
ESP_LOGW(TAG, "Dropping outgoing connection (%s)",
|
||||
at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"));
|
||||
return nullptr;
|
||||
}
|
||||
auto *conn = this->add_client_(std::move(sock));
|
||||
// After start(): sends our server hello first so the peer can pick the key
|
||||
conn->mark_outgoing();
|
||||
return conn;
|
||||
}
|
||||
|
||||
void APIServer::on_outgoing_target_client(APIConnection *conn) {
|
||||
this->outgoing_target_count_++;
|
||||
this->outgoing_conn_.on_target_client(conn);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -301,6 +379,9 @@ void APIServer::dump_config() {
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
|
||||
#endif
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.dump_config();
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIServer::handle_disconnect(APIConnection *conn) {}
|
||||
@@ -423,7 +504,16 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (c->flags_.serial_proxy_identity_subscription)
|
||||
c->send_serial_proxy_identity(msg);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
const std::vector<int32_t> *timings) {
|
||||
InfraredRFReceiveEvent resp{};
|
||||
@@ -436,6 +526,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_infrared_rf_receive_event(resp);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
@@ -597,6 +688,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
// Force it: a session from before the key was active must not survive
|
||||
c->flags_.next_close = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -698,6 +791,9 @@ void APIServer::on_shutdown() {
|
||||
|
||||
// Close the listening socket to prevent new connections
|
||||
this->destroy_socket_();
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
this->outgoing_conn_.on_shutdown();
|
||||
#endif
|
||||
|
||||
// Change batch delay to 5ms for quick flushing during shutdown
|
||||
this->batch_delay_ = 5;
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
#include "api_buffer.h"
|
||||
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API_NOISE
|
||||
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
|
||||
// Only present in the build when the noise component is loaded
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_outgoing_connection.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -90,6 +91,10 @@ class APIServer final : public Component
|
||||
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
|
||||
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Called by APIConnection when a client declares itself a dial-back target in its hello
|
||||
void on_outgoing_target_client(APIConnection *conn);
|
||||
#endif
|
||||
|
||||
void handle_disconnect(APIConnection *conn);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -198,7 +203,11 @@ class APIServer final : public Component
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
|
||||
/// Tell every subscribed client that a serial proxy port's identity changed
|
||||
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
@@ -267,6 +276,16 @@ class APIServer final : public Component
|
||||
protected:
|
||||
// Accept incoming socket connections. Only called when socket has pending connections.
|
||||
void __attribute__((noinline)) accept_new_connections_();
|
||||
/// Takes the socket into a new connection and starts it; callers must have
|
||||
/// checked at_client_limit_() first
|
||||
APIConnection *add_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Returns the new connection, or nullptr (socket dropped) when at the limit
|
||||
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
|
||||
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
|
||||
friend class OutgoingConnectionManager;
|
||||
#endif
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
@@ -307,6 +326,8 @@ class APIServer final : public Component
|
||||
delete this->socket_;
|
||||
this->socket_ = nullptr;
|
||||
}
|
||||
/// Log the failure, drop the listen socket, and mark the component failed
|
||||
/// unless this build can still dial out
|
||||
void socket_failed_(const LogString *msg);
|
||||
// Pointers and pointer-like types first (4 bytes each)
|
||||
socket::ListenSocket *socket_{nullptr};
|
||||
@@ -357,16 +378,21 @@ class APIServer final : public Component
|
||||
// Group smaller types together
|
||||
uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py
|
||||
uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py
|
||||
// Connection limits - these defaults will be overridden by config values
|
||||
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
|
||||
uint8_t listen_backlog_{4};
|
||||
uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
// Connected clients whose hello declared them a dial-back target
|
||||
uint8_t outgoing_target_count_{0};
|
||||
#endif
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_NOISE_SPARE_EPHEMERAL
|
||||
void refill_spare_ephemeral_();
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
noise::NoiseContext noise_ctx_;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
@@ -374,6 +400,9 @@ class APIServer final : public Component
|
||||
#endif
|
||||
ESPPreferenceObject noise_pref_;
|
||||
#endif // USE_API_NOISE
|
||||
#ifdef USE_API_OUTGOING_CONNECTION
|
||||
OutgoingConnectionManager outgoing_conn_;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "api_wizard.h"
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_pb2.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.wizard";
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
|
||||
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
|
||||
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
|
||||
size_t joined = 0;
|
||||
const char *end = value + length;
|
||||
while (true) {
|
||||
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
|
||||
const char *id_end = comma != nullptr ? comma : end;
|
||||
while (value < id_end && *value == ' ')
|
||||
value++;
|
||||
while (id_end > value && id_end[-1] == ' ')
|
||||
id_end--;
|
||||
size_t id_length = id_end - value;
|
||||
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
|
||||
return 0;
|
||||
if (joined != 0) {
|
||||
if (out != nullptr)
|
||||
out[joined] = ',';
|
||||
joined++;
|
||||
}
|
||||
if (out != nullptr)
|
||||
memcpy(out + joined, value, id_length);
|
||||
joined += id_length;
|
||||
if (comma == nullptr)
|
||||
return joined;
|
||||
value = comma + 1;
|
||||
}
|
||||
}
|
||||
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
|
||||
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
|
||||
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
|
||||
}
|
||||
|
||||
const char *wizard_set_input(const WizardInputSetRequest &msg) {
|
||||
const char *value = msg.entity_id.c_str();
|
||||
const size_t length = msg.entity_id.size();
|
||||
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
|
||||
// The table is in flash, which ESP8266 can only read through progmem_memcpy
|
||||
WizardInputEntry entry;
|
||||
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
|
||||
if (entry.key != msg.key)
|
||||
continue;
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
if (entry.size != 0) {
|
||||
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
|
||||
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
|
||||
if (joined == 0 || joined >= entry.size)
|
||||
break;
|
||||
wizard_join_entity_ids(value, length, entry.entity_id);
|
||||
entry.entity_id[joined] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
#endif
|
||||
if (!wizard_entity_id_valid(value, length))
|
||||
break;
|
||||
memcpy(entry.entity_id, value, length);
|
||||
entry.entity_id[length] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
|
||||
return nullptr;
|
||||
}
|
||||
#endif // USE_API_WIZARD_INPUTS
|
||||
|
||||
void APIConnection::on_device_wizard_request() {
|
||||
DeviceWizardResponse resp;
|
||||
resp.data = API_WIZARD_DATA;
|
||||
resp.data_len = API_WIZARD_DATA_SIZE;
|
||||
if (!this->send_message(resp)) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) {
|
||||
const char *entity_id = wizard_set_input(msg);
|
||||
if (entity_id == nullptr)
|
||||
return;
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
// Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now
|
||||
for (auto &client : this->parent_->active_clients()) {
|
||||
client->resend_state_subscriptions(entity_id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
void APIConnection::resend_state_subscriptions(const char *entity_id) {
|
||||
if (!this->flags_.home_assistant_states)
|
||||
return;
|
||||
for (const auto &it : this->parent_->get_state_subs()) {
|
||||
if (it.entity_id != entity_id)
|
||||
continue;
|
||||
SubscribeHomeAssistantStateResponse resp;
|
||||
resp.entity_id = StringRef(it.entity_id);
|
||||
resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef("");
|
||||
resp.once = it.once;
|
||||
if (!this->send_message(resp)) {
|
||||
// Could not send now: send every subscription again from the loop
|
||||
this->state_subs_at_ = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API_WIZARD
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_API_WIZARD
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
#include <cstring>
|
||||
#endif
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "proto.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
class WizardInputSetRequest;
|
||||
|
||||
/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes.
|
||||
static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256;
|
||||
|
||||
/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code
|
||||
/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long.
|
||||
extern const uint8_t API_WIZARD_DATA[] PROGMEM;
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
/// Where the entity id of an input is kept, found by the key the client uses for it.
|
||||
struct WizardInputEntry {
|
||||
uint32_t key; // FNV-1 hash of the ESPHome id of the input
|
||||
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
uint16_t size; // Buffer size of a multiple input, 0 for a single input
|
||||
#endif
|
||||
};
|
||||
|
||||
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
|
||||
extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM;
|
||||
|
||||
/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored
|
||||
/// across restarts, so the client sends the choices again after every connection.
|
||||
/// Returns the buffer, or nullptr when the request was ignored.
|
||||
const char *wizard_set_input(const WizardInputSetRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_WIZARD_STANDALONE_INPUTS
|
||||
/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read
|
||||
/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses.
|
||||
class WizardInput {
|
||||
public:
|
||||
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
|
||||
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
|
||||
/// comma separated list, which homeassistant.action data can use as it is.
|
||||
StringRef entity_id() const { return StringRef(this->entity_id_); }
|
||||
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
/// The number of entity IDs in the list, 0 until the wizard sets one.
|
||||
size_t entity_id_count() const {
|
||||
if (!this->has_entity_id())
|
||||
return 0;
|
||||
size_t count = 1;
|
||||
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
|
||||
/// not null terminated, so print it with "%.*s".
|
||||
template<typename F> void for_each_entity_id(F &&callback) const {
|
||||
// The device stores the list without spaces or empty entries
|
||||
const char *start = this->entity_id_;
|
||||
while (*start != '\0') {
|
||||
const char *end = strchr(start, ',');
|
||||
if (end == nullptr)
|
||||
end = start + strlen(start);
|
||||
callback(StringRef(start, end - start));
|
||||
start = *end == ',' ? end + 1 : end;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
const char *entity_id_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API_WIZARD
|
||||
@@ -4,64 +4,40 @@
|
||||
#ifdef USE_API
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "api_pb2.h"
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#endif
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
|
||||
// Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation)
|
||||
// rather than being wrapped in a lambda. The base class constructor for const char* is more
|
||||
// specialized than the templated constructor here, so it should be selected.
|
||||
static_assert(std::is_constructible_v<TemplatableValue<std::string, X...>, const char *>,
|
||||
"Base class must have const char* constructor for STATIC_STRING optimization");
|
||||
// Converts a lambda result to the string sent to Home Assistant
|
||||
template<typename T>
|
||||
requires(!std::is_pointer_v<std::remove_cvref_t<T>>) std::string field_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
}
|
||||
inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); }
|
||||
inline std::string field_to_string(std::string val) { return val; }
|
||||
inline std::string field_to_string(StringRef val) { return val.str(); }
|
||||
|
||||
private:
|
||||
// Helper to convert value to string - handles the case where value is already a string
|
||||
template<typename T> static std::string value_to_string(T &&val) {
|
||||
return to_string(std::forward<T>(val)); // NOLINT
|
||||
/// A key and value from codegen; on ESP8266 the table and its strings are in flash.
|
||||
/// The value is the constant `value`, or the result of `fn` when it is set.
|
||||
template<typename... Ts> struct HomeAssistantField {
|
||||
const char *key;
|
||||
const char *value;
|
||||
std::string (*fn)(const Ts &...);
|
||||
|
||||
template<typename F> static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) {
|
||||
return {key, nullptr, &call_lambda<F>};
|
||||
}
|
||||
|
||||
// Overloads for string types - needed because std::to_string doesn't support them
|
||||
static std::string value_to_string(char *val) {
|
||||
return val ? std::string(val) : std::string();
|
||||
} // For lambdas returning char* (e.g., itoa)
|
||||
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
|
||||
static std::string value_to_string(const std::string &val) { return val; }
|
||||
static std::string value_to_string(std::string &&val) { return std::move(val); }
|
||||
static std::string value_to_string(const StringRef &val) { return val.str(); }
|
||||
static std::string value_to_string(StringRef &&val) { return val.str(); }
|
||||
|
||||
public:
|
||||
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
|
||||
|
||||
template<typename F, enable_if_t<!is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F value) : TemplatableValue<std::string, X...>(value) {}
|
||||
|
||||
template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F f)
|
||||
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
|
||||
};
|
||||
|
||||
template<typename... Ts> class TemplatableKeyValuePair {
|
||||
public:
|
||||
// Default constructor needed for FixedVector::emplace_back()
|
||||
TemplatableKeyValuePair() = default;
|
||||
|
||||
// Keys are always string literals from YAML dictionary keys (e.g., "code", "event")
|
||||
// and never templatable values or lambdas. Only the value parameter can be a lambda/template.
|
||||
// Using const char* avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename T> TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {}
|
||||
|
||||
const char *key{nullptr};
|
||||
TemplatableStringValue<Ts...> value;
|
||||
template<typename F> static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); }
|
||||
};
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -106,45 +82,20 @@ template<typename... Ts> using ActionResponseCallback = std::function<void(const
|
||||
|
||||
template<typename... Ts> class HomeAssistantServiceCallAction final : public Action<Ts...> {
|
||||
public:
|
||||
explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) {
|
||||
using Field = HomeAssistantField<Ts...>;
|
||||
|
||||
/// `fields` is a codegen table: the action or event name (no key), then the data, data_template
|
||||
/// and variables entries.
|
||||
HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count,
|
||||
uint8_t data_template_count, uint8_t variables_count)
|
||||
: parent_(parent),
|
||||
fields_(fields),
|
||||
data_count_(data_count),
|
||||
data_template_count_(data_template_count),
|
||||
variables_count_(variables_count) {
|
||||
this->flags_.is_event = is_event;
|
||||
}
|
||||
|
||||
template<typename T> void set_service(T service) { this->service_ = service; }
|
||||
|
||||
// Initialize FixedVector members - called from Python codegen with compile-time known sizes.
|
||||
// Must be called before any add_* methods; capacity must match the number of subsequent add_* calls.
|
||||
void init_data(size_t count) { this->data_.init(count); }
|
||||
void init_data_template(size_t count) { this->data_template_.init(count); }
|
||||
void init_variables(size_t count) { this->variables_.init(count); }
|
||||
|
||||
// Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))).
|
||||
// The value parameter can be a lambda/template, but keys are never templatable.
|
||||
// Using const char* for keys avoids std::string heap allocation - keys remain in flash.
|
||||
template<typename V> void add_data(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const char *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, key, std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const char *key, V &&value) {
|
||||
this->add_kv_(this->variables_, key, std::forward<V>(value));
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
|
||||
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
|
||||
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
|
||||
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
template<typename T> void set_response_template(T response_template) {
|
||||
this->response_template_ = response_template;
|
||||
@@ -162,19 +113,61 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
const Field *fields = this->fields_;
|
||||
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
|
||||
|
||||
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
|
||||
size_t lambda_count = 0;
|
||||
#ifdef USE_ESP8266
|
||||
size_t flash_len = 0;
|
||||
#endif
|
||||
for (size_t i = 0; i < total; i++) {
|
||||
lambda_count += fields[i].fn != nullptr;
|
||||
#ifdef USE_ESP8266
|
||||
if (fields[i].fn == nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].value);
|
||||
if (fields[i].key != nullptr)
|
||||
flash_len += ESPHOME_strlen_P(fields[i].key);
|
||||
#endif
|
||||
}
|
||||
FixedVector<std::string> results;
|
||||
results.init(lambda_count);
|
||||
#ifdef USE_ESP8266
|
||||
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
|
||||
char *cursor = flash_copy.get();
|
||||
#endif
|
||||
auto string_ref = [&](const char *str) {
|
||||
#ifdef USE_ESP8266
|
||||
size_t len = ESPHOME_strlen_P(str);
|
||||
memcpy_P(cursor, str, len);
|
||||
StringRef ref(cursor, len);
|
||||
cursor += len;
|
||||
return ref;
|
||||
#else
|
||||
return StringRef(str);
|
||||
#endif
|
||||
};
|
||||
auto value_ref = [&](const Field &field) {
|
||||
if (field.fn == nullptr)
|
||||
return string_ref(field.value);
|
||||
results.push_back(field.fn(x...));
|
||||
return StringRef(results.back());
|
||||
};
|
||||
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
|
||||
dest.init(count);
|
||||
for (uint8_t i = 0; i < count; i++, fields++) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = string_ref(fields->key);
|
||||
kv.value = value_ref(*fields);
|
||||
}
|
||||
};
|
||||
|
||||
HomeassistantActionRequest resp;
|
||||
std::string service_value = this->service_.value(x...);
|
||||
resp.service = StringRef(service_value);
|
||||
resp.service = value_ref(*fields++);
|
||||
resp.is_event = this->flags_.is_event;
|
||||
|
||||
// Local storage for lambda-evaluated strings - lives until after send
|
||||
FixedVector<std::string> data_storage;
|
||||
FixedVector<std::string> data_template_storage;
|
||||
FixedVector<std::string> variables_storage;
|
||||
|
||||
this->populate_service_map(resp.data, this->data_, data_storage, x...);
|
||||
this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...);
|
||||
this->populate_service_map(resp.variables, this->variables_, variables_storage, x...);
|
||||
fill(resp.data, this->data_count_);
|
||||
fill(resp.data_template, this->data_template_count_);
|
||||
fill(resp.variables, this->variables_count_);
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -223,90 +216,26 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
|
||||
}
|
||||
|
||||
protected:
|
||||
// Helper to add key-value pairs to FixedVectors
|
||||
// Keys are always string literals (const char*), values can be lambdas/templates
|
||||
template<typename V> void add_kv_(FixedVector<TemplatableKeyValuePair<Ts...>> &vec, const char *key, V &&value) {
|
||||
auto &kv = vec.emplace_back();
|
||||
kv.key = key;
|
||||
kv.value = std::forward<V>(value);
|
||||
}
|
||||
|
||||
template<typename VectorType, typename SourceType>
|
||||
static void populate_service_map(VectorType &dest, SourceType &source, FixedVector<std::string> &value_storage,
|
||||
Ts... x) {
|
||||
dest.init(source.size());
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM),
|
||||
// so is_static_string() is always false — the zero-copy STATIC_STRING fast
|
||||
// path from the non-ESP8266 branch cannot trigger. We copy all keys and
|
||||
// values unconditionally: keys via _P functions (may be in PROGMEM), values
|
||||
// via value() which handles FLASH_STRING internally.
|
||||
value_storage.init(source.size() * 2);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
|
||||
// Key: copy from possible PROGMEM
|
||||
{
|
||||
size_t key_len = strlen_P(it.key);
|
||||
value_storage.push_back(std::string(key_len, '\0'));
|
||||
memcpy_P(value_storage.back().data(), it.key, key_len);
|
||||
kv.key = StringRef(value_storage.back());
|
||||
}
|
||||
|
||||
// Value: value() handles FLASH_STRING via _P functions internally
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
#else
|
||||
// On non-ESP8266, strings are directly readable from flash-mapped memory.
|
||||
// Count non-static strings to allocate exact storage needed.
|
||||
size_t lambda_count = 0;
|
||||
for (const auto &it : source) {
|
||||
if (!it.value.is_static_string()) {
|
||||
lambda_count++;
|
||||
}
|
||||
}
|
||||
value_storage.init(lambda_count);
|
||||
|
||||
for (auto &it : source) {
|
||||
auto &kv = dest.emplace_back();
|
||||
kv.key = StringRef(it.key);
|
||||
|
||||
if (it.value.is_static_string()) {
|
||||
// Static string — pointer directly readable, zero allocation
|
||||
kv.value = StringRef(it.value.get_static_string());
|
||||
} else {
|
||||
// Lambda — evaluate and store result
|
||||
value_storage.push_back(it.value.value(x...));
|
||||
kv.value = StringRef(value_storage.back());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
APIServer *parent_;
|
||||
TemplatableStringValue<Ts...> service_{};
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> data_template_;
|
||||
FixedVector<TemplatableKeyValuePair<Ts...>> variables_;
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableStringValue<Ts...> response_template_{""};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
|
||||
const Field *fields_;
|
||||
uint8_t data_count_;
|
||||
uint8_t data_template_count_;
|
||||
uint8_t variables_count_;
|
||||
struct Flags {
|
||||
uint8_t is_event : 1;
|
||||
uint8_t wants_status : 1;
|
||||
uint8_t wants_response : 1;
|
||||
uint8_t has_response_template : 1;
|
||||
uint8_t reserved : 5;
|
||||
uint8_t reserved : 4;
|
||||
} flags_{0};
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
TemplatableValue<std::string, Ts...> response_template_{};
|
||||
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
Trigger<Ts...> success_trigger_;
|
||||
Trigger<std::string, Ts...> error_trigger_;
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
}
|
||||
|
||||
// Single-pass encode for repeated submessage elements (non-template core).
|
||||
// Writes field tag, reserves 1 byte for length varint, encodes the submessage body,
|
||||
// Reserves 1 byte for length varint, encodes the submessage body,
|
||||
// then backpatches the actual length. For the common case (body < 128 bytes), this is
|
||||
// just a single byte write with no memmove — all current repeated submessage types
|
||||
// (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take
|
||||
@@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
|
||||
//
|
||||
// After writing 2-byte varint at len_pos:
|
||||
// [tag][v1][v2][body ..... body]
|
||||
// ^-- pos_ = element end, within buffer
|
||||
void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
this->encode_field_raw(field_id, 2);
|
||||
// Reserve 1 byte for length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = this->pos_;
|
||||
this->debug_check_bounds_(1);
|
||||
this->pos_++;
|
||||
uint8_t *body_start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
uint32_t body_size = static_cast<uint32_t>(this->pos_ - body_start);
|
||||
if (body_size < 128) [[likely]] {
|
||||
// ^-- returned cursor = element end, within buffer
|
||||
uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value,
|
||||
ProtoEncodeFn encode_fn) {
|
||||
// Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes)
|
||||
uint8_t *len_pos = pos;
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
uint8_t *body_start = pos + 1;
|
||||
uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG);
|
||||
uint32_t body_size = static_cast<uint32_t>(after_body - body_start);
|
||||
if (body_size < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
// Common case: 1-byte varint, just backpatch
|
||||
*len_pos = static_cast<uint8_t>(body_size);
|
||||
return;
|
||||
return after_body;
|
||||
}
|
||||
// Compute extra bytes needed for varint beyond the 1 already reserved
|
||||
// Shift the body forward to make room for the extra length varint bytes
|
||||
uint8_t extra = ProtoSize::varint(body_size) - 1;
|
||||
// Shift body forward to make room for the extra varint bytes
|
||||
this->debug_check_bounds_(extra);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(after_body, extra);
|
||||
std::memmove(body_start + extra, body_start, body_size);
|
||||
uint8_t *end = this->pos_ + extra;
|
||||
// Write the full varint at len_pos
|
||||
this->pos_ = len_pos;
|
||||
this->encode_varint_raw(body_size);
|
||||
this->pos_ = end;
|
||||
(void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size);
|
||||
return after_body + extra;
|
||||
}
|
||||
|
||||
// Non-template core for encode_optional_sub_message.
|
||||
void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *,
|
||||
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
|
||||
if (nested_size == 0)
|
||||
return;
|
||||
this->encode_field_raw(field_id, 2);
|
||||
this->encode_varint_raw(nested_size);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *start = this->pos_;
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
if (static_cast<uint32_t>(this->pos_ - start) != nested_size)
|
||||
this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start);
|
||||
#else
|
||||
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
|
||||
#endif
|
||||
uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) {
|
||||
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size);
|
||||
return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG);
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
@@ -201,6 +184,14 @@ void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) {
|
||||
ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected);
|
||||
abort();
|
||||
}
|
||||
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) {
|
||||
ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected));
|
||||
if (actual == static_cast<ptrdiff_t>(expected))
|
||||
return;
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) {
|
||||
ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes,
|
||||
@@ -208,11 +199,6 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) {
|
||||
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
|
||||
expected, actual);
|
||||
abort();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "api_pb2_defines.h"
|
||||
#include "api_buffer.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
@@ -56,22 +57,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) {
|
||||
return (value & 1) ? static_cast<int64_t>(~(value >> 1)) : static_cast<int64_t>(value >> 1);
|
||||
}
|
||||
|
||||
/// Count number of varints in a packed buffer
|
||||
inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
uint16_t count = 0;
|
||||
while (len > 0) {
|
||||
// Skip varint bytes until we find one without continuation bit
|
||||
while (len > 0 && (*data & 0x80)) {
|
||||
data++;
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
data++;
|
||||
len--;
|
||||
count++;
|
||||
/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time.
|
||||
/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host.
|
||||
template<typename Word = size_t> inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
|
||||
constexpr size_t word_size = sizeof(Word);
|
||||
constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01
|
||||
const uint8_t *end = data + len;
|
||||
size_t continuations = 0;
|
||||
while (data != end) {
|
||||
// Unaligned word loads fault on Xtensa
|
||||
if ((reinterpret_cast<uintptr_t>(data) & (word_size - 1)) == 0 && static_cast<size_t>(end - data) >= word_size) {
|
||||
Word word;
|
||||
memcpy(&word, __builtin_assume_aligned(data, word_size), word_size);
|
||||
continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8);
|
||||
data += word_size;
|
||||
} else {
|
||||
continuations += *data++ >> 7;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return static_cast<uint16_t>(len - continuations);
|
||||
}
|
||||
|
||||
/// Encode a varint directly into a pre-allocated buffer.
|
||||
@@ -228,6 +232,8 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *
|
||||
/// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than
|
||||
/// assert(), so NDEBUG cannot switch it off.
|
||||
void proto_check_encode_end(const uint8_t *end, const uint8_t *expected);
|
||||
/// Aborts unless a sized sub-message (length prefix at len_pos) ended where its calculated size said.
|
||||
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end);
|
||||
#else
|
||||
#define PROTO_ENCODE_DEBUG_PARAM
|
||||
#define PROTO_ENCODE_DEBUG_ARG
|
||||
@@ -260,21 +266,6 @@ class ProtoWriteBuffer {
|
||||
* Following https://protobuf.dev/programming-guides/encoding/#structure
|
||||
*/
|
||||
void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); }
|
||||
/// Single-pass encode for repeated submessage elements.
|
||||
/// Thin template wrapper; all buffer work is in the non-template core.
|
||||
template<typename T> void encode_sub_message(uint32_t field_id, const T &value);
|
||||
/// Encode an optional singular submessage field — skips if empty.
|
||||
/// Thin template wrapper; all buffer work is in the non-template core.
|
||||
template<typename T> void encode_optional_sub_message(uint32_t field_id, const T &value);
|
||||
|
||||
// NOLINTBEGIN(readability-identifier-naming)
|
||||
// Non-template core for encode_sub_message — backpatch approach.
|
||||
void encode_sub_message(uint32_t field_id, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
|
||||
// Non-template core for encode_optional_sub_message.
|
||||
void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
|
||||
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
|
||||
// NOLINTEND(readability-identifier-naming)
|
||||
APIBuffer *get_buffer() const { return buffer_; }
|
||||
uint8_t *get_pos() const { return pos_; }
|
||||
void set_pos(uint8_t *pos) { pos_ = pos; }
|
||||
@@ -285,7 +276,6 @@ class ProtoWriteBuffer {
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION());
|
||||
void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual);
|
||||
#else
|
||||
void debug_check_bounds_([[maybe_unused]] size_t bytes) {}
|
||||
#endif
|
||||
@@ -310,6 +300,9 @@ class ProtoWriteBuffer {
|
||||
constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
|
||||
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
|
||||
|
||||
/// Generated encode body: writes the fields at pos, returns the cursor past them.
|
||||
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
|
||||
|
||||
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
|
||||
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
|
||||
/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default.
|
||||
@@ -473,6 +466,21 @@ class ProtoEncode {
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
/// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy.
|
||||
[[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
if (len == 0)
|
||||
return pos;
|
||||
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited
|
||||
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
|
||||
progmem_memcpy(pos, data, len);
|
||||
return pos + len;
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
@@ -572,22 +580,36 @@ class ProtoEncode {
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value));
|
||||
}
|
||||
/// Sub-message encoding: sync pos to buffer, delegate, read the cursor back.
|
||||
/// Repeated sub-message element; the constant tag is written inline.
|
||||
template<typename T>
|
||||
[[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_sub_message(field_id, value);
|
||||
return buffer.get_pos();
|
||||
uint32_t field_id, const T &value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
|
||||
return encode_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, &value, &T::encode_msg);
|
||||
}
|
||||
/// Singular sub-message field, skipped when it encodes to nothing.
|
||||
template<typename T>
|
||||
[[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id,
|
||||
const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_optional_sub_message(field_id, value);
|
||||
return buffer.get_pos();
|
||||
uint32_t field_id, const T &value) {
|
||||
uint32_t nested_size = T::calc_size_msg(&value);
|
||||
if (nested_size == 0)
|
||||
return pos;
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *end = encode_sized_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, nested_size, &value, &T::encode_msg);
|
||||
proto_check_sub_message_size(field_id, nested_size, pos, end);
|
||||
return end;
|
||||
#else
|
||||
return encode_sized_sub_message_body(pos, nested_size, &value, &T::encode_msg);
|
||||
#endif
|
||||
}
|
||||
/// Length and body, length backpatched after the body is written.
|
||||
[[nodiscard]] static uint8_t *encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
const void *value, ProtoEncodeFn encode_fn);
|
||||
/// Length and body for a precomputed size.
|
||||
[[nodiscard]] static uint8_t *encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t nested_size, const void *value,
|
||||
ProtoEncodeFn encode_fn);
|
||||
|
||||
private:
|
||||
/// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM
|
||||
@@ -701,9 +723,7 @@ class ProtoMessage {
|
||||
public:
|
||||
// Non-virtual defaults for messages with no fields; generated classes hide all four. The
|
||||
// static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk.
|
||||
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return buffer.get_pos();
|
||||
}
|
||||
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { return pos; }
|
||||
static uint32_t calc_size_msg(const void *self) { return 0; }
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); }
|
||||
uint32_t calculate_size() const { return 0; }
|
||||
@@ -957,18 +977,6 @@ class ProtoSize {
|
||||
}
|
||||
};
|
||||
|
||||
// Implementation of methods that depend on ProtoSize being fully defined
|
||||
|
||||
// Thin template wrapper; delegates to non-template core in proto.cpp.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_sub_message(field_id, &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
// Thin template wrapper; delegates to non-template core.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
template<typename T> const char *proto_enum_to_string(T value);
|
||||
|
||||
// ProtoService removed — its methods were inlined into APIConnection.
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""The device wizard of the api component: schema, validation and code generation.
|
||||
|
||||
Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_DESCRIPTION
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MAX_LENGTH,
|
||||
CONF_MULTIPLE,
|
||||
CONF_NAME,
|
||||
CONF_PAGES,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import (
|
||||
fnv1_hash,
|
||||
fnv1_hash_object_id,
|
||||
fnv1a_32bit_hash,
|
||||
zstd_module,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
API_DOMAIN = "api"
|
||||
|
||||
CONF_ENTITIES = "entities"
|
||||
CONF_ENTITY = "entity"
|
||||
CONF_INPUTS = "inputs"
|
||||
CONF_INTEGRATION = "integration"
|
||||
CONF_SUPPORTED_FEATURES = "supported_features"
|
||||
CONF_TITLE = "title"
|
||||
CONF_WIZARD = "wizard"
|
||||
|
||||
_API = cg.esphome_ns.namespace("api")
|
||||
WizardInput = _API.class_("WizardInput")
|
||||
|
||||
WIZARD_ENTITY_ID_BUFFER_SIZE = (
|
||||
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
|
||||
)
|
||||
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
|
||||
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
|
||||
# api.proto max_data_length documents the same value
|
||||
WIZARD_MULTIPLE_MAX_LENGTH = 4095
|
||||
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
|
||||
# Noise) and footer (16 bytes, Noise MAC)
|
||||
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
|
||||
# Version of the JSON the wizard is sent as, see wizard_document()
|
||||
WIZARD_JSON_VERSION = 1
|
||||
# zstd level the JSON is compressed with. Output is deterministic for a given zstd version.
|
||||
WIZARD_ZSTD_LEVEL = 19
|
||||
|
||||
# Wizard string limits; api.proto documents the same values
|
||||
WIZARD_TITLE_MAX_LENGTH = 127
|
||||
WIZARD_DESCRIPTION_MAX_LENGTH = 255
|
||||
WIZARD_FILTER_MAX_LENGTH = 63
|
||||
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127
|
||||
|
||||
|
||||
def _wizard_text(max_length: int) -> Callable[[Any], str]:
|
||||
"""A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder."""
|
||||
return cv.All(cv.string_strict, cv.Length(max=max_length))
|
||||
|
||||
|
||||
def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]:
|
||||
"""A single string or a list of strings, always validated to a non-empty list."""
|
||||
return cv.All(
|
||||
cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))),
|
||||
cv.Length(min=1),
|
||||
)
|
||||
|
||||
|
||||
# Mirrors Home Assistant's EntityFilterSelectorConfig
|
||||
WIZARD_ENTITY_FILTER_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_INTEGRATION): cv.All(
|
||||
cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
|
||||
cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
|
||||
cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings(
|
||||
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(
|
||||
CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES
|
||||
),
|
||||
)
|
||||
|
||||
WIZARD_ENTITY_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(cg.EntityBase),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_multiple(conf: ConfigType) -> ConfigType:
|
||||
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
|
||||
raise cv.Invalid(
|
||||
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
|
||||
"is only for standalone inputs (id)",
|
||||
path=[CONF_MULTIPLE],
|
||||
)
|
||||
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
|
||||
raise cv.Invalid(
|
||||
"max_length only applies to an input with multiple: true",
|
||||
path=[CONF_MAX_LENGTH],
|
||||
)
|
||||
return conf
|
||||
|
||||
|
||||
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
|
||||
# entity_id of its own: Home Assistant sets it
|
||||
WIZARD_INPUT_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
|
||||
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
|
||||
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
|
||||
),
|
||||
cv.Optional(CONF_TARGET): cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ENTITY): cv.All(
|
||||
cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
|
||||
_validate_multiple,
|
||||
)
|
||||
|
||||
WIZARD_PAGE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH),
|
||||
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
|
||||
cv.Optional(CONF_ENTITIES): cv.All(
|
||||
cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_INPUTS): cv.All(
|
||||
cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS),
|
||||
)
|
||||
|
||||
|
||||
def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]:
|
||||
return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])]
|
||||
|
||||
|
||||
def _wizard_input_id(conf: ConfigType) -> ID:
|
||||
"""The ID that names an input: the one a standalone input declares, or its linked entity."""
|
||||
return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY]
|
||||
|
||||
|
||||
def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType:
|
||||
"""An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique."""
|
||||
seen: dict[int, str] = {}
|
||||
for conf in _wizard_inputs(wizard):
|
||||
input_id = _wizard_input_id(conf).id
|
||||
if (key := fnv1_hash(input_id)) in seen:
|
||||
if seen[key] == input_id:
|
||||
raise cv.Invalid(f"Wizard input '{input_id}' is used more than once")
|
||||
raise cv.Invalid(
|
||||
f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one"
|
||||
)
|
||||
seen[key] = input_id
|
||||
return wizard
|
||||
|
||||
|
||||
WIZARD_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PAGES): cv.All(
|
||||
cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_unique_wizard_inputs,
|
||||
)
|
||||
|
||||
# Platforms of the homeassistant component that a wizard input can stand for
|
||||
WIZARD_INPUT_DOMAINS = (
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"number",
|
||||
"select",
|
||||
"sensor",
|
||||
"switch",
|
||||
"text",
|
||||
"text_sensor",
|
||||
)
|
||||
# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within
|
||||
WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text")
|
||||
|
||||
|
||||
def wizard_input_ids(api_config: ConfigType) -> set[str]:
|
||||
"""The IDs of the entities that are linked inputs of the wizard in the given api config."""
|
||||
if (wizard := api_config.get(CONF_WIZARD)) is None:
|
||||
return set()
|
||||
return {
|
||||
conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf
|
||||
}
|
||||
|
||||
|
||||
def _wizard_buffer_name(entity_id: ID) -> str:
|
||||
return f"api_wizard_input_{entity_id.id}"
|
||||
|
||||
|
||||
def wizard_input_buffer(entity_id: ID) -> str | None:
|
||||
"""Name of the RAM buffer holding the Home Assistant entity ID of a wizard input.
|
||||
|
||||
Returns None when the entity is not an input of the wizard. The buffer is defined by the api
|
||||
codegen, and the homeassistant entity of the input is given it in place of a constant.
|
||||
"""
|
||||
if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})):
|
||||
return None
|
||||
return _wizard_buffer_name(entity_id)
|
||||
|
||||
|
||||
def _wizard_input_declaration(
|
||||
config: fv.FinalValidateConfig, entity_id: ID
|
||||
) -> tuple[str, ConfigType]:
|
||||
"""The domain (like sensor) and the config an input ID is declared in."""
|
||||
path = config.get_path_for_id(entity_id)[:-1]
|
||||
return path[0], config.get_config_for_path(path)
|
||||
|
||||
|
||||
def _wizard_input_filters(
|
||||
conf: ConfigType, config: fv.FinalValidateConfig
|
||||
) -> list[ConfigType]:
|
||||
"""The entity filters of an input, with the defaults of a domain limited linked platform."""
|
||||
if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None:
|
||||
return filters
|
||||
if CONF_ENTITY in conf:
|
||||
domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY])
|
||||
if domains := _wizard_default_domains(domain):
|
||||
return [{CONF_DOMAIN: domains}]
|
||||
return []
|
||||
|
||||
|
||||
def _wizard_defines(wizard: ConfigType) -> set[str]:
|
||||
"""The defines for the parts of the wizard the configuration uses, so the rest is not compiled."""
|
||||
inputs = _wizard_inputs(wizard)
|
||||
defines = {"USE_API_WIZARD"}
|
||||
if inputs:
|
||||
defines.add("USE_API_WIZARD_INPUTS")
|
||||
if any(CONF_ENTITY in conf for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_LINKED_INPUTS")
|
||||
if any(CONF_ID in conf for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
|
||||
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
|
||||
return defines
|
||||
|
||||
|
||||
def _wizard_default_domains(domain: str) -> list[str] | None:
|
||||
"""The domains Home Assistant entities can be picked from when the input sets no target."""
|
||||
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
|
||||
platform = importlib.import_module(f"esphome.components.homeassistant.{domain}")
|
||||
return list(platform.SUPPORTED_DOMAINS)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_wizard_input(conf: ConfigType) -> ConfigType:
|
||||
if CONF_ENTITY not in conf:
|
||||
return conf
|
||||
domain, declaration = _wizard_input_declaration(
|
||||
fv.full_config.get(), conf[CONF_ENTITY]
|
||||
)
|
||||
if (
|
||||
declaration.get(CONF_PLATFORM) != "homeassistant"
|
||||
or domain not in WIZARD_INPUT_DOMAINS
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant "
|
||||
f"{', '.join(WIZARD_INPUT_DOMAINS)} entity"
|
||||
)
|
||||
if CONF_ENTITY_ID in declaration:
|
||||
# An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones
|
||||
raise cv.Invalid(
|
||||
f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a "
|
||||
"wizard input. Remove entity_id to let Home Assistant set it through the wizard."
|
||||
)
|
||||
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
|
||||
supported = _wizard_default_domains(domain)
|
||||
for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []):
|
||||
if not (domains := entity_filter.get(CONF_DOMAIN)):
|
||||
raise cv.Invalid(
|
||||
f"Every filter of a homeassistant {domain} input must set domain"
|
||||
)
|
||||
if unsupported := [d for d in domains if d not in supported]:
|
||||
raise cv.Invalid(
|
||||
f"The homeassistant {domain} does not support the domain(s) "
|
||||
f"{', '.join(unsupported)}. Supported: {', '.join(supported)}"
|
||||
)
|
||||
return conf
|
||||
|
||||
|
||||
def _validate_wizard_entity_exposed(value: ID) -> ID:
|
||||
"""Reject entities that are internal, as they are not exposed over the API, or have no name.
|
||||
|
||||
The key a client knows an entity by is a hash of its name. Without a name of its own, the
|
||||
device works the name out at runtime from its friendly name, which can add the MAC address,
|
||||
so the key cannot be known when the wizard is built.
|
||||
"""
|
||||
_, declaration = _wizard_input_declaration(fv.full_config.get(), value)
|
||||
if declaration.get(CONF_INTERNAL, False):
|
||||
raise cv.Invalid(
|
||||
f"Entity '{value.id}' is internal, so it is not exposed over the API "
|
||||
"and cannot be used in the wizard"
|
||||
)
|
||||
if not declaration.get(CONF_NAME):
|
||||
raise cv.Invalid(
|
||||
f"Entity '{value.id}' has no name of its own, so its key is not known "
|
||||
"when the wizard is built. Give it a name to use it in the wizard"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WIZARD): {
|
||||
cv.Optional(CONF_PAGES): [
|
||||
{
|
||||
cv.Optional(CONF_ENTITIES): [
|
||||
{cv.Optional(CONF_ID): _validate_wizard_entity_exposed}
|
||||
],
|
||||
cv.Optional(CONF_INPUTS): [_validate_wizard_input],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
"""Final validation of the wizard in the given api config, if it has one."""
|
||||
_WIZARD_FINAL_VALIDATE_SCHEMA(config)
|
||||
if (wizard := config.get(CONF_WIZARD)) is not None:
|
||||
size = len(wizard_blob(wizard, fv.full_config.get()))
|
||||
if size > WIZARD_RESPONSE_MAX_SIZE:
|
||||
raise cv.Invalid(
|
||||
f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} "
|
||||
f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. "
|
||||
"Shorten the texts or use fewer pages, entities or filters",
|
||||
path=[CONF_WIZARD],
|
||||
)
|
||||
|
||||
|
||||
WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value(
|
||||
{cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID
|
||||
)
|
||||
|
||||
# Only for standalone inputs: a linked input is read through its homeassistant entity
|
||||
automation.register_apply_condition(
|
||||
"api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()"
|
||||
)
|
||||
|
||||
|
||||
def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
|
||||
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
|
||||
document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])}
|
||||
if (device := declaration.get(CONF_DEVICE_ID)) is not None:
|
||||
document["device_id"] = fnv1a_32bit_hash(device.id)
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
document[CONF_DESCRIPTION] = description
|
||||
return document
|
||||
|
||||
|
||||
def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
document[CONF_DESCRIPTION] = description
|
||||
if conf.get(CONF_MULTIPLE):
|
||||
document["multiple"] = True
|
||||
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
|
||||
document["max_length"] = max_length
|
||||
if filters := _wizard_input_filters(conf, config):
|
||||
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
|
||||
return document
|
||||
|
||||
|
||||
def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
|
||||
"""The wizard as the JSON document the device sends, before it is serialised.
|
||||
|
||||
This is the format Home Assistant reads, and api.proto documents it for clients. Version 1:
|
||||
|
||||
{"version": 1,
|
||||
"pages": [{"title": "...", "description": "...",
|
||||
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
"entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
|
||||
Anything empty or unset, and every empty list, is left out. Strings are passed through as
|
||||
written, so they may be Home Assistant translation placeholders.
|
||||
|
||||
- An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the
|
||||
object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of
|
||||
the device it belongs to (esphome/core/config.py), and is left out for the main device.
|
||||
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
|
||||
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
|
||||
- multiple means the user may pick several entities. The client sends them in one
|
||||
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
|
||||
device trims spaces around each id and stores the list without them. The whole request is
|
||||
ignored when any id is empty or has no '.', or when the list without spaces does not fit
|
||||
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
|
||||
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
|
||||
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
|
||||
accepts.
|
||||
- entity_filters are the filters of the input, or the default filters of a linked switch,
|
||||
number, text, select or button.
|
||||
"""
|
||||
pages: list[ConfigType] = []
|
||||
for page in wizard[CONF_PAGES]:
|
||||
document: ConfigType = {}
|
||||
for key in (CONF_TITLE, CONF_DESCRIPTION):
|
||||
if value := page.get(key):
|
||||
document[key] = value
|
||||
if entities := [
|
||||
_entity_document(e, config) for e in page.get(CONF_ENTITIES, [])
|
||||
]:
|
||||
document[CONF_ENTITIES] = entities
|
||||
if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]:
|
||||
document[CONF_INPUTS] = inputs
|
||||
pages.append(document)
|
||||
return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages}
|
||||
|
||||
|
||||
def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes:
|
||||
"""The wizard document as compact, sorted UTF-8 JSON in a single zstd frame."""
|
||||
text = json.dumps(
|
||||
wizard_document(wizard, config),
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL)
|
||||
|
||||
|
||||
async def to_code(wizard: ConfigType) -> None:
|
||||
"""Emit the compressed wizard, the table of inputs and the defines.
|
||||
|
||||
The API reads both tables from its own sources, so they are externally linked PROGMEM arrays.
|
||||
"""
|
||||
blob = wizard_blob(wizard, CORE.config)
|
||||
cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob))
|
||||
cg.add_define("API_WIZARD_DATA_SIZE", len(blob))
|
||||
for define in sorted(_wizard_defines(wizard)):
|
||||
cg.add_define(define)
|
||||
entries: list[cg.RawExpression] = []
|
||||
for conf in _wizard_inputs(wizard):
|
||||
input_id = _wizard_input_id(conf)
|
||||
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
|
||||
buffer = _wizard_buffer_name(input_id)
|
||||
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
|
||||
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
|
||||
if CONF_ID in conf:
|
||||
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
|
||||
key = fnv1_hash(input_id.id)
|
||||
if conf[CONF_MULTIPLE]:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
|
||||
else:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
|
||||
if entries:
|
||||
cg.extern_progmem_array(
|
||||
"esphome::api::API_WIZARD_INPUTS",
|
||||
cg.esphome_ns.namespace("api").struct("WizardInputEntry"),
|
||||
entries,
|
||||
)
|
||||
cg.add_define("API_WIZARD_INPUT_COUNT", len(entries))
|
||||
@@ -35,10 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_AS3935_ID])
|
||||
|
||||
if distance_config := config.get(CONF_DISTANCE):
|
||||
sens = await sensor.new_sensor(distance_config)
|
||||
cg.add(hub.set_distance_sensor(sens))
|
||||
|
||||
if lightning_energy_config := config.get(CONF_LIGHTNING_ENERGY):
|
||||
sens = await sensor.new_sensor(lightning_energy_config)
|
||||
cg.add(hub.set_energy_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
|
||||
await sensors(CONF_LIGHTNING_ENERGY, hub.set_energy_sensor)
|
||||
|
||||
@@ -87,18 +87,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE):
|
||||
cg.add(var.set_out_of_range_mode(out_of_range_mode_config))
|
||||
|
||||
if raw_position_config := config.get(CONF_RAW_POSITION):
|
||||
sens = await sensor.new_sensor(raw_position_config)
|
||||
cg.add(var.set_raw_position_sensor(sens))
|
||||
|
||||
if gain_config := config.get(CONF_GAIN):
|
||||
sens = await sensor.new_sensor(gain_config)
|
||||
cg.add(var.set_gain_sensor(sens))
|
||||
|
||||
if magnitude_config := config.get(CONF_MAGNITUDE):
|
||||
sens = await sensor.new_sensor(magnitude_config)
|
||||
cg.add(var.set_magnitude_sensor(sens))
|
||||
|
||||
if status_config := config.get(CONF_STATUS):
|
||||
sens = await sensor.new_sensor(status_config)
|
||||
cg.add(var.set_status_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_RAW_POSITION, var.set_raw_position_sensor)
|
||||
await sensors(CONF_GAIN, var.set_gain_sensor)
|
||||
await sensors(CONF_MAGNITUDE, var.set_magnitude_sensor)
|
||||
await sensors(CONF_STATUS, var.set_status_sensor)
|
||||
|
||||
@@ -241,12 +241,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
if peak_current_config := conf.get(CONF_PEAK_CURRENT):
|
||||
sens = await sensor.new_sensor(peak_current_config)
|
||||
cg.add(var.set_peak_current_sensor(i, sens))
|
||||
if frequency_config := config.get(CONF_FREQUENCY):
|
||||
sens = await sensor.new_sensor(frequency_config)
|
||||
cg.add(var.set_freq_sensor(sens))
|
||||
if chip_temperature_config := config.get(CONF_CHIP_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(chip_temperature_config)
|
||||
cg.add(var.set_chip_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_FREQUENCY, var.set_freq_sensor)
|
||||
await sensors(CONF_CHIP_TEMPERATURE, var.set_chip_temperature_sensor)
|
||||
cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY]))
|
||||
cg.add(var.set_current_phases(config[CONF_CURRENT_PHASES]))
|
||||
cg.add(var.set_pga_gain(config[CONF_GAIN_PGA]))
|
||||
|
||||
@@ -36,14 +36,13 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
hub = await cg.get_variable(config[CONF_ID])
|
||||
|
||||
if phase_cfg := config.get(CONF_PHASE_STATUS):
|
||||
for i, key in enumerate(PHASE_KEYS):
|
||||
if sub_phase_cfg := phase_cfg.get(key):
|
||||
sens = await text_sensor.new_text_sensor(sub_phase_cfg)
|
||||
cg.add(parent.set_phase_status_text_sensor(i, sens))
|
||||
cg.add(hub.set_phase_status_text_sensor(i, sens))
|
||||
|
||||
if freq_status_config := config.get(CONF_FREQUENCY_STATUS):
|
||||
sens = await text_sensor.new_text_sensor(freq_status_config)
|
||||
cg.add(parent.set_freq_status_text_sensor(sens))
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_FREQUENCY_STATUS, hub.set_freq_status_text_sensor)
|
||||
|
||||
@@ -6,7 +6,7 @@ import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
include_builtin_idf_component,
|
||||
request_http_client,
|
||||
require_certificate_bundle,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
@@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_http_client")
|
||||
request_http_client()
|
||||
# HTTPS streams verify the server against the root certificate bundle
|
||||
require_certificate_bundle()
|
||||
|
||||
@@ -392,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
|
||||
if data.flac_support:
|
||||
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
|
||||
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
|
||||
_emit_memory_pair(
|
||||
data.flac.buffer_memory,
|
||||
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
|
||||
|
||||
@@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) {
|
||||
esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) {
|
||||
file_type = AudioFileType::NONE;
|
||||
|
||||
this->current_audio_file_ = audio_file;
|
||||
|
||||
@@ -47,7 +47,7 @@ class AudioReader {
|
||||
/// @param audio_file AudioFile struct containing the file.
|
||||
/// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read.
|
||||
/// @return ESP_OK
|
||||
esp_err_t start(AudioFile *audio_file, AudioFileType &file_type);
|
||||
esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type);
|
||||
|
||||
/// @brief Reads new file data from the source and sends to the ring buffer sink.
|
||||
/// @return AudioReaderState
|
||||
@@ -69,7 +69,7 @@ class AudioReader {
|
||||
|
||||
esp_http_client_handle_t client_{nullptr};
|
||||
|
||||
AudioFile *current_audio_file_{nullptr};
|
||||
const AudioFile *current_audio_file_{nullptr};
|
||||
AudioFileType audio_file_type_{AudioFileType::NONE};
|
||||
const uint8_t *file_current_{nullptr};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ class AudioDac {
|
||||
virtual bool is_muted() = 0;
|
||||
virtual float volume() = 0;
|
||||
|
||||
/// Called from the main loop by the speaker driving this DAC once its audio clocks are running.
|
||||
/// DACs that can only be configured while clocked (e.g. DSP state lost across clock stops) override this.
|
||||
virtual void on_audio_started() {}
|
||||
|
||||
protected:
|
||||
bool is_muted_{false};
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression
|
||||
from esphome.external_files import download_web_files_in_config
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
CONF_FILE_DATA_ID = "file_data_id"
|
||||
|
||||
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
|
||||
cv.Required(CONF_FILE): _file_schema,
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All:
|
||||
|
||||
|
||||
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
|
||||
"""Generate the progmem data and a flash AudioFile for one file.
|
||||
|
||||
Returns the created Pvariable. Caller is responsible for any further
|
||||
Returns a const pointer to the AudioFile. Caller is responsible for any further
|
||||
registration (the audio_file component additionally registers each file in
|
||||
its named C++ registry; other consumers may skip that).
|
||||
"""
|
||||
@@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
else:
|
||||
data, media_file_type = read_audio_file_and_type(file_config)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
# Global constants so the AudioFile lives in flash; the id stays a plain pointer
|
||||
# because actions render id arguments as ``::<id>``.
|
||||
data_id = file_config[CONF_RAW_DATA_ID]
|
||||
cg.add_global(
|
||||
ProgmemAssignmentExpression(
|
||||
data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data])
|
||||
)
|
||||
)
|
||||
media_file = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
("data", prog_arr),
|
||||
("length", len(rhs)),
|
||||
("data", MockObj(data_id, ".")),
|
||||
("length", len(data)),
|
||||
("file_type", media_file_type),
|
||||
)
|
||||
|
||||
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
|
||||
file_var_id = file_config[CONF_ID]
|
||||
storage = file_config[CONF_FILE_DATA_ID]
|
||||
cg.add_global(
|
||||
cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};")
|
||||
)
|
||||
cg.add_global(
|
||||
cg.RawStatement(
|
||||
f"static const {storage.type} *const {file_var_id} = &{storage};"
|
||||
)
|
||||
)
|
||||
var = MockObj(file_var_id, "->")
|
||||
CORE.register_variable(file_var_id, var)
|
||||
return var
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
namespace esphome::audio_file {
|
||||
|
||||
struct NamedAudioFile {
|
||||
audio::AudioFile *file;
|
||||
const audio::AudioFile *file;
|
||||
const char *file_id;
|
||||
};
|
||||
|
||||
inline StaticVector<NamedAudioFile, AUDIO_FILE_MAX_FILES>
|
||||
named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) {
|
||||
inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) {
|
||||
named_audio_files.push_back({file, file_id});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component,
|
||||
protected:
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
audio::AudioFile *current_file_{nullptr};
|
||||
const audio::AudioFile *current_file_{nullptr};
|
||||
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
|
||||
@@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() {
|
||||
" Persistent Ring Buffer: %s\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_));
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr));
|
||||
#endif
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::setup() {
|
||||
@@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() {
|
||||
config.reader_stack_size = READER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
// micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle.
|
||||
if (this->http_ca_certificate_ != nullptr) {
|
||||
config.http_ca_certificate = this->http_ca_certificate_;
|
||||
}
|
||||
#endif
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
|
||||
@@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component,
|
||||
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; }
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
/// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs,
|
||||
/// replacing the built-in certificate bundle.
|
||||
void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; }
|
||||
#endif
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
@@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component,
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
bool persistent_ring_buffer_{false};
|
||||
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
|
||||
const char *http_ca_certificate_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
AUTO_LOAD = ["audio"]
|
||||
|
||||
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
|
||||
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
|
||||
CONF_HTTP_REQUEST = "http_request"
|
||||
CONF_VERIFY_SSL = "verify_ssl"
|
||||
|
||||
audio_http_ns = cg.esphome_ns.namespace("audio_http")
|
||||
AudioHTTPMediaSource = audio_http_ns.class_(
|
||||
@@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
|
||||
# Default to the CA certificate configured on the http_request component so
|
||||
# HTTPS playback verifies against the same trust anchor without repeating
|
||||
# the option on every source. Needed because audio_http sources are often
|
||||
# declared by device packages and cannot be extended from the device config.
|
||||
# The PEM replaces the built-in certificate bundle as the sole trust anchor,
|
||||
# so make the inheritance visible in the log.
|
||||
if CONF_CA_CERTIFICATE_PATH in config:
|
||||
return config
|
||||
fconf = fv.full_config.get()
|
||||
if CONF_HTTP_REQUEST not in fconf:
|
||||
return config
|
||||
http_request_config = fconf[CONF_HTTP_REQUEST]
|
||||
# Mirror http_request's own semantics: it only applies its CA when SSL
|
||||
# verification is enabled, so neither does the inheritance.
|
||||
if not http_request_config.get(CONF_VERIFY_SSL, True):
|
||||
return config
|
||||
if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
_LOGGER.info(
|
||||
"audio_http source '%s' is inheriting ca_certificate_path from the "
|
||||
"http_request component; HTTPS playback will verify against that PEM "
|
||||
"instead of the built-in certificate bundle",
|
||||
config[CONF_ID].id,
|
||||
)
|
||||
# Already validated by http_request's schema; the value is a resolved Path.
|
||||
config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
@@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean,
|
||||
cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER]))
|
||||
|
||||
# Embed the certificate content, like http_request does. Passed to
|
||||
# micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as
|
||||
# the sole trust anchor for HTTPS playback URLs instead of the certificate
|
||||
# bundle.
|
||||
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE")
|
||||
with Path(ca_cert_path).open(encoding="utf-8") as f:
|
||||
ca_cert_content = f.read()
|
||||
cg.add(var.set_http_ca_certificate(ca_cert_content))
|
||||
|
||||
@@ -44,10 +44,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await register_bedjet_child(var, config)
|
||||
|
||||
if outlet_temperature_sensor := config.get(CONF_OUTLET_TEMPERATURE):
|
||||
sensor_var = await sensor.new_sensor(outlet_temperature_sensor)
|
||||
cg.add(var.set_outlet_temperature_sensor(sensor_var))
|
||||
|
||||
if ambient_temperature_sensor := config.get(CONF_AMBIENT_TEMPERATURE):
|
||||
sensor_var = await sensor.new_sensor(ambient_temperature_sensor)
|
||||
cg.add(var.set_ambient_temperature_sensor(sensor_var))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_OUTLET_TEMPERATURE, var.set_outlet_temperature_sensor)
|
||||
await sensors(CONF_AMBIENT_TEMPERATURE, var.set_ambient_temperature_sensor)
|
||||
|
||||
@@ -11,29 +11,34 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// True if `address` is in `table`, a list of MACs ended by 0.
|
||||
inline bool mac_in_table(const uint64_t *table, uint64_t address) {
|
||||
for (; *table != 0; table++) {
|
||||
if (*table == address)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
|
||||
/// Table of MACs ended by 0; must outlive the trigger.
|
||||
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
|
||||
device.address_uint64()) == this->addresses_.end()) {
|
||||
if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64()))
|
||||
return false;
|
||||
}
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
FixedVector<uint64_t> addresses_;
|
||||
const uint64_t *addresses_{nullptr};
|
||||
};
|
||||
|
||||
// on_ble_service_data_advertise: fires when an advertisement contains service
|
||||
|
||||
@@ -69,13 +69,24 @@ def uuid_trigger_schema(
|
||||
)
|
||||
|
||||
|
||||
def _filter_mac(value: Any) -> Any:
|
||||
mac = cv.mac_address(value)
|
||||
if not any(mac.parts):
|
||||
# 0 ends the flash MAC table, and it is never a real device address.
|
||||
raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter")
|
||||
return mac
|
||||
|
||||
|
||||
MAC_FILTER_LIST = cv.ensure_list(_filter_mac)
|
||||
|
||||
|
||||
def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]:
|
||||
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
|
||||
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]
|
||||
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
def mac_filter_table(macs: list) -> cg.MockObj:
|
||||
"""Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer."""
|
||||
return cg.shared_progmem_array(
|
||||
"ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0]
|
||||
)
|
||||
|
||||
|
||||
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
|
||||
if macs := conf.get(CONF_MAC_ADDRESS):
|
||||
cg.add(trigger.set_addresses(mac_filter_table(macs)))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
zephyr_add_prj_conf("BT_NUS", True)
|
||||
zephyr_add_prj_conf("RING_BUFFER", True)
|
||||
request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU
|
||||
expose_log = config[CONF_TYPE] == CONF_LOGS
|
||||
cg.add(var.set_expose_log(expose_log))
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <esp_idf_version.h>
|
||||
|
||||
// cancel_open exists from ESP-IDF 5.5.5 and 6.0.1.
|
||||
#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 5) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)) || \
|
||||
ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 1)
|
||||
#define BLUEDROID_HAS_CANCEL_OPEN
|
||||
#endif
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
@@ -63,6 +70,12 @@ void BluedroidGattClient::loop() {
|
||||
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
|
||||
// teardown whose OPEN_EVT never arrives.
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
this->cancel_pending_open_();
|
||||
} else if (st == ClientState::CONNECTING) {
|
||||
// CONNECT_EVT came but OPEN_EVT was lost, so our open still holds the ACL link.
|
||||
this->check_and_log_error_("esp_ble_gap_disconnect", esp_ble_gap_disconnect(this->remote_bda_));
|
||||
}
|
||||
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
|
||||
// Release before idling: a lost completion must not leak the cache.
|
||||
this->release_services();
|
||||
@@ -140,6 +153,8 @@ void BluedroidGattClient::tracker_connect_() {
|
||||
this->services_released_ = false;
|
||||
this->seen_mtu_ = false;
|
||||
this->mtu_failed_ = false;
|
||||
this->cancel_open_sent_ = false;
|
||||
this->conn_id_ = UNSET_CONN_ID;
|
||||
this->enable_loop();
|
||||
this->set_state(ClientState::CONNECTING);
|
||||
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
|
||||
@@ -183,12 +198,28 @@ int BluedroidGattClient::gatt_disconnect() {
|
||||
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
|
||||
this->disconnecting_started_ = millis();
|
||||
this->enable_loop();
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
// CANCEL_OPEN_EVT or a racing OPEN_EVT settles the slot.
|
||||
this->cancel_pending_open_();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
this->unconditional_disconnect_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void BluedroidGattClient::cancel_pending_open_() {
|
||||
#ifdef BLUEDROID_HAS_CANCEL_OPEN
|
||||
esp_ble_gattc_cancel_open_params_t params{};
|
||||
params.gattc_if = this->gattc_if_;
|
||||
memcpy(params.remote_bda, this->remote_bda_, sizeof(esp_bd_addr_t));
|
||||
// A refused cancel schedules no CANCEL_OPEN_EVT, so the teardown stays cancellable.
|
||||
if (this->check_and_log_error_("esp_ble_gattc_cancel_open", esp_ble_gattc_cancel_open(¶ms)) == ESP_OK) {
|
||||
this->cancel_open_sent_ = true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluedroidGattClient::unconditional_disconnect_() {
|
||||
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
@@ -214,6 +245,10 @@ bool BluedroidGattClient::cancel_gatt_disconnect() {
|
||||
if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) {
|
||||
return false;
|
||||
}
|
||||
if (this->cancel_open_sent_) {
|
||||
// The cancel already went out, so CANCEL_OPEN_EVT ends it.
|
||||
return false;
|
||||
}
|
||||
this->want_disconnect_ = false;
|
||||
return true;
|
||||
}
|
||||
@@ -574,6 +609,8 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
this->listener_->on_connection_state(false, 0, param->open.status);
|
||||
return;
|
||||
}
|
||||
// ALREADY_OPEN on an existing link sends no CONNECT_EVT, so take the id here.
|
||||
this->conn_id_ = param->open.conn_id;
|
||||
if (this->disconnect_pending()) {
|
||||
// Open resolved with a teardown scheduled: close now (conn_id_ stays set
|
||||
// so CLOSE_EVT still matches).
|
||||
@@ -617,6 +654,11 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
|
||||
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
|
||||
return;
|
||||
}
|
||||
if (this->conn_id_ == UNSET_CONN_ID) {
|
||||
// No CONNECT_EVT for this attempt yet, so the event belongs to an earlier
|
||||
// link, such as one the teardown timeout forced down.
|
||||
return;
|
||||
}
|
||||
// Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting
|
||||
// earlier makes the controller reject with 133 or assert) and before
|
||||
// reporting - the wrapper frees the slot on the report, and a freed slot
|
||||
@@ -645,6 +687,10 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
case ESP_GATTC_CONNECT_EVT: {
|
||||
if (!this->check_addr_(param->connect.remote_bda))
|
||||
return false;
|
||||
// Every client interface gets CONNECT_EVT for every new link; only an
|
||||
// attempt in flight owns it. An idle slot must not adopt another slot's link.
|
||||
if (this->state() != ClientState::CONNECTING)
|
||||
break;
|
||||
this->conn_id_ = param->connect.conn_id;
|
||||
// MTU request here rather than OPEN_EVT, matching the IDF examples.
|
||||
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
|
||||
@@ -656,8 +702,15 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
if (!this->check_addr_(param->open.remote_bda))
|
||||
if (!this->check_addr_(param->open.remote_bda)) {
|
||||
// Our interface (filtered above), stale address, so nobody tracks this link.
|
||||
if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) {
|
||||
ESP_LOGW(TAG, "[%d] Closing link left by an abandoned open", this->connection_index_);
|
||||
this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this->handle_open_evt_(param);
|
||||
break;
|
||||
}
|
||||
@@ -748,6 +801,24 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
|
||||
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
|
||||
break;
|
||||
}
|
||||
#ifdef BLUEDROID_HAS_CANCEL_OPEN
|
||||
case ESP_GATTC_CANCEL_OPEN_EVT: {
|
||||
if (!this->check_addr_(param->cancel_open.remote_bda))
|
||||
return false;
|
||||
if (param->cancel_open.status != ESP_GATT_OK) {
|
||||
// Too late to cancel: OPEN_EVT follows and settles the slot. The latch
|
||||
// stays set because the event cannot be tied to this attempt's cancel.
|
||||
break;
|
||||
}
|
||||
if (this->state() == ClientState::CONNECTING && this->disconnect_pending()) {
|
||||
ESP_LOGD(TAG, "[%d] Pending open cancelled", this->connection_index_);
|
||||
this->release_services();
|
||||
this->set_idle_();
|
||||
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_CONN_CANCEL);
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
int handle_search_cmpl_(esp_gatt_status_t status);
|
||||
void deliver_pending_search_();
|
||||
void unconditional_disconnect_();
|
||||
void cancel_pending_open_();
|
||||
void set_idle_();
|
||||
void set_disconnecting_();
|
||||
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
|
||||
@@ -133,6 +134,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
bool seen_mtu_ : 1 {false};
|
||||
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
|
||||
bool mtu_failed_ : 1 {false};
|
||||
// esp_ble_gattc_cancel_open() was accepted; CANCEL_OPEN_EVT or OPEN_EVT ends
|
||||
// the attempt, so the scheduled teardown can no longer be cancelled.
|
||||
bool cancel_open_sent_ : 1 {false};
|
||||
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
|
||||
// completes from it. Reset by set_idle_().
|
||||
static_assert(static_cast<uint8_t>(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow");
|
||||
|
||||
@@ -131,24 +131,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
|
||||
|
||||
if humidity_config := config.get(CONF_HUMIDITY):
|
||||
sens = await sensor.new_sensor(humidity_config)
|
||||
cg.add(var.set_humidity_sensor(sens))
|
||||
cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING]))
|
||||
|
||||
if gas_resistance_config := config.get(CONF_GAS_RESISTANCE):
|
||||
sens = await sensor.new_sensor(gas_resistance_config)
|
||||
cg.add(var.set_gas_resistance_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING]
|
||||
cg.add(var.set_temperature_oversampling(oversampling))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING]))
|
||||
if await sensors(CONF_HUMIDITY, var.set_humidity_sensor):
|
||||
cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING]))
|
||||
await sensors(CONF_GAS_RESISTANCE, var.set_gas_resistance_sensor)
|
||||
|
||||
cg.add(var.set_iir_filter(IIR_FILTER_OPTIONS[config[CONF_IIR_FILTER]]))
|
||||
|
||||
|
||||
@@ -88,16 +88,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
for d in ["x", "y", "z"]:
|
||||
accel_key = f"acceleration_{d}"
|
||||
if accel_key in config:
|
||||
sens = await sensor.new_sensor(config[accel_key])
|
||||
cg.add(getattr(var, f"set_accel_{d}_sensor")(sens))
|
||||
accel_key = f"gyroscope_{d}"
|
||||
if accel_key in config:
|
||||
sens = await sensor.new_sensor(config[accel_key])
|
||||
cg.add(getattr(var, f"set_gyro_{d}_sensor")(sens))
|
||||
|
||||
if CONF_TEMPERATURE in config:
|
||||
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor)
|
||||
await sensors(CONF_GYROSCOPE_X, var.set_gyro_x_sensor)
|
||||
await sensors(CONF_ACCELERATION_Y, var.set_accel_y_sensor)
|
||||
await sensors(CONF_GYROSCOPE_Y, var.set_gyro_y_sensor)
|
||||
await sensors(CONF_ACCELERATION_Z, var.set_accel_z_sensor)
|
||||
await sensors(CONF_GYROSCOPE_Z, var.set_gyro_z_sensor)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
|
||||
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature(sens))
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature)
|
||||
await sensors(CONF_PRESSURE, var.set_pressure)
|
||||
|
||||
@@ -75,14 +75,13 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
|
||||
|
||||
cg.add(var.set_iir_filter(config[CONF_IIR_FILTER]))
|
||||
|
||||
@@ -80,18 +80,17 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_iir_filter_config(config[CONF_IIR_FILTER]))
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(
|
||||
var.set_temperature_oversampling_config(
|
||||
temperature_config[CONF_OVERSAMPLING]
|
||||
)
|
||||
)
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
|
||||
|
||||
return var
|
||||
|
||||
@@ -137,9 +137,9 @@ CONFIG_SCHEMA_BASE = cv.Schema(
|
||||
async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
if temperature_config := config.get(CONF_TEMPERATURE):
|
||||
sens = await sensor.new_sensor(temperature_config)
|
||||
cg.add(var.set_temperature_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
|
||||
temperature_config = config[CONF_TEMPERATURE]
|
||||
cg.add(
|
||||
var.set_temperature_oversampling_config(
|
||||
temperature_config[CONF_OVERSAMPLING]
|
||||
@@ -149,9 +149,8 @@ async def to_code_base(config: ConfigType) -> MockObj:
|
||||
var.set_temperature_iir_filter_config(temperature_config[CONF_IIR_FILTER])
|
||||
)
|
||||
|
||||
if pressure_config := config.get(CONF_PRESSURE):
|
||||
sens = await sensor.new_sensor(pressure_config)
|
||||
cg.add(var.set_pressure_sensor(sens))
|
||||
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
|
||||
pressure_config = config[CONF_PRESSURE]
|
||||
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
|
||||
cg.add(var.set_pressure_iir_filter_config(pressure_config[CONF_IIR_FILTER]))
|
||||
|
||||
|
||||
@@ -422,6 +422,21 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T
|
||||
if (this->temperature_ != nullptr) {
|
||||
const int16_t raw = encode_uint16(value[1], value[0]);
|
||||
this->temperature_->publish_state(raw * 0.1f);
|
||||
reported = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x2E: { // humidity (uint8, 1%), e.g. Shelly BLU H&T
|
||||
if (this->humidity_ != nullptr) {
|
||||
this->humidity_->publish_state(value[0]);
|
||||
reported = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
|
||||
CONF_CANBUS_ID = "canbus_id"
|
||||
CONF_BIT_RATE = "bit_rate"
|
||||
CONF_ON_FRAME = "on_frame"
|
||||
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
|
||||
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
|
||||
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
|
||||
|
||||
|
||||
def validate_id(config: ConfigType) -> ConfigType:
|
||||
@@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(CanbusComponent),
|
||||
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
|
||||
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
|
||||
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
|
||||
CAN_SPEEDS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
|
||||
): cv.positive_not_null_int,
|
||||
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
|
||||
@@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
|
||||
|
||||
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
|
||||
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
|
||||
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
|
||||
cg.add(var.set_can_id(config[CONF_CAN_ID]))
|
||||
if config[CONF_USE_EXTENDED_ID]:
|
||||
cg.add(var.set_use_extended_id(True))
|
||||
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
|
||||
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
|
||||
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
|
||||
cg.add(var.set_max_frames_per_loop(max_frames))
|
||||
|
||||
for conf in config.get(CONF_ON_FRAME, []):
|
||||
can_id = conf[CONF_CAN_ID]
|
||||
|
||||
@@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
|
||||
|
||||
void Canbus::loop() {
|
||||
struct CanFrame can_message;
|
||||
// read all messages until queue is empty
|
||||
int message_counter = 0;
|
||||
while (this->read_message(&can_message) == canbus::ERROR_OK) {
|
||||
message_counter++;
|
||||
uint32_t message_counter = 0;
|
||||
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
|
||||
if (this->read_message(&can_message) != canbus::ERROR_OK) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (can_message.use_extended_id) {
|
||||
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
|
||||
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
|
||||
can_message.can_id, can_message.can_data_length_code);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
|
||||
can_message.can_data_length_code);
|
||||
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
|
||||
can_message.can_id, can_message.can_data_length_code);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> data;
|
||||
@@ -100,6 +102,12 @@ void Canbus::loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message_counter == this->max_frames_per_loop_) {
|
||||
ESP_LOGW(TAG,
|
||||
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
|
||||
this->max_frames_per_loop_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::canbus
|
||||
|
||||
@@ -78,6 +78,7 @@ class Canbus : public Component {
|
||||
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
|
||||
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
|
||||
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
|
||||
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
|
||||
|
||||
void add_trigger(CanbusTrigger *trigger);
|
||||
/**
|
||||
@@ -96,8 +97,9 @@ class Canbus : public Component {
|
||||
template<typename... Ts> friend class CanbusSendAction;
|
||||
std::vector<CanbusTrigger *> triggers_{};
|
||||
uint32_t can_id_;
|
||||
bool use_extended_id_;
|
||||
CanSpeed bit_rate_;
|
||||
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
|
||||
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
|
||||
bool use_extended_id_{false};
|
||||
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
|
||||
callback_manager_{};
|
||||
|
||||
|
||||
@@ -253,11 +253,17 @@ void CC1101Component::dump_config() {
|
||||
}
|
||||
|
||||
void CC1101Component::begin_tx() {
|
||||
// Read the mode before the PKTCTRL0 write below overwrites it in state_
|
||||
const bool packet_mode = this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
|
||||
// Ensure Packet Format is 3 (Async Serial)
|
||||
this->write_(Register::PKTCTRL0, 0x32);
|
||||
ESP_LOGV(TAG, "Beginning TX sequence");
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
// setup() only attaches the interrupt in packet mode. In async serial mode the pin may be
|
||||
// shared with remote_receiver, and detaching here would remove the receiver's interrupt.
|
||||
if (packet_mode) {
|
||||
this->gdo0_pin_->detach_interrupt();
|
||||
}
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
|
||||
}
|
||||
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
|
||||
|
||||
@@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if eco2_config := config.get(CONF_ECO2):
|
||||
sens = await sensor.new_sensor(eco2_config)
|
||||
cg.add(var.set_co2(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_ECO2, var.set_co2)
|
||||
await sensors(CONF_TVOC, var.set_tvoc)
|
||||
|
||||
if tvoc_config := config.get(CONF_TVOC):
|
||||
sens = await sensor.new_sensor(tvoc_config)
|
||||
cg.add(var.set_tvoc(sens))
|
||||
|
||||
if version_config := config.get(CONF_VERSION):
|
||||
sens = await text_sensor.new_text_sensor(version_config)
|
||||
cg.add(var.set_version(sens))
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_VERSION, var.set_version)
|
||||
|
||||
if (baseline := config.get(CONF_BASELINE)) is not None:
|
||||
cg.add(var.set_baseline(baseline))
|
||||
|
||||
@@ -368,19 +368,11 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
|
||||
}
|
||||
|
||||
void Climate::save_state_(const ClimateTraits &traits) {
|
||||
#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wclass-memaccess"
|
||||
#define TEMP_IGNORE_MEMACCESS
|
||||
#endif
|
||||
ClimateDeviceRestoreState state{};
|
||||
// initialize as zero (including padding) to prevent random data on stack triggering erase
|
||||
// initialize as zero (including padding) to prevent random data on stack triggering erase;
|
||||
// the void * cast tells GCC the bytewise clear of a non-trivial type is intentional
|
||||
// NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save
|
||||
memset(&state, 0, sizeof(ClimateDeviceRestoreState));
|
||||
#ifdef TEMP_IGNORE_MEMACCESS
|
||||
#pragma GCC diagnostic pop
|
||||
#undef TEMP_IGNORE_MEMACCESS
|
||||
#endif
|
||||
memset(static_cast<void *>(&state), 0, sizeof(ClimateDeviceRestoreState));
|
||||
|
||||
state.mode = this->mode;
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
|
||||
@@ -54,9 +54,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
if co2_config := config.get(CONF_CO2):
|
||||
sens = await sensor.new_sensor(co2_config)
|
||||
cg.add(var.set_co2_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_CO2, var.set_co2_sensor)
|
||||
|
||||
|
||||
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
@@ -18,8 +16,6 @@ from esphome.const import (
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
|
||||
|
||||
combination_ns = cg.esphome_ns.namespace("combination")
|
||||
@@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _migrate_coeffecient(config: ConfigType) -> ConfigType:
|
||||
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
|
||||
if CONF_COEFFECIENT in config:
|
||||
if CONF_COEFFICIENT in config:
|
||||
raise cv.Invalid(
|
||||
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
|
||||
CONF_COEFFECIENT,
|
||||
CONF_COEFFICIENT,
|
||||
)
|
||||
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
|
||||
elif CONF_COEFFICIENT not in config:
|
||||
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
|
||||
return config
|
||||
|
||||
|
||||
LINEAR_SOURCE_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_COEFFECIENT,
|
||||
CONF_COEFFICIENT,
|
||||
removed_in="2026.12.0",
|
||||
component="combination",
|
||||
),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
|
||||
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
|
||||
cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_),
|
||||
}
|
||||
),
|
||||
_migrate_coeffecient,
|
||||
)
|
||||
|
||||
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
|
||||
@@ -203,6 +185,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
cg.add(var.add_source(source))
|
||||
|
||||
if CONF_STD_DEV in config:
|
||||
sens = await sensor.new_sensor(config[CONF_STD_DEV])
|
||||
cg.add(var.set_std_dev_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_STD_DEV, var.set_std_dev_sensor)
|
||||
|
||||
@@ -7,6 +7,7 @@ BYTE_ORDER_BIG = "big_endian"
|
||||
|
||||
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
|
||||
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
|
||||
CONF_ALLOWED_IPS = "allowed_ips"
|
||||
CONF_B_CONSTANT = "b_constant"
|
||||
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
|
||||
CONF_BYTE_ORDER = "byte_order"
|
||||
@@ -15,6 +16,7 @@ CONF_CLIMATE_ID = "climate_id"
|
||||
CONF_CO2_EQUIVALENT = "co2_equivalent"
|
||||
CONF_COLOR_DEPTH = "color_depth"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_CONNECTED = "connected"
|
||||
CONF_CONVERSION_RATE = "conversion_rate"
|
||||
CONF_CRC_ENABLE = "crc_enable"
|
||||
CONF_DATA_BITS = "data_bits"
|
||||
@@ -41,7 +43,9 @@ CONF_ON_SCAN_END = "on_scan_end"
|
||||
CONF_ON_STATE_CHANGE = "on_state_change"
|
||||
CONF_PARITY = "parity"
|
||||
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
|
||||
CONF_RECONNECT_INTERVAL = "reconnect_interval"
|
||||
CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROLE = "role"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SHA256 = "sha256"
|
||||
|
||||
@@ -117,15 +117,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_hpf_enable(config[CONF_CURRENT_HPF], config[CONF_VOLTAGE_HPF]))
|
||||
cg.add(var.set_pulse_energy_wh(config[CONF_PULSE_ENERGY]))
|
||||
|
||||
if voltage_config := config.get(CONF_VOLTAGE):
|
||||
sens = await sensor.new_sensor(voltage_config)
|
||||
cg.add(var.set_voltage_sensor(sens))
|
||||
if current_config := config.get(CONF_CURRENT):
|
||||
sens = await sensor.new_sensor(current_config)
|
||||
cg.add(var.set_current_sensor(sens))
|
||||
if power_config := config.get(CONF_POWER):
|
||||
sens = await sensor.new_sensor(power_config)
|
||||
cg.add(var.set_power_sensor(sens))
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
|
||||
await sensors(CONF_CURRENT, var.set_current_sensor)
|
||||
await sensors(CONF_POWER, var.set_power_sensor)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user