mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 18:58:23 +00:00
Compare commits
1751 Commits
2026.2.0b2
...
api-server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc15ff495 | ||
|
|
79b741b8dc | ||
|
|
112646a9c4 | ||
|
|
2e096bb036 | ||
|
|
e87e78c544 | ||
|
|
0f25d91e68 | ||
|
|
8dbdcfc128 | ||
|
|
8950afc3c4 | ||
|
|
04d067196d | ||
|
|
502c010465 | ||
|
|
180105bb4b | ||
|
|
4c0dfb0e0d | ||
|
|
df987a7ffb | ||
|
|
c8d4420408 | ||
|
|
b084fa4490 | ||
|
|
68625a1b76 | ||
|
|
dc57969afd | ||
|
|
f092e619d8 | ||
|
|
58f6ad2d0c | ||
|
|
bc33260c61 | ||
|
|
4cab262ef8 | ||
|
|
9ad820c921 | ||
|
|
4f8feb86f0 | ||
|
|
b5ccd55f4e | ||
|
|
a437b3086b | ||
|
|
c27f9e512b | ||
|
|
f62972c2c6 | ||
|
|
f36efbc762 | ||
|
|
9caf9ee023 | ||
|
|
94e300389c | ||
|
|
55bcf33446 | ||
|
|
f132b7dc07 | ||
|
|
baa6d5f96b | ||
|
|
773b4d887b | ||
|
|
ac7f0f0b74 | ||
|
|
bc7f35b569 | ||
|
|
ae02ab3865 | ||
|
|
eceb534895 | ||
|
|
404620b99c | ||
|
|
3ccaa771a7 | ||
|
|
b4a86e46b2 | ||
|
|
ddf1426f86 | ||
|
|
90d7bfe02e | ||
|
|
d759f1a567 | ||
|
|
f757cd1210 | ||
|
|
9b45b046a8 | ||
|
|
70ae614abd | ||
|
|
8f9b91eece | ||
|
|
3ca86fc3fc | ||
|
|
b38db617a2 | ||
|
|
13fe881f70 | ||
|
|
50c181671c | ||
|
|
43a371caab | ||
|
|
64290d32a1 | ||
|
|
9685d4eb0b | ||
|
|
4c2efd4165 | ||
|
|
6f00ea1457 | ||
|
|
a881121110 | ||
|
|
f8167c9a70 | ||
|
|
e1d629f0d2 | ||
|
|
224cc7b419 | ||
|
|
4d4347d33a | ||
|
|
6ca5b31fab | ||
|
|
17f9269841 | ||
|
|
6253947311 | ||
|
|
00b71208a6 | ||
|
|
76eb8f697f | ||
|
|
2a3bd8bc85 | ||
|
|
629da4d878 | ||
|
|
5c2ceb63e0 | ||
|
|
92cb6dd7fd | ||
|
|
06e5931ad7 | ||
|
|
dc5b06285d | ||
|
|
3d0a2421a6 | ||
|
|
22f6791dea | ||
|
|
70b1d9a087 | ||
|
|
36720c8495 | ||
|
|
c48ab2ef92 | ||
|
|
162ee2ecaf | ||
|
|
a73bac0b5f | ||
|
|
4e84611ae7 | ||
|
|
ea2e36e55a | ||
|
|
fcbc4d64fe | ||
|
|
dcd103cec0 | ||
|
|
5e715692d6 | ||
|
|
d5263cd46e | ||
|
|
c399cd2fa2 | ||
|
|
f6bf6dc8e5 | ||
|
|
e35b435f02 | ||
|
|
886cd7ab72 | ||
|
|
73714dc489 | ||
|
|
5218bbd791 | ||
|
|
23ad30cb4c | ||
|
|
a3b49d1ed9 | ||
|
|
9c80cbf19c | ||
|
|
699cf9690a | ||
|
|
67576d4879 | ||
|
|
edcf96d057 | ||
|
|
bb81c91d0c | ||
|
|
78f1467be4 | ||
|
|
da44d43981 | ||
|
|
9cebce1b6e | ||
|
|
b20fedd806 | ||
|
|
ee91ad8f06 | ||
|
|
7560112144 | ||
|
|
43c6b839cd | ||
|
|
0c9d443a5c | ||
|
|
14defb69b6 | ||
|
|
3a6f3dfb94 | ||
|
|
7bd36e0c8d | ||
|
|
e4f413adad | ||
|
|
1504ac3d19 | ||
|
|
947c714f89 | ||
|
|
e4d5886383 | ||
|
|
f504099485 | ||
|
|
cb56f9a9bf | ||
|
|
26a656af29 | ||
|
|
a8bd035b62 | ||
|
|
f05fa45747 | ||
|
|
78875abee4 | ||
|
|
37608c2656 | ||
|
|
a5b1f3eece | ||
|
|
0d3a3552da | ||
|
|
0a0176d600 | ||
|
|
4cb7ea2584 | ||
|
|
a43ee15b56 | ||
|
|
213ab312d2 | ||
|
|
94f30d5950 | ||
|
|
6af341bb5b | ||
|
|
82656cb0cf | ||
|
|
b72f5447c3 | ||
|
|
73b8e8ac09 | ||
|
|
9459f0426d | ||
|
|
0dae41aa22 | ||
|
|
7321e6e52f | ||
|
|
f0c21520aa | ||
|
|
b0c133201f | ||
|
|
572fb83015 | ||
|
|
0d3db2b670 | ||
|
|
e5f6a734ba | ||
|
|
bab9cd3e7a | ||
|
|
36812591eb | ||
|
|
1862c6115f | ||
|
|
ef780886c3 | ||
|
|
602305b20d | ||
|
|
78701debec | ||
|
|
08ac61ae94 | ||
|
|
6d5340f253 | ||
|
|
e2dfef5ddc | ||
|
|
1d88027618 | ||
|
|
9841deec31 | ||
|
|
ed5852c2d6 | ||
|
|
b26601a3dc | ||
|
|
f5806818cd | ||
|
|
c3e739eba9 | ||
|
|
b167b64f06 | ||
|
|
722cfae04c | ||
|
|
9cb2b562b9 | ||
|
|
81fb6712fe | ||
|
|
227dfa3730 | ||
|
|
aa80bdbbc6 | ||
|
|
914ed10bcc | ||
|
|
92c99a7d41 | ||
|
|
af1aaba547 | ||
|
|
5a2b7546f6 | ||
|
|
4047d5af5f | ||
|
|
6857e1ceb4 | ||
|
|
4479212008 | ||
|
|
cb90ac45c3 | ||
|
|
1847666e75 | ||
|
|
aad1318b4a | ||
|
|
7a23a339e9 | ||
|
|
38d894dfe7 | ||
|
|
b293be23b0 | ||
|
|
ccb53e34ca | ||
|
|
ec9d59f3dc | ||
|
|
df72aa26c0 | ||
|
|
d3691c7ca5 | ||
|
|
562ce541a0 | ||
|
|
6ebe1e92eb | ||
|
|
1bf455cfbb | ||
|
|
290e213cd0 | ||
|
|
b1b0005574 | ||
|
|
70ea527161 | ||
|
|
34c35c84d5 | ||
|
|
bcbfc843ae | ||
|
|
d4fe46bb24 | ||
|
|
523c6f2376 | ||
|
|
b018ac67bc | ||
|
|
1a529a62aa | ||
|
|
6a46437a5f | ||
|
|
cfe8c0eeee | ||
|
|
b232fc91ab | ||
|
|
ac50f33388 | ||
|
|
ff52bb3029 | ||
|
|
627e440bd6 | ||
|
|
6bb90a1268 | ||
|
|
7d8add70a7 | ||
|
|
9094392870 | ||
|
|
c6ad23fbc0 | ||
|
|
6af7a9ed8f | ||
|
|
0b051289f5 | ||
|
|
d8329dba22 | ||
|
|
ee70a4aa72 | ||
|
|
04a58159d0 | ||
|
|
4c758fa1da | ||
|
|
c8e21802db | ||
|
|
b40ffacb8d | ||
|
|
e0118dd8eb | ||
|
|
e7194dce75 | ||
|
|
01b5bef37f | ||
|
|
403a9f7b7e | ||
|
|
10f52f2056 | ||
|
|
274c01ca74 | ||
|
|
3b82c6e38b | ||
|
|
f59a1011df | ||
|
|
82c0cb8929 | ||
|
|
2bdd9f6217 | ||
|
|
193e7d476d | ||
|
|
1b3e7d5ec4 | ||
|
|
767a8c49b0 | ||
|
|
4c43f7e9d0 | ||
|
|
3ef140e25d | ||
|
|
0a568a3e1e | ||
|
|
ef44491c69 | ||
|
|
089a2c99e2 | ||
|
|
311812c8cc | ||
|
|
a77ab59436 | ||
|
|
89fbfc6f71 | ||
|
|
28f3bcdba3 | ||
|
|
445715b9fd | ||
|
|
8843c36ec6 | ||
|
|
bd63f63b36 | ||
|
|
033e144e06 | ||
|
|
20d49f9a7c | ||
|
|
3b2caa1f5b | ||
|
|
c3769e4fce | ||
|
|
6d894dd6ee | ||
|
|
2db2b89eb1 | ||
|
|
e48c7165c5 | ||
|
|
506edaadd5 | ||
|
|
3f82a3a519 | ||
|
|
79cee864cb | ||
|
|
9f5ed938e5 | ||
|
|
4729efbd04 | ||
|
|
da9fbb8044 | ||
|
|
cf01163c8c | ||
|
|
5ba8c644e4 | ||
|
|
c833ff4a84 | ||
|
|
2a530a4bf4 | ||
|
|
6b4b653462 | ||
|
|
edb16a27d3 | ||
|
|
21df5d9bf6 | ||
|
|
73c972a604 | ||
|
|
8cdffef82a | ||
|
|
4034809281 | ||
|
|
ce6bffb65c | ||
|
|
e8bc4bedb4 | ||
|
|
b85a7ef317 | ||
|
|
9f7e310526 | ||
|
|
af7cb1d81e | ||
|
|
53ce2a2f7f | ||
|
|
fb0283e0ee | ||
|
|
5d0cfc31fa | ||
|
|
f30f0a0edc | ||
|
|
6aa538a61d | ||
|
|
7918a93a7f | ||
|
|
fe6ecb24b4 | ||
|
|
6db787d5e4 | ||
|
|
5b4385a084 | ||
|
|
4f69c3b850 | ||
|
|
c62a75ee17 | ||
|
|
d4e9c62d92 | ||
|
|
ac8a2467a5 | ||
|
|
dc1dd9ebb7 | ||
|
|
0c06d78a4f | ||
|
|
41c9ed28cd | ||
|
|
5608aa10a5 | ||
|
|
daa68a2a60 | ||
|
|
a408b5a4fe | ||
|
|
e264c97454 | ||
|
|
8790dec137 | ||
|
|
6480868e6e | ||
|
|
0578e43352 | ||
|
|
2a89d4835f | ||
|
|
5084c61016 | ||
|
|
b45f94d511 | ||
|
|
66a4752e13 | ||
|
|
4d4f78de81 | ||
|
|
0faa641c8a | ||
|
|
0f16d27a72 | ||
|
|
835ee456a5 | ||
|
|
17f3b7dbd5 | ||
|
|
171a429526 | ||
|
|
e4ee2b7c04 | ||
|
|
c85a062e23 | ||
|
|
873378fa1f | ||
|
|
4f00ad409e | ||
|
|
20b516ff11 | ||
|
|
8754bbfa89 | ||
|
|
6d92cc3d2b | ||
|
|
2f684bf4f3 | ||
|
|
45af21bf38 | ||
|
|
e6318a2d16 | ||
|
|
bef4c8a86c | ||
|
|
6e67864510 | ||
|
|
c2af4874f9 | ||
|
|
2001b91280 | ||
|
|
5460ee7edd | ||
|
|
40081e5ae7 | ||
|
|
a7c5b0ab46 | ||
|
|
e1a813e11f | ||
|
|
1dfeef0265 | ||
|
|
395610c117 | ||
|
|
ae96f82b82 | ||
|
|
2c610abcd0 | ||
|
|
d3591c8d9e | ||
|
|
ec420d5792 | ||
|
|
17209df7b5 | ||
|
|
9cf9b02ba2 | ||
|
|
c90fa2378a | ||
|
|
c04dfa922e | ||
|
|
668007707d | ||
|
|
ab71f5276f | ||
|
|
d062f62656 | ||
|
|
03db32d045 | ||
|
|
8f6d489a9a | ||
|
|
dd07fba943 | ||
|
|
6f5d642a31 | ||
|
|
2721f08bcc | ||
|
|
eafc5df3f2 | ||
|
|
46d0c29be5 | ||
|
|
abdbbf4dd2 | ||
|
|
4dc0599a7d | ||
|
|
ded0936b2a | ||
|
|
52c35ec09c | ||
|
|
76490e45bc | ||
|
|
0a8130858c | ||
|
|
ff5ba99d16 | ||
|
|
14ec82084b | ||
|
|
8e02d0a20e | ||
|
|
faa05031a7 | ||
|
|
d4cce142c5 | ||
|
|
576d89a82a | ||
|
|
4a18ef87d7 | ||
|
|
2cd92a311b | ||
|
|
94f1e48d95 | ||
|
|
19c8f0ac7a | ||
|
|
312dea7ddb | ||
|
|
fb0033947c | ||
|
|
4b8f99ed10 | ||
|
|
4a764ae1e3 | ||
|
|
5b840c1662 | ||
|
|
62d84db5a4 | ||
|
|
019d415bbd | ||
|
|
7de060ed55 | ||
|
|
cfa41b3467 | ||
|
|
0a42a11f1c | ||
|
|
063a8ce666 | ||
|
|
a2bd83382b | ||
|
|
869cace2f3 | ||
|
|
b83edf6c17 | ||
|
|
e1aa92b983 | ||
|
|
a72609e640 | ||
|
|
a8b7c7a4ac | ||
|
|
9bf53e0ab8 | ||
|
|
51f3f5c774 | ||
|
|
313b9fd5bf | ||
|
|
e658a8559e | ||
|
|
4db82877af | ||
|
|
2e3ff4e215 | ||
|
|
8ffe0f5e31 | ||
|
|
c7513b9262 | ||
|
|
de7f081799 | ||
|
|
88f4067dd6 | ||
|
|
d20d613c1d | ||
|
|
801f3fadaa | ||
|
|
b307c7c74c | ||
|
|
aad898503d | ||
|
|
14bcdfe700 | ||
|
|
0d7f2f05b9 | ||
|
|
ee7b38504b | ||
|
|
5d31f4aeba | ||
|
|
9fe4d5c63d | ||
|
|
97ad5ab35f | ||
|
|
e7ddc6f6d3 | ||
|
|
cbcf80081b | ||
|
|
3073f3ec5c | ||
|
|
5a52936f72 | ||
|
|
3ca3cdc5e2 | ||
|
|
4ebfe71b8f | ||
|
|
2fe6cb392b | ||
|
|
d354747da0 | ||
|
|
17ec5389d8 | ||
|
|
687753b0be | ||
|
|
186525e77d | ||
|
|
9d396cea5a | ||
|
|
ac14b9e558 | ||
|
|
ef6c65c7ec | ||
|
|
c6c743e2bb | ||
|
|
6460f3a757 | ||
|
|
0d809a7481 | ||
|
|
674d030cbb | ||
|
|
7ab7538220 | ||
|
|
488a6a1c40 | ||
|
|
f94e1dfab6 | ||
|
|
e49384cd57 | ||
|
|
10b38e1588 | ||
|
|
b6ef1a58fb | ||
|
|
9894bdc0f1 | ||
|
|
99ee405f4e | ||
|
|
517d0390d0 | ||
|
|
96c3986481 | ||
|
|
e62c78ad46 | ||
|
|
e428cb5092 | ||
|
|
b8b8d1bb15 | ||
|
|
82dc80a413 | ||
|
|
d15fa84f4f | ||
|
|
4fa3e48d33 | ||
|
|
094e0440c6 | ||
|
|
b155c13117 | ||
|
|
0816579fa9 | ||
|
|
c6e683cc33 | ||
|
|
14bcd9db59 | ||
|
|
d9da91efbe | ||
|
|
017af24c22 | ||
|
|
496c395f1a | ||
|
|
29ca7bc8f9 | ||
|
|
62d0c25a2b | ||
|
|
1c67e4ce4c | ||
|
|
162c8810db | ||
|
|
9036c29c8a | ||
|
|
9bd936112d | ||
|
|
c98bb9060f | ||
|
|
ce0d360790 | ||
|
|
2b5ee69eb2 | ||
|
|
5a14d6a4ad | ||
|
|
6f62b2f18c | ||
|
|
c78fb964a2 | ||
|
|
8650c5b013 | ||
|
|
5051891813 | ||
|
|
95e2b0a8b0 | ||
|
|
ab45591507 | ||
|
|
62b4b250c7 | ||
|
|
a7963bee98 | ||
|
|
e86978f0da | ||
|
|
6044f41db5 | ||
|
|
a64f09a43f | ||
|
|
dbd4e77d61 | ||
|
|
02185fb4f4 | ||
|
|
2f2b7e42ba | ||
|
|
1c97954b47 | ||
|
|
859ea23bde | ||
|
|
7644f17cf6 | ||
|
|
1de94c1a84 | ||
|
|
10f08e0802 | ||
|
|
aac74f4c94 | ||
|
|
07f6be679f | ||
|
|
ea0ce710a8 | ||
|
|
155657f1cc | ||
|
|
0f2d8656ad | ||
|
|
30d1230a17 | ||
|
|
83a4edbea1 | ||
|
|
f193bab60b | ||
|
|
f01762ea44 | ||
|
|
f23843130e | ||
|
|
c7a163441e | ||
|
|
ae9068a4c4 | ||
|
|
dae8ea1b04 | ||
|
|
2d7eb116f2 | ||
|
|
9ea27e68ee | ||
|
|
4d2062282e | ||
|
|
2d9a42e4ba | ||
|
|
830517a98f | ||
|
|
1a1725f958 | ||
|
|
297f9c134f | ||
|
|
f51871fa6b | ||
|
|
9ee5089891 | ||
|
|
b0d39aedd3 | ||
|
|
89de00e7ce | ||
|
|
53b6528cc5 | ||
|
|
16ae753317 | ||
|
|
2337767c38 | ||
|
|
4f2290d548 | ||
|
|
7ab26a4fe0 | ||
|
|
533eeabf1d | ||
|
|
c6bb1fe141 | ||
|
|
f8f65c1a7b | ||
|
|
d90e2a6a9a | ||
|
|
4969fd6e99 | ||
|
|
95683b7416 | ||
|
|
38f4dc3217 | ||
|
|
f2a0d9943d | ||
|
|
ea0227a206 | ||
|
|
5a23669747 | ||
|
|
2a5933e4f7 | ||
|
|
6fecd72049 | ||
|
|
8360502a94 | ||
|
|
5548a32771 | ||
|
|
6f05e3d204 | ||
|
|
bcd8ddeabe | ||
|
|
af662da90d | ||
|
|
710186998b | ||
|
|
be3e0c27bf | ||
|
|
4d0d3cc271 | ||
|
|
4134763f34 | ||
|
|
1e72f0ee5a | ||
|
|
63710a4cb7 | ||
|
|
c82166e5f3 | ||
|
|
90624e6eca | ||
|
|
6b89998b60 | ||
|
|
dde472b0cf | ||
|
|
f7222a0e6c | ||
|
|
0262d20bbe | ||
|
|
37b33f62de | ||
|
|
2f405fd96f | ||
|
|
67ee727e38 | ||
|
|
12a0f5959f | ||
|
|
5dcae1a133 | ||
|
|
0343121e9b | ||
|
|
da09e1e1ce | ||
|
|
e7e590b36f | ||
|
|
da8d9d9c2d | ||
|
|
b8a9d327f0 | ||
|
|
a359ecaaf4 | ||
|
|
c21c7dd292 | ||
|
|
34295fbd69 | ||
|
|
3fbf0f0c01 | ||
|
|
1436d034bf | ||
|
|
08c7b3afbd | ||
|
|
f36d78e09c | ||
|
|
be56be5201 | ||
|
|
bcc7b8f490 | ||
|
|
27c662e73f | ||
|
|
eefbb42be4 | ||
|
|
b5c4449a16 | ||
|
|
5cdbbd4887 | ||
|
|
bdce47e764 | ||
|
|
813b142b72 | ||
|
|
b7dabe236e | ||
|
|
2e3ea2152d | ||
|
|
ea609d3552 | ||
|
|
f33fd047ee | ||
|
|
cc88896280 | ||
|
|
fbfb5d401f | ||
|
|
212b3e1688 | ||
|
|
31a70ab299 | ||
|
|
8f2cf8b8a7 | ||
|
|
600ca01fd3 | ||
|
|
65051153ac | ||
|
|
514c0c8331 | ||
|
|
dc634b8c7b | ||
|
|
66a4acafd0 | ||
|
|
3bf45d8fe0 | ||
|
|
9cd7c5e700 | ||
|
|
d79cf1d718 | ||
|
|
3d8a3a91f2 | ||
|
|
3fd3dcc7e5 | ||
|
|
7b5a4b466a | ||
|
|
92642df419 | ||
|
|
f5f99071fb | ||
|
|
cb15e98765 | ||
|
|
2f2c7ac393 | ||
|
|
d9788aaefc | ||
|
|
f7b410fd0c | ||
|
|
e261b5de65 | ||
|
|
954227b203 | ||
|
|
4a23ba7d8a | ||
|
|
b71c406e70 | ||
|
|
15bcd62f22 | ||
|
|
23dcc5389d | ||
|
|
9dca7e0daf | ||
|
|
66b6d36a26 | ||
|
|
2064eef273 | ||
|
|
64e836f9c8 | ||
|
|
2cb987095d | ||
|
|
da6c4e20fe | ||
|
|
26b426bbff | ||
|
|
2449aa75af | ||
|
|
2c9a3051d6 | ||
|
|
9b97e95cf3 | ||
|
|
c64bc24960 | ||
|
|
ceb3cb2ae7 | ||
|
|
a3913b98ba | ||
|
|
ef65e47bc5 | ||
|
|
53b2a03c80 | ||
|
|
58df755d8b | ||
|
|
c5eb0eb984 | ||
|
|
f25fa71235 | ||
|
|
8561a8c495 | ||
|
|
8688ef7125 | ||
|
|
46ea61666e | ||
|
|
8969eb76e9 | ||
|
|
ffee4c22b3 | ||
|
|
ad3f6ae313 | ||
|
|
b579758c46 | ||
|
|
45e6d49d36 | ||
|
|
ddb188e8f0 | ||
|
|
1a86e88373 | ||
|
|
31574a427b | ||
|
|
1bc6a8d956 | ||
|
|
d420e7bc23 | ||
|
|
cd3c2ae77e | ||
|
|
95b0e60617 | ||
|
|
ffbbe5eab3 | ||
|
|
18168ad7fd | ||
|
|
17afbeb87b | ||
|
|
d51b047f63 | ||
|
|
508ec295a4 | ||
|
|
66754fa376 | ||
|
|
4da7f5ecc2 | ||
|
|
29419d9d97 | ||
|
|
3520ef7480 | ||
|
|
d6475eaeed | ||
|
|
a9aaf29d83 | ||
|
|
38fa8925da | ||
|
|
c2b8ea3361 | ||
|
|
584807b039 | ||
|
|
5da3253f4b | ||
|
|
2a97eca00b | ||
|
|
1f3fd60d29 | ||
|
|
8a802ca666 | ||
|
|
a91e6d92f6 | ||
|
|
d9adb078aa | ||
|
|
7a7c33fdb1 | ||
|
|
b6abfec82e | ||
|
|
47774fb644 | ||
|
|
34410e92b7 | ||
|
|
a99f051e19 | ||
|
|
f6c63c62e4 | ||
|
|
76d75850a3 | ||
|
|
68d9f657ad | ||
|
|
24b8a95340 | ||
|
|
d245b9f123 | ||
|
|
a2dee21e8e | ||
|
|
3016cd3636 | ||
|
|
7532e1f957 | ||
|
|
f0db0c1054 | ||
|
|
05c15f4241 | ||
|
|
951ad91cb2 | ||
|
|
53bd57f3c2 | ||
|
|
4b9467cd0c | ||
|
|
0a607b9c93 | ||
|
|
810c046cc6 | ||
|
|
5a8d6931a8 | ||
|
|
0d67f91fac | ||
|
|
f9d41bd36a | ||
|
|
39509265bc | ||
|
|
2f3c21c7c1 | ||
|
|
d77bf23c76 | ||
|
|
f5cd1e5e76 | ||
|
|
a73c67e476 | ||
|
|
a95f9f41fb | ||
|
|
6ffb5af60c | ||
|
|
a5416df615 | ||
|
|
985477f2cf | ||
|
|
a4a8fa3027 | ||
|
|
623408bbfe | ||
|
|
514df6c99a | ||
|
|
54283a2599 | ||
|
|
4493d2efb6 | ||
|
|
83b3187126 | ||
|
|
a2d452684a | ||
|
|
2e42547d32 | ||
|
|
dea8fdd906 | ||
|
|
b41634e19a | ||
|
|
b0f6a94df5 | ||
|
|
1e65165e48 | ||
|
|
73e939ffb5 | ||
|
|
2d9922496c | ||
|
|
6feb2d04df | ||
|
|
90dafa3fa4 | ||
|
|
e77cdb5971 | ||
|
|
90e6c0d7c7 | ||
|
|
240e53afce | ||
|
|
fa8a609bcc | ||
|
|
6aafb521c1 | ||
|
|
81f0aa1168 | ||
|
|
3152642571 | ||
|
|
1e2c410abf | ||
|
|
a008c27fcf | ||
|
|
1edf952dda | ||
|
|
d9ada4536c | ||
|
|
bf89a191f0 | ||
|
|
c2456409bd | ||
|
|
02e23eb386 | ||
|
|
6898284361 | ||
|
|
f3a31be6d0 | ||
|
|
9260401747 | ||
|
|
8a6b009173 | ||
|
|
676ac9d8b8 | ||
|
|
29e263ad7d | ||
|
|
a075f63b59 | ||
|
|
ec60da893f | ||
|
|
d8fbce365a | ||
|
|
f6c5767a83 | ||
|
|
19615f2eae | ||
|
|
c42c6745b9 | ||
|
|
65d0a91fcc | ||
|
|
a22d47c719 | ||
|
|
010516aef2 | ||
|
|
a15389318f | ||
|
|
5d67868ac6 | ||
|
|
e0d8000007 | ||
|
|
b66ff374a2 | ||
|
|
6c981e83db | ||
|
|
2355fcb44e | ||
|
|
f5bbff0b05 | ||
|
|
c45c9da771 | ||
|
|
7a40759567 | ||
|
|
af5b98c635 | ||
|
|
690dc324c9 | ||
|
|
26e78c840c | ||
|
|
9c9ae190ee | ||
|
|
238adbe008 | ||
|
|
f457b995f7 | ||
|
|
b6aec4fa25 | ||
|
|
9fb5b6aa15 | ||
|
|
752fe30332 | ||
|
|
4ff85e2a1e | ||
|
|
13baf26050 | ||
|
|
8751f348c8 | ||
|
|
22bc47da23 | ||
|
|
55df21db51 | ||
|
|
3cd50f0495 | ||
|
|
b3390d40fb | ||
|
|
7eddf429ea | ||
|
|
793813790a | ||
|
|
fe2c4e47bf | ||
|
|
df4318505f | ||
|
|
69911c3db1 | ||
|
|
8ad8f89e50 | ||
|
|
a3d9854704 | ||
|
|
13d3968d9b | ||
|
|
382de7ca90 | ||
|
|
a0d0516b22 | ||
|
|
0fb31726f6 | ||
|
|
e6a73cab8f | ||
|
|
bf6000ef3d | ||
|
|
332118db56 | ||
|
|
6956bf7e53 | ||
|
|
11b829dda1 | ||
|
|
1e16b30380 | ||
|
|
4c1363b104 | ||
|
|
9da0c5bc85 | ||
|
|
4b0c711f77 | ||
|
|
9385f16128 | ||
|
|
36d2e58b11 | ||
|
|
03d6b36fe0 | ||
|
|
3b5b51b4f0 | ||
|
|
e8c5dfca3e | ||
|
|
5a984b54cf | ||
|
|
43879964bd | ||
|
|
5560c9eef7 | ||
|
|
f4097d5a95 | ||
|
|
225330413a | ||
|
|
e67b5a78d0 | ||
|
|
baf365404c | ||
|
|
0de2c758aa | ||
|
|
597bb18543 | ||
|
|
ebdf20adc0 | ||
|
|
7ecdf6db2e | ||
|
|
8a3b5a8def | ||
|
|
98d9fd76b3 | ||
|
|
6992219e34 | ||
|
|
fbe3e7d99c | ||
|
|
9cdc17566a | ||
|
|
cd05462e9f | ||
|
|
83d02c602a | ||
|
|
e85065b1c4 | ||
|
|
d0e705d948 | ||
|
|
2c06464f7b | ||
|
|
84727b1f71 | ||
|
|
aef987dccf | ||
|
|
b2b61bea6a | ||
|
|
30f66be1da | ||
|
|
6caa9ee227 | ||
|
|
9152f77cdd | ||
|
|
4d09eb2cec | ||
|
|
5cc4f6e85a | ||
|
|
6d16c57747 | ||
|
|
27f3a5f5f4 | ||
|
|
45c0e6ef7f | ||
|
|
593dbc9e67 | ||
|
|
daafa8faa3 | ||
|
|
320474b62d | ||
|
|
a3c483edf3 | ||
|
|
036be63f7b | ||
|
|
bbfe324dd6 | ||
|
|
de3292c828 | ||
|
|
67ab2e143c | ||
|
|
9abc112f76 | ||
|
|
b5880df93c | ||
|
|
2352c732de | ||
|
|
77264de3f6 | ||
|
|
42da281854 | ||
|
|
06cc5a29a7 | ||
|
|
98b4e1ea15 | ||
|
|
0bf6e1e839 | ||
|
|
3fe84eadef | ||
|
|
12eed0d384 | ||
|
|
28e8250b69 | ||
|
|
0297260a57 | ||
|
|
d4f7cb984c | ||
|
|
08187a01b1 | ||
|
|
daf3502e15 | ||
|
|
08cab43548 | ||
|
|
5cbe936256 | ||
|
|
729d3d4bc2 | ||
|
|
8af0991590 | ||
|
|
99d968f80a | ||
|
|
705d548435 | ||
|
|
2b6d63fd09 | ||
|
|
c917b8ce06 | ||
|
|
12b10d8b89 | ||
|
|
6a77b8b1f4 | ||
|
|
ba4be2a904 | ||
|
|
ca0523b86c | ||
|
|
5e68282519 | ||
|
|
a0d5525312 | ||
|
|
c48fd0738b | ||
|
|
8224da3460 | ||
|
|
dd82a91d8f | ||
|
|
86ec218f75 | ||
|
|
2a6ec597b4 | ||
|
|
8dd69207ea | ||
|
|
d203a46ef8 | ||
|
|
1920d8a887 | ||
|
|
95dea59382 | ||
|
|
f3cddcee21 | ||
|
|
21e384cafd | ||
|
|
32db055b98 | ||
|
|
2c87260046 | ||
|
|
51ccad8461 | ||
|
|
7f500c4b6e | ||
|
|
564d155cb6 | ||
|
|
edf5542559 | ||
|
|
51335e8830 | ||
|
|
391ffe34f8 | ||
|
|
12ead0408a | ||
|
|
2d39cc2540 | ||
|
|
a9a8f4cb3b | ||
|
|
8fa2e75afa | ||
|
|
0b01f9fc42 | ||
|
|
ed8c062d9f | ||
|
|
5e516e78e4 | ||
|
|
896b6ec8c9 | ||
|
|
9e7cdaf475 | ||
|
|
a3fd1d5d00 | ||
|
|
7257bed1e9 | ||
|
|
5a9977cf5c | ||
|
|
12b3aec567 | ||
|
|
d59c006ff9 | ||
|
|
02ada93ea5 | ||
|
|
6e87f8eb4e | ||
|
|
7df550f2a9 | ||
|
|
b02f0e3c5f | ||
|
|
151f71e033 | ||
|
|
7ac001e994 | ||
|
|
de177d2445 | ||
|
|
a9cb7143dc | ||
|
|
902258b56e | ||
|
|
c2a96ea293 | ||
|
|
37a3c3ab3a | ||
|
|
a8ed781f3e | ||
|
|
63f0d054b7 | ||
|
|
e7dcf54a77 | ||
|
|
1ba5504944 | ||
|
|
5637116378 | ||
|
|
cdc4ba6295 | ||
|
|
d1aa1881bb | ||
|
|
14107ec452 | ||
|
|
2ca6681896 | ||
|
|
40a65d36b4 | ||
|
|
16ec237ac6 | ||
|
|
0afcdbfe73 | ||
|
|
b9439036d4 | ||
|
|
cb23f9453f | ||
|
|
0858ecbb8e | ||
|
|
96da6dd075 | ||
|
|
2c31bdc6a2 | ||
|
|
0a3393bed3 | ||
|
|
c2c50ceea7 | ||
|
|
2341d510d3 | ||
|
|
9d6f2f71e8 | ||
|
|
e1334cf57f | ||
|
|
a1aff7cadf | ||
|
|
2271ac6470 | ||
|
|
8fe36cde23 | ||
|
|
fdd5956c1e | ||
|
|
609003c897 | ||
|
|
403ba262c6 | ||
|
|
f8be27ce6d | ||
|
|
2c10adba85 | ||
|
|
a50d70c8d3 | ||
|
|
4d86049c21 | ||
|
|
44037c4f9b | ||
|
|
5856d05701 | ||
|
|
a2a048e3bf | ||
|
|
9f4c773963 | ||
|
|
ef0eef8117 | ||
|
|
097e6eb41f | ||
|
|
73a49493a2 | ||
|
|
4a93d5b544 | ||
|
|
cc0655a904 | ||
|
|
a859cb3cce | ||
|
|
9e4e2d78dc | ||
|
|
47909d5299 | ||
|
|
16667bf5be | ||
|
|
ef3afe3e21 | ||
|
|
3a47317fc8 | ||
|
|
89066e3e20 | ||
|
|
af9366fdd4 | ||
|
|
448402ca2c | ||
|
|
fc67551edc | ||
|
|
98d3dce672 | ||
|
|
4cb93d4df8 | ||
|
|
91e66cfd9d | ||
|
|
6cf32af33f | ||
|
|
9a80c980cb | ||
|
|
c9e6c85e6a | ||
|
|
e88c9ba066 | ||
|
|
45be290392 | ||
|
|
3f28ab88ca | ||
|
|
1d07f37d62 | ||
|
|
16c5224341 | ||
|
|
e83372e2f3 | ||
|
|
6b9be033d6 | ||
|
|
2531fb1a02 | ||
|
|
3e845d387a | ||
|
|
5cc03d9bef | ||
|
|
0fa96b6e1e | ||
|
|
be2e4a5278 | ||
|
|
80bd6489cf | ||
|
|
ccf672d7ee | ||
|
|
6154b673c2 | ||
|
|
3bde7ec978 | ||
|
|
8caa11dcf4 | ||
|
|
1b70df2c1f | ||
|
|
4122fa5ddd | ||
|
|
c5d42b0569 | ||
|
|
37f9541f32 | ||
|
|
8bbfadb59a | ||
|
|
b9e8da92c7 | ||
|
|
0c5f055d45 | ||
|
|
342020e1d3 | ||
|
|
62f9bc79c4 | ||
|
|
53bfb02a21 | ||
|
|
83484a8828 | ||
|
|
ece235218f | ||
|
|
f3409acfa8 | ||
|
|
77b7201eb8 | ||
|
|
6b91df8d75 | ||
|
|
1670f04a87 | ||
|
|
1adf05e2d5 | ||
|
|
a94bb74d04 | ||
|
|
c19c75220b | ||
|
|
97382ed814 | ||
|
|
5f06679d78 | ||
|
|
851e8b6c0d | ||
|
|
9a729608d5 | ||
|
|
53fa346ddc | ||
|
|
b3210de374 | ||
|
|
82ccc37ba1 | ||
|
|
3826e95506 | ||
|
|
b083491e74 | ||
|
|
73ca0ff106 | ||
|
|
bba11b3b1e | ||
|
|
a40d97f346 | ||
|
|
d6c67d5c35 | ||
|
|
0816b27398 | ||
|
|
9133582aa0 | ||
|
|
f36b0fcb61 | ||
|
|
bb0a5dc8a8 | ||
|
|
0c260e483e | ||
|
|
b8ce907976 | ||
|
|
ffce637ea5 | ||
|
|
d6fba39037 | ||
|
|
5d5c2723b2 | ||
|
|
06d1498c47 | ||
|
|
2142bc1b76 | ||
|
|
f81e04b036 | ||
|
|
c3327d0b43 | ||
|
|
8577c26358 | ||
|
|
80730fd012 | ||
|
|
5ee3e94ca1 | ||
|
|
037f75e0ff | ||
|
|
c47f4fbc1c | ||
|
|
2f86e48a83 | ||
|
|
0bbba75757 | ||
|
|
9362d9745e | ||
|
|
c8f708c13c | ||
|
|
05590a3a21 | ||
|
|
cdf2867baf | ||
|
|
b142557979 | ||
|
|
db405c483e | ||
|
|
808c7b67b3 | ||
|
|
7131eafc09 | ||
|
|
7b4af76a61 | ||
|
|
2cd93daa5e | ||
|
|
f86bb2bdb0 | ||
|
|
414182fe6d | ||
|
|
2ee0df1da3 | ||
|
|
e1252e32d1 | ||
|
|
1183ef825b | ||
|
|
c09edb94c1 | ||
|
|
9948adc6a0 | ||
|
|
ccb467b219 | ||
|
|
1377776d21 | ||
|
|
29501ef4f8 | ||
|
|
d97c23b8e3 | ||
|
|
92d5e7b18c | ||
|
|
15ce4b3616 | ||
|
|
254e1f3abb | ||
|
|
deb6b97eea | ||
|
|
22ea2764d4 | ||
|
|
632dbc8fe8 | ||
|
|
98d9871620 | ||
|
|
a064eceb9b | ||
|
|
49107f2174 | ||
|
|
e9c2659147 | ||
|
|
18b54f075e | ||
|
|
45e40223ac | ||
|
|
1ab1534028 | ||
|
|
039efdb02a | ||
|
|
b0447dc521 | ||
|
|
aacbaab5f8 | ||
|
|
dc5032f72f | ||
|
|
c263c2c382 | ||
|
|
910784ca84 | ||
|
|
0b99e8f08d | ||
|
|
93be539789 | ||
|
|
390bb0451f | ||
|
|
14c3e2d9d9 | ||
|
|
23c7e0f803 | ||
|
|
cb4d1d1b5e | ||
|
|
2ba807efe8 | ||
|
|
c8cf9b74b1 | ||
|
|
33475703da | ||
|
|
1b7d0f9c0b | ||
|
|
1d881ef6f4 | ||
|
|
3a838d897f | ||
|
|
da130c900f | ||
|
|
440734dadf | ||
|
|
df2ddc47ec | ||
|
|
4b1c4ba5c0 | ||
|
|
6002badb3c | ||
|
|
e8f51fec88 | ||
|
|
7cec2d3029 | ||
|
|
2b0c471ed7 | ||
|
|
064bd13ebb | ||
|
|
2627490a11 | ||
|
|
4219d6d367 | ||
|
|
33f9ad9cee | ||
|
|
18a082de30 | ||
|
|
7f418d969e | ||
|
|
fe9f19d9ed | ||
|
|
d37f8876d7 | ||
|
|
d7c42bc9ec | ||
|
|
efc508a82b | ||
|
|
0edc0fd9c8 | ||
|
|
cc4c13930f | ||
|
|
234ca7c951 | ||
|
|
447c4669b1 | ||
|
|
27942f1973 | ||
|
|
158a119a5a | ||
|
|
b126f3af3b | ||
|
|
d4e1e32a30 | ||
|
|
271b423b22 | ||
|
|
417858f098 | ||
|
|
c52042e023 | ||
|
|
f12531e7e0 | ||
|
|
ca279110c9 | ||
|
|
f2968e0449 | ||
|
|
0043be6165 | ||
|
|
0716c9f722 | ||
|
|
fcf5637aa5 | ||
|
|
5e3c44d48f | ||
|
|
d6d3bbbad8 | ||
|
|
86b7933081 | ||
|
|
7cceb72cc3 | ||
|
|
56f7b3e61b | ||
|
|
22062d79a2 | ||
|
|
ab3b677113 | ||
|
|
cdb445f69d | ||
|
|
1eed1adfa0 | ||
|
|
a6c08576be | ||
|
|
f41aa8b18c | ||
|
|
6700347a48 | ||
|
|
b147830ef9 | ||
|
|
bd844fcd0a | ||
|
|
8936be628f | ||
|
|
5920fa97e4 | ||
|
|
326769e43c | ||
|
|
7524590bcf | ||
|
|
15ec46abfe | ||
|
|
920af91db6 | ||
|
|
a744261934 | ||
|
|
59c1368440 | ||
|
|
7e8e085a04 | ||
|
|
22b25724ae | ||
|
|
89719cf4b2 | ||
|
|
e15b19b223 | ||
|
|
2ca13972b9 | ||
|
|
7bb4e75459 | ||
|
|
fd8e510745 | ||
|
|
25c74c8f99 | ||
|
|
05d285ba86 | ||
|
|
186ca4e458 | ||
|
|
618312f0ee | ||
|
|
70d188202a | ||
|
|
4a21afe7ce | ||
|
|
fd1d016795 | ||
|
|
03c091adfc | ||
|
|
a3a88acfcf | ||
|
|
07f8ae6c82 | ||
|
|
25c30ac5bb | ||
|
|
a76767a0ab | ||
|
|
511d185772 | ||
|
|
c4c19c8a6c | ||
|
|
fe2d60ccec | ||
|
|
657890695f | ||
|
|
8a5f008aee | ||
|
|
f8a22b87b8 | ||
|
|
7f38d95424 | ||
|
|
bb7d96b954 | ||
|
|
8daa946afa | ||
|
|
ddc40f44fa | ||
|
|
409640c0ee | ||
|
|
822c9161c6 | ||
|
|
ad198fd77b | ||
|
|
a060f175ad | ||
|
|
73f305ff9c | ||
|
|
b6ff7185e7 | ||
|
|
928f6f1866 | ||
|
|
02f7aee680 | ||
|
|
e7c3277eeb | ||
|
|
bef5e4de9c | ||
|
|
04bcd9f56b | ||
|
|
03c0ce704b | ||
|
|
b27165a842 | ||
|
|
3d4ebe74ce | ||
|
|
4e16f270a3 | ||
|
|
c52a48ed38 | ||
|
|
236f6b1935 | ||
|
|
e8e700a683 | ||
|
|
4df3d3554e | ||
|
|
d0f37ae694 | ||
|
|
6561c9bc95 | ||
|
|
794098de99 | ||
|
|
b84d773bec | ||
|
|
dcbf3c8728 | ||
|
|
30c8c68703 | ||
|
|
9513edc468 | ||
|
|
6356e3def9 | ||
|
|
8d988723cd | ||
|
|
8ca6ee4349 | ||
|
|
780e009bf4 | ||
|
|
04d80cfb75 | ||
|
|
9404eadaf8 | ||
|
|
4d2ef09a29 | ||
|
|
89bb5d9e42 | ||
|
|
9dd3ec258c | ||
|
|
c709010c4c | ||
|
|
6e468936ec | ||
|
|
2c7ef4f758 | ||
|
|
06a127f64b | ||
|
|
fba21e6dd4 | ||
|
|
4b50d14496 | ||
|
|
e82f0f4432 | ||
|
|
00f809f5f0 | ||
|
|
c31ac662bd | ||
|
|
d6ce5dda81 | ||
|
|
dadbdd0f7b | ||
|
|
d96be88ff5 | ||
|
|
d2686b49be | ||
|
|
468ce74c8e | ||
|
|
b3fc43c13c | ||
|
|
308e8e78cd | ||
|
|
470d9160a5 | ||
|
|
9902447834 | ||
|
|
7c1b9f0cb4 | ||
|
|
fecedeb018 | ||
|
|
9418f35cc3 | ||
|
|
08a0608a48 | ||
|
|
b721cd48e5 | ||
|
|
75f55adbfa | ||
|
|
a379e5a635 | ||
|
|
019db74582 | ||
|
|
31f4b4d00d | ||
|
|
0db9137d91 | ||
|
|
f3ca86b670 | ||
|
|
088a8a4338 | ||
|
|
5d3893368d | ||
|
|
5b9cab02be | ||
|
|
cac751e9e8 | ||
|
|
6ba5c9a705 | ||
|
|
c681dc8872 | ||
|
|
d0285cdc41 | ||
|
|
9547a54fac | ||
|
|
b05dbfccd3 | ||
|
|
aef2d74e41 | ||
|
|
e1c849d5d2 | ||
|
|
c11ad7f0e6 | ||
|
|
88536ff72b | ||
|
|
93d7ec4d72 | ||
|
|
66a5ad0d75 | ||
|
|
771404668d | ||
|
|
76c567a71c | ||
|
|
e7730cff00 | ||
|
|
d5dc4a39cb | ||
|
|
50b3f9d25c | ||
|
|
ad5811280a | ||
|
|
9be1876fae | ||
|
|
1b3a7f0b6a | ||
|
|
3f143d9f19 | ||
|
|
a9b5f95c76 | ||
|
|
0c4a44566f | ||
|
|
2c705810cd | ||
|
|
a530aeec22 | ||
|
|
d9e76da806 | ||
|
|
e4b89a69d4 | ||
|
|
04cff1c916 | ||
|
|
5e842a8b20 | ||
|
|
be6c3c52ac | ||
|
|
9fea8fe01b | ||
|
|
d55fe9a34b | ||
|
|
66919ef969 | ||
|
|
ea7cfffdda | ||
|
|
888f3d804b | ||
|
|
545395a6f0 | ||
|
|
f2dfb5e1dc | ||
|
|
3f700bac1c | ||
|
|
a0cd35c5fc | ||
|
|
e7b8ec18f1 | ||
|
|
77f2c371b2 | ||
|
|
45f20d9c06 | ||
|
|
f57fa4cc8d | ||
|
|
abc870006c | ||
|
|
15ffbb0b05 | ||
|
|
8b62c35ea7 | ||
|
|
0e106d843c | ||
|
|
cbebb81196 | ||
|
|
05ae69b766 | ||
|
|
9b489c9eba | ||
|
|
df11e2765e | ||
|
|
f53ee70caa | ||
|
|
d8deb2255d | ||
|
|
035f985693 | ||
|
|
086c1bb505 | ||
|
|
c26c5935b6 | ||
|
|
de7572bd3e | ||
|
|
5777908da7 | ||
|
|
587bf68091 | ||
|
|
2c83c6a79f | ||
|
|
4f4b2bfdec | ||
|
|
0469612d07 | ||
|
|
8f3db96291 | ||
|
|
a9cceebb33 | ||
|
|
9ab5f5d451 | ||
|
|
219d5170e0 | ||
|
|
7b8ba9bf20 | ||
|
|
3db436e48e | ||
|
|
3c7956e72d | ||
|
|
42dbb51022 | ||
|
|
9654140c00 | ||
|
|
8a915dcbbe | ||
|
|
b2378e830e | ||
|
|
65b7c73bf3 | ||
|
|
6e3bc7b1dd | ||
|
|
82629c397f | ||
|
|
a16b8fc0ac | ||
|
|
74e4b69654 | ||
|
|
07e51886f3 | ||
|
|
e59a2b3ede | ||
|
|
5084c32f3c | ||
|
|
c0b7f41397 | ||
|
|
6c07c15c50 | ||
|
|
666fb7cf39 | ||
|
|
a2c0d70c2c | ||
|
|
80fe54ed69 | ||
|
|
44870323da | ||
|
|
58ab630965 | ||
|
|
64098122e7 | ||
|
|
8a8f6824a2 | ||
|
|
b2c12d88fe | ||
|
|
e8b1dce67b | ||
|
|
fbf63d8e3b | ||
|
|
06d6322fe3 | ||
|
|
de14e7055e | ||
|
|
3392e4d73b | ||
|
|
b0be02e16d | ||
|
|
d11e7cab46 | ||
|
|
e25d740968 | ||
|
|
291679126f | ||
|
|
99a805cba6 | ||
|
|
bb37887a8c | ||
|
|
5c5ea8824e | ||
|
|
22d90d702d | ||
|
|
9961c8180a | ||
|
|
d6f3186b3d | ||
|
|
05ddc85412 | ||
|
|
6f0460b0ee | ||
|
|
44d314d069 | ||
|
|
e210e414bd | ||
|
|
cce7a09fa9 | ||
|
|
e1d0c6da09 | ||
|
|
9518d88a2a | ||
|
|
4a5d8449fd | ||
|
|
3df4ef9362 | ||
|
|
01f4275202 | ||
|
|
a061397469 | ||
|
|
2777d35990 | ||
|
|
c49c23d5d9 | ||
|
|
0e2a10c5f0 | ||
|
|
f5c37bf486 | ||
|
|
0ff5270632 | ||
|
|
5df4fd0a27 | ||
|
|
c0143ac6d6 | ||
|
|
c8e7f78a25 | ||
|
|
b6d7e8e14d | ||
|
|
55103c0652 | ||
|
|
61ea6c3b2f | ||
|
|
e11a91411b | ||
|
|
0c883b80c4 | ||
|
|
4928e678d1 | ||
|
|
22fc3aab39 | ||
|
|
c37ab1de84 | ||
|
|
246a8bff0c | ||
|
|
9abba79c54 | ||
|
|
5ba880f19b | ||
|
|
b2e8544c58 | ||
|
|
ac19d05db2 | ||
|
|
065773ed4c | ||
|
|
37146ff565 | ||
|
|
cba34e770e | ||
|
|
8911d9d28f | ||
|
|
9371159a7e | ||
|
|
43a6fe9b6c | ||
|
|
989330d6bc | ||
|
|
ee78d7a0c0 | ||
|
|
96793a99ce | ||
|
|
380c0db020 | ||
|
|
95544dddf8 | ||
|
|
b209c903bb | ||
|
|
4f69c487da | ||
|
|
78602ccacb | ||
|
|
1f1b20f4fe | ||
|
|
d53ff7892a | ||
|
|
b6f0bb9b6b | ||
|
|
cfde0613bb | ||
|
|
903c67c994 | ||
|
|
d8d479cef7 | ||
|
|
f48106b160 | ||
|
|
1b5bf2c848 | ||
|
|
c4fa476c3c | ||
|
|
60d66ca2dc | ||
|
|
db15b94cd7 | ||
|
|
ae49b67321 | ||
|
|
c77241940b | ||
|
|
97d713ee64 | ||
|
|
bc04a1a0ff | ||
|
|
c4869bad88 | ||
|
|
7a87348855 | ||
|
|
2e623fd6c3 | ||
|
|
727fa07377 | ||
|
|
5510b45f3b | ||
|
|
3615a7b90c | ||
|
|
d1de50c0e5 | ||
|
|
dc56cd1d1f | ||
|
|
d2a819eb77 | ||
|
|
0ac61cbb9b | ||
|
|
c9c99a22e0 | ||
|
|
91250fd46c | ||
|
|
641914cdbe | ||
|
|
840859ab7c | ||
|
|
97b712da98 | ||
|
|
b5c36140fa | ||
|
|
48a9c1cd67 | ||
|
|
38f671a923 | ||
|
|
cb232d8288 | ||
|
|
2fa244715d | ||
|
|
b9b1af1c3d | ||
|
|
a1d91ac779 | ||
|
|
585e195044 | ||
|
|
39572d9628 | ||
|
|
f278250740 | ||
|
|
54f410901f | ||
|
|
00242443e1 | ||
|
|
82da4935b6 | ||
|
|
1c5fd8bbd4 | ||
|
|
77a7cbcffd | ||
|
|
3160457ca6 | ||
|
|
590ee81f7a | ||
|
|
9d4357c619 | ||
|
|
80a2acca4f | ||
|
|
f68a3ed15d | ||
|
|
6d3d8970a6 | ||
|
|
073ca63f60 | ||
|
|
0e18e4461e | ||
|
|
3e7424b307 | ||
|
|
48b5cae6c4 | ||
|
|
a1760a1980 | ||
|
|
19bbd39e33 | ||
|
|
3f97b3b706 | ||
|
|
82e620bcf5 | ||
|
|
80e0761bf1 | ||
|
|
c0781d3680 | ||
|
|
b7cb65ec49 | ||
|
|
fdbfac15db | ||
|
|
28424d6acd | ||
|
|
b679b04d14 | ||
|
|
b7d651dd17 | ||
|
|
757e8d90e6 | ||
|
|
7d52a9587f | ||
|
|
067d773aac | ||
|
|
089d1e55e7 | ||
|
|
6c0998f220 | ||
|
|
49cc389bf0 | ||
|
|
8480e8df9f | ||
|
|
e7d4f2608b | ||
|
|
d1b4813197 | ||
|
|
298ee7b92e | ||
|
|
5c56b99742 | ||
|
|
b9d70dcda2 | ||
|
|
5e3857abf7 | ||
|
|
bb567827a1 | ||
|
|
280f874edc | ||
|
|
f6755aabae | ||
|
|
52af4bced0 | ||
|
|
63e757807e | ||
|
|
edd63e3d2d | ||
|
|
32133e2f46 | ||
|
|
2255c68377 | ||
|
|
9c1d1a0d9f | ||
|
|
8698b01bc7 | ||
|
|
3411ce2150 | ||
|
|
29e1e8bdfd | ||
|
|
317dd5b2da | ||
|
|
4ae7633418 | ||
|
|
c3a0eeceec | ||
|
|
4fe173b644 | ||
|
|
1c7f769ec7 | ||
|
|
72ca514cc2 | ||
|
|
20314b4d63 | ||
|
|
017d1b2872 | ||
|
|
ef9fc87351 | ||
|
|
0f7ac1726d | ||
|
|
bd3f8e006c | ||
|
|
07406c96e1 | ||
|
|
4044520ccc | ||
|
|
656389f215 | ||
|
|
04db37a34a | ||
|
|
15846137a6 | ||
|
|
50e7571f4c | ||
|
|
1ccfcfc8d8 | ||
|
|
527d4964f6 | ||
|
|
67ba68a1a0 | ||
|
|
8bd474fd01 | ||
|
|
54edc46c7f | ||
|
|
08035261b8 | ||
|
|
e8b45e53fd | ||
|
|
d325890148 | ||
|
|
8da1e3ce21 | ||
|
|
c149be20fc | ||
|
|
4c3bb1596e | ||
|
|
1912dcf03d | ||
|
|
ae16c3bae7 | ||
|
|
be000eab4e | ||
|
|
a05d0202e6 | ||
|
|
6c253f0c71 | ||
|
|
962cbfb9d8 | ||
|
|
d52f8c9c6f | ||
|
|
ee4d67930f | ||
|
|
cced0a82b5 | ||
|
|
478a876b01 | ||
|
|
789da5fdf8 | ||
|
|
bd08a56210 | ||
|
|
f7843582e8 | ||
|
|
2c749e9dbe | ||
|
|
8479664df1 | ||
|
|
5a1d6428b2 | ||
|
|
a39be5a461 | ||
|
|
da930310b1 | ||
|
|
af296eb600 | ||
|
|
2c11c65faf | ||
|
|
29d890bb0f | ||
|
|
efa39ae591 | ||
|
|
4b57ac3236 | ||
|
|
997f825cd3 | ||
|
|
27fe866d5e | ||
|
|
c5c6ce6b0e | ||
|
|
15e2a778d4 | ||
|
|
1f5a35a99f | ||
|
|
0975755a9d | ||
|
|
19f4845185 | ||
|
|
0d5b7df77d | ||
|
|
534857db9c | ||
|
|
0a81a7a50b | ||
|
|
23ef233b60 | ||
|
|
24fb74f78b | ||
|
|
2e167835ea | ||
|
|
a60e5c5c4f | ||
|
|
3dcc9ab765 | ||
|
|
d61e2f9c29 | ||
|
|
5dffceda59 | ||
|
|
d1a636a5c3 | ||
|
|
3f558f63d8 | ||
|
|
df77213f2c | ||
|
|
e601162cdd | ||
|
|
62da60df47 | ||
|
|
8bb577de64 | ||
|
|
ede8235aae | ||
|
|
37a0cec53d | ||
|
|
78ab63581b | ||
|
|
1beeb9ab5c | ||
|
|
228874a52b | ||
|
|
bb05cfb711 | ||
|
|
b134c4679c | ||
|
|
2e705a919f | ||
|
|
1dac501b04 | ||
|
|
905e81330e | ||
|
|
3460a8c922 | ||
|
|
2ff876c629 | ||
|
|
08dc487b5b | ||
|
|
4dc6b12ec5 | ||
|
|
cca4777f64 | ||
|
|
af00d601be | ||
|
|
fe3c2ba555 | ||
|
|
6554ad7c7e | ||
|
|
4abbed0cd4 | ||
|
|
72263eda85 | ||
|
|
abf7074518 | ||
|
|
ad2da0af52 | ||
|
|
7d9d90d3f8 | ||
|
|
70e47f301d | ||
|
|
1614eb9c9c | ||
|
|
a694003fe3 | ||
|
|
500aa7bf1d | ||
|
|
63c1496115 | ||
|
|
843d06df3f | ||
|
|
30cc51eac9 | ||
|
|
ebf1047da7 | ||
|
|
869678953d | ||
|
|
4a52900352 | ||
|
|
02c37bb6d6 | ||
|
|
918bbfb0d3 | ||
|
|
063c6a9e45 | ||
|
|
daee71a2c1 | ||
|
|
0d32a5321c | ||
|
|
e199145f1c | ||
|
|
1f945a334a | ||
|
|
fb6c7d81d5 | ||
|
|
417f4535af | ||
|
|
ee94bc4715 | ||
|
|
6801604533 | ||
|
|
5c388a5200 | ||
|
|
d239a2400d | ||
|
|
93ce582ad3 | ||
|
|
6e70987451 | ||
|
|
263fff0ba2 | ||
|
|
ded457c2c1 | ||
|
|
b539a5aa51 | ||
|
|
5fddce6638 | ||
|
|
ee1f521325 | ||
|
|
ede2da2fbc | ||
|
|
509f06afac | ||
|
|
1753074eef | ||
|
|
e013b48675 | ||
|
|
49e4ae54be | ||
|
|
d5c9c56fdf | ||
|
|
a468261523 | ||
|
|
462ac29563 | ||
|
|
6f198adb0c | ||
|
|
e521522b38 | ||
|
|
5a07908dfa | ||
|
|
9571a979eb | ||
|
|
48ba007c22 | ||
|
|
6ff17fbf7c | ||
|
|
416b97311b | ||
|
|
7fb09da7cf | ||
|
|
6ecb01dedc | ||
|
|
518f08b909 | ||
|
|
2eac106f11 | ||
|
|
f77da803c9 | ||
|
|
f8f98bf428 | ||
|
|
abe37c9841 | ||
|
|
8589f80d8f | ||
|
|
0e38acd67a | ||
|
|
a3f279c1cf | ||
|
|
35037d1a5b | ||
|
|
d206c75b0b | ||
|
|
1d3054ef5e | ||
|
|
db6aa58f40 | ||
|
|
48115eca18 | ||
|
|
edfc3e3501 | ||
|
|
1a37632891 | ||
|
|
b85a49cdb3 | ||
|
|
9c0eed8a67 | ||
|
|
887375ebef | ||
|
|
49356f4132 | ||
|
|
403235e2d4 | ||
|
|
9ce01fc369 | ||
|
|
b0a35559b3 | ||
|
|
8aaf0b8d85 | ||
|
|
28d510191c | ||
|
|
4c8e0575f9 | ||
|
|
49afe53a2c | ||
|
|
d19c1b689a | ||
|
|
e7e1acc0a2 | ||
|
|
7bdeb32a8a | ||
|
|
f412ab4f8b | ||
|
|
0fc09462ff | ||
|
|
d78496321e | ||
|
|
ac76fc4409 | ||
|
|
a343ff1989 | ||
|
|
2d2178c90a | ||
|
|
25b14f9953 | ||
|
|
2491b4f85c | ||
|
|
cb8b14e64b | ||
|
|
887172d663 | ||
|
|
e4aa23abaa | ||
|
|
8c0cc3a2d8 | ||
|
|
efe8a6c8eb | ||
|
|
efe54e3b5e | ||
|
|
5af871acce | ||
|
|
a2f0607c1e | ||
|
|
b67b2cc3ab | ||
|
|
7a2a149061 | ||
|
|
afbc45bf32 | ||
|
|
c1265a9490 | ||
|
|
94712b3961 | ||
|
|
d29288547e | ||
|
|
1b4de55efd | ||
|
|
cceb109303 | ||
|
|
17a810b939 | ||
|
|
4aa8f57d36 | ||
|
|
f2c98d6126 | ||
|
|
7a5c3cee0d | ||
|
|
9aa17984df | ||
|
|
da616e0557 | ||
|
|
d2026b4cd7 | ||
|
|
ed74790eed | ||
|
|
bf2e22da4f | ||
|
|
bd50b80882 | ||
|
|
b11ad26c4f | ||
|
|
f7459670d3 | ||
|
|
5304750215 | ||
|
|
a8171da003 | ||
|
|
916cf0d8b7 | ||
|
|
0484b2852d | ||
|
|
b5a8e1c94c | ||
|
|
01a46f665f | ||
|
|
535980b9bd | ||
|
|
b0085e21f7 | ||
|
|
6daca09794 | ||
|
|
7b53a98950 | ||
|
|
4cc1e6a910 | ||
|
|
4d05e4d576 | ||
|
|
eefad194d0 | ||
|
|
ba7134ee3f | ||
|
|
264c8faedd | ||
|
|
3c227eeca4 | ||
|
|
c8598fe620 | ||
|
|
2f9b76f129 | ||
|
|
9a8b00a428 | ||
|
|
eaf0d03a37 | ||
|
|
e7f2021864 | ||
|
|
dff9780d3a | ||
|
|
20239d1bb3 | ||
|
|
ee7d63f73a | ||
|
|
76c151c6e6 | ||
|
|
9c9365c146 | ||
|
|
7e118178b3 | ||
|
|
66d2ac8cb9 | ||
|
|
e4c233b6ce | ||
|
|
be853afc24 | ||
|
|
565443b710 | ||
|
|
3b869f1720 | ||
|
|
5f82017a31 | ||
|
|
bd055e75b9 | ||
|
|
d90754dc0a | ||
|
|
387f615dae | ||
|
|
02e310f2c9 | ||
|
|
d83738df87 | ||
|
|
6b61edce92 | ||
|
|
09fc028895 | ||
|
|
82cfa00a97 | ||
|
|
4a038978d2 | ||
|
|
2c89cded4b | ||
|
|
bd38041d04 | ||
|
|
896dc4d34d | ||
|
|
ab572c2882 | ||
|
|
6b8264fcaa | ||
|
|
9cd7b0b32b | ||
|
|
f73bcc0e7b | ||
|
|
652c669777 | ||
|
|
fb89900c64 | ||
|
|
fb35ddebb9 | ||
|
|
a3d7e76992 | ||
|
|
973656191b | ||
|
|
d9f493ab7a | ||
|
|
a0c4fa6496 | ||
|
|
5bb863f7da | ||
|
|
81ed70325c | ||
|
|
e826d71bd8 | ||
|
|
4cd3f6c36a | ||
|
|
6b4b8cb2f9 | ||
|
|
fd43bd2b7e | ||
|
|
5904808804 | ||
|
|
e945e9b659 | ||
|
|
df29cdbf17 | ||
|
|
f6362aa8da | ||
|
|
1517b7799a | ||
|
|
0c4827d348 | ||
|
|
81872d9822 | ||
|
|
ffb9a00e26 | ||
|
|
f2c827f9a2 | ||
|
|
f2cb5db9e0 | ||
|
|
066419019f | ||
|
|
15da6d0a0b | ||
|
|
6303bc3e35 | ||
|
|
0f4dc6702d | ||
|
|
f48c8a6444 | ||
|
|
38404b2013 | ||
|
|
5a6d64814a | ||
|
|
36776b40c2 | ||
|
|
58c3ba7ac6 | ||
|
|
931b47673c | ||
|
|
79d9fbf645 | ||
|
|
f24e7709ac | ||
|
|
903971de12 | ||
|
|
b04e427f01 | ||
|
|
e0c03b2dfa | ||
|
|
7dff631dcb | ||
|
|
36aba385af | ||
|
|
136d17366f | ||
|
|
db7870ef5f | ||
|
|
bbc88d92ea | ||
|
|
1604b5d6e4 | ||
|
|
7fd535179e | ||
|
|
e3a457e402 | ||
|
|
0dcff82bb4 | ||
|
|
cde8b66719 | ||
|
|
0e1433329d | ||
|
|
60fef5e656 | ||
|
|
725e774fe7 | ||
|
|
9aa98ed6c6 | ||
|
|
7b251dcc31 | ||
|
|
8a08c688f6 | ||
|
|
d6461251f9 |
@@ -124,6 +124,28 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Indentation:** Use spaces (two per indentation level), not tabs
|
||||
* **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;`
|
||||
* **Line length:** Wrap lines at no more than 120 characters
|
||||
* **Constructor parameters vs setters:** Component properties that are both **required** and **invariant**
|
||||
(never change after construction) should be constructor parameters rather than set via setter methods.
|
||||
This makes the dependency explicit and prevents use of the object in an incompletely-initialized state.
|
||||
In code generation, when calling `cg.new_Pvariable()` or the relevant helper function to create the component, pass these as arguments.
|
||||
```cpp
|
||||
// Good - required invariant dependency as constructor parameter
|
||||
class SourceTextSensor : public text_sensor::TextSensor, public Component {
|
||||
public:
|
||||
explicit SourceTextSensor(text::Text *source) : source_(source) {}
|
||||
protected:
|
||||
text::Text *source_;
|
||||
};
|
||||
```
|
||||
```cpp
|
||||
// Bad - required invariant dependency as setter
|
||||
class SourceTextSensor : public text_sensor::TextSensor, public Component {
|
||||
public:
|
||||
void set_source(text::Text *source) { this->source_ = source; }
|
||||
protected:
|
||||
text::Text *source_{nullptr};
|
||||
};
|
||||
```
|
||||
|
||||
* **Component Structure:**
|
||||
* **Standard Files:**
|
||||
@@ -217,6 +239,123 @@ This document provides essential context for AI models interacting with this pro
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
* **Automations (Triggers, Actions, Conditions):**
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
|
||||
|
||||
* **Triggers -- Callback method (preferred):**
|
||||
|
||||
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [(bool, "x")], conf
|
||||
)
|
||||
```
|
||||
|
||||
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
|
||||
|
||||
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
|
||||
```python
|
||||
for conf_key, forwarder in (
|
||||
(CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
|
||||
(CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
|
||||
):
|
||||
for conf in config.get(conf_key, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_state_callback", [], conf, forwarder=forwarder
|
||||
)
|
||||
```
|
||||
|
||||
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
public:
|
||||
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
protected:
|
||||
// Use CallbackManager when callbacks are always registered (e.g. core components)
|
||||
CallbackManager<void(bool)> state_callback_;
|
||||
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
|
||||
// (nullptr vs empty std::vector) per instance when no callbacks are added
|
||||
// LazyCallbackManager<void(bool)> state_callback_;
|
||||
};
|
||||
```
|
||||
|
||||
* **Triggers -- Trigger class method:**
|
||||
|
||||
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
})
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
```
|
||||
|
||||
**C++:**
|
||||
```cpp
|
||||
class TurnOnTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
|
||||
parent->add_on_state_callback([this](bool state) {
|
||||
if (state && !this->last_on_)
|
||||
this->trigger();
|
||||
this->last_on_ = state;
|
||||
});
|
||||
}
|
||||
protected:
|
||||
bool last_on_;
|
||||
};
|
||||
```
|
||||
|
||||
* **Actions:**
|
||||
```cpp
|
||||
template<typename... Ts> class MyAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit MyAction(MyComponent *parent) : parent_(parent) {}
|
||||
void play(const Ts &...) override { this->parent_->do_something(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
|
||||
|
||||
* **Conditions:**
|
||||
```cpp
|
||||
template<typename... Ts> class MyCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
|
||||
bool check(const Ts &...) override { return this->parent_->is_active(); }
|
||||
protected:
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
@@ -252,10 +391,39 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
|
||||
```
|
||||
tests/
|
||||
├── test_build_components/ # Base test configurations
|
||||
└── components/[component]/ # Component-specific tests
|
||||
├── test_build_components/
|
||||
│ └── common/ # Shared bus packages (uart, i2c, spi, etc.)
|
||||
│ ├── uart/ # UART at default baud rate
|
||||
│ ├── uart_115200/ # UART at 115200 baud
|
||||
│ ├── i2c/ # I2C bus
|
||||
│ └── spi/ # SPI bus
|
||||
└── components/[component]/
|
||||
├── common.yaml # Component-only config (no bus definitions)
|
||||
├── test.esp32-idf.yaml
|
||||
├── test.esp8266-ard.yaml
|
||||
└── test.rp2040-ard.yaml
|
||||
```
|
||||
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — use packages for buses
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml — component config only, NO bus definitions
|
||||
my_component:
|
||||
id: my_instance
|
||||
|
||||
sensor:
|
||||
- platform: my_component
|
||||
name: My Sensor
|
||||
```
|
||||
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
./script/test_component_grouping.py -e config --all
|
||||
@@ -286,6 +454,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome-docs` repository.
|
||||
* The contribution workflow is the same as for the codebase.
|
||||
* When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync.
|
||||
|
||||
* **Best Practices:**
|
||||
* **Component Development:** Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests.
|
||||
@@ -394,6 +563,30 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`).
|
||||
|
||||
**Callback Managers:**
|
||||
|
||||
ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method.
|
||||
|
||||
| Type | Idle overhead (32-bit) | When to use |
|
||||
|------|----------------------|-------------|
|
||||
| `CallbackManager<void(Ts...)>` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
|
||||
| `LazyCallbackManager<void(Ts...)>` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
|
||||
|
||||
`LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
|
||||
|
||||
**Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature:
|
||||
```cpp
|
||||
// Bad -- forces heap allocation for forwarder structs
|
||||
void add_on_state_callback(std::function<void(bool)> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
// Good -- accepts any callable without forcing std::function wrapping
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
```
|
||||
|
||||
* **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
|
||||
|
||||
**Bad Pattern (Module-Level Globals):**
|
||||
|
||||
@@ -1 +1 @@
|
||||
ce05c28e9dc0b12c4f6e7454986ffea5123ac974a949da841be698c535f2083e
|
||||
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"--privileged",
|
||||
"-e",
|
||||
"GIT_EDITOR=code --wait"
|
||||
// uncomment and edit the path in order to pass though local USB serial to the conatiner
|
||||
// uncomment and edit the path in order to pass through local USB serial to the container
|
||||
// , "--device=/dev/ttyACM0"
|
||||
],
|
||||
"appPort": 6052,
|
||||
|
||||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -6,8 +6,9 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Developer breaking change (an API change that could break external components)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
- [ ] Code quality improvements to existing code or addition of tests
|
||||
- [ ] Other
|
||||
|
||||
@@ -24,7 +25,7 @@
|
||||
- [ ] ESP32
|
||||
- [ ] ESP32 IDF
|
||||
- [ ] ESP8266
|
||||
- [ ] RP2040
|
||||
- [ ] RP2040/RP2350
|
||||
- [ ] BK72xx
|
||||
- [ ] RTL87xx
|
||||
- [ ] LN882x
|
||||
|
||||
4
.github/actions/build-image/action.yaml
vendored
4
.github/actions/build-image/action.yaml
vendored
@@ -47,7 +47,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -73,7 +73,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
2
.github/actions/restore-python/action.yml
vendored
2
.github/actions/restore-python/action.yml
vendored
@@ -22,7 +22,7 @@ runs:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
|
||||
3
.github/scripts/auto-label-pr/constants.js
vendored
3
.github/scripts/auto-label-pr/constants.js
vendored
@@ -4,6 +4,7 @@ module.exports = {
|
||||
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
|
||||
TOO_BIG_MARKER: '<!-- too-big-request -->',
|
||||
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
|
||||
ORG_FORK_MARKER: '<!-- maintainer-access-warning -->',
|
||||
|
||||
MANAGED_LABELS: [
|
||||
'new-component',
|
||||
@@ -14,6 +15,7 @@ module.exports = {
|
||||
'chained-pr',
|
||||
'core',
|
||||
'small-pr',
|
||||
'medium-pr',
|
||||
'dashboard',
|
||||
'github-actions',
|
||||
'by-code-owner',
|
||||
@@ -27,6 +29,7 @@ module.exports = {
|
||||
'new-feature',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
'code-quality',
|
||||
'deprecated-component'
|
||||
],
|
||||
|
||||
125
.github/scripts/auto-label-pr/detectors.js
vendored
125
.github/scripts/auto-label-pr/detectors.js
vendored
@@ -1,5 +1,13 @@
|
||||
const fs = require('fs');
|
||||
const { DOCS_PR_PATTERNS } = require('./constants');
|
||||
const {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
hasCoreChanges,
|
||||
hasDashboardChanges,
|
||||
hasGitHubActionsChanges,
|
||||
} = require('../detect-tags');
|
||||
const { loadCodeowners, getEffectiveOwners } = require('../codeowners');
|
||||
|
||||
// Strategy: Merge branch detection
|
||||
async function detectMergeBranch(context) {
|
||||
@@ -20,15 +28,13 @@ async function detectMergeBranch(context) {
|
||||
// Strategy: Component and platform labeling
|
||||
async function detectComponentPlatforms(changedFiles, apiData) {
|
||||
const labels = new Set();
|
||||
const componentRegex = /^esphome\/components\/([^\/]+)\//;
|
||||
const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`);
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const componentMatch = file.match(componentRegex);
|
||||
if (componentMatch) {
|
||||
labels.add(`component: ${componentMatch[1]}`);
|
||||
}
|
||||
for (const comp of detectComponents(changedFiles)) {
|
||||
labels.add(`component: ${comp}`);
|
||||
}
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const platformMatch = file.match(targetPlatformRegex);
|
||||
if (platformMatch) {
|
||||
labels.add(`platform: ${platformMatch[1]}`);
|
||||
@@ -90,20 +96,14 @@ async function detectNewPlatforms(prFiles, apiData) {
|
||||
// Strategy: Core files detection
|
||||
async function detectCoreChanges(changedFiles) {
|
||||
const labels = new Set();
|
||||
const coreFiles = changedFiles.filter(file =>
|
||||
file.startsWith('esphome/core/') ||
|
||||
(file.startsWith('esphome/') && file.split('/').length === 2)
|
||||
);
|
||||
|
||||
if (coreFiles.length > 0) {
|
||||
if (hasCoreChanges(changedFiles)) {
|
||||
labels.add('core');
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
// Strategy: PR size detection
|
||||
async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
|
||||
async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
|
||||
const labels = new Set();
|
||||
|
||||
if (totalChanges <= SMALL_PR_THRESHOLD) {
|
||||
@@ -111,6 +111,11 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (totalChanges <= MEDIUM_PR_THRESHOLD) {
|
||||
labels.add('medium-pr');
|
||||
return labels;
|
||||
}
|
||||
|
||||
const testAdditions = prFiles
|
||||
.filter(file => file.filename.startsWith('tests/'))
|
||||
.reduce((sum, file) => sum + (file.additions || 0), 0);
|
||||
@@ -131,80 +136,33 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
|
||||
// Strategy: Dashboard changes
|
||||
async function detectDashboardChanges(changedFiles) {
|
||||
const labels = new Set();
|
||||
const dashboardFiles = changedFiles.filter(file =>
|
||||
file.startsWith('esphome/dashboard/') ||
|
||||
file.startsWith('esphome/components/dashboard_import/')
|
||||
);
|
||||
|
||||
if (dashboardFiles.length > 0) {
|
||||
if (hasDashboardChanges(changedFiles)) {
|
||||
labels.add('dashboard');
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
// Strategy: GitHub Actions changes
|
||||
async function detectGitHubActionsChanges(changedFiles) {
|
||||
const labels = new Set();
|
||||
const githubActionsFiles = changedFiles.filter(file =>
|
||||
file.startsWith('.github/workflows/')
|
||||
);
|
||||
|
||||
if (githubActionsFiles.length > 0) {
|
||||
if (hasGitHubActionsChanges(changedFiles)) {
|
||||
labels.add('github-actions');
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
// Strategy: Code owner detection
|
||||
async function detectCodeOwner(github, context, changedFiles) {
|
||||
const labels = new Set();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: 'CODEOWNERS',
|
||||
});
|
||||
|
||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
||||
const codeownersPatterns = loadCodeowners();
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
||||
const codeownersLines = codeownersContent.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
|
||||
const codeownersRegexes = codeownersLines.map(line => {
|
||||
const parts = line.split(/\s+/);
|
||||
const pattern = parts[0];
|
||||
const owners = parts.slice(1);
|
||||
|
||||
let regex;
|
||||
if (pattern.endsWith('*')) {
|
||||
const dir = pattern.slice(0, -1);
|
||||
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
||||
} else if (pattern.includes('*')) {
|
||||
// First escape all regex special chars except *, then replace * with .*
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
regex = new RegExp(`^${regexPattern}$`);
|
||||
} else {
|
||||
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||||
}
|
||||
|
||||
return { regex, owners };
|
||||
});
|
||||
|
||||
for (const file of changedFiles) {
|
||||
for (const { regex, owners } of codeownersRegexes) {
|
||||
if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
|
||||
labels.add('by-code-owner');
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
// Check if PR author is a codeowner of any changed file
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
if (effective.users.has(prAuthor)) {
|
||||
labels.add('by-code-owner');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to read or parse CODEOWNERS file:', error.message);
|
||||
@@ -238,6 +196,7 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
{ pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' }
|
||||
];
|
||||
|
||||
@@ -258,7 +217,7 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Compile regex once for better performance
|
||||
const componentFileRegex = /^esphome\/components\/([^\/]+)\//;
|
||||
const componentFileRegex = COMPONENT_REGEX;
|
||||
|
||||
// Get files that are modified or added in components directory
|
||||
const componentFiles = changedFiles.filter(file => componentFileRegex.test(file));
|
||||
@@ -276,19 +235,20 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get PR head to fetch files from the PR branch
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
// Get base branch ref to check if deprecation already exists for the component
|
||||
// This prevents flagging a PR that simply adds deprecation
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
|
||||
// Check each component's __init__.py for DEPRECATED_COMPONENT constant
|
||||
for (const component of components) {
|
||||
const initFile = `esphome/components/${component}/__init__.py`;
|
||||
try {
|
||||
// Fetch file content from PR head using GitHub API
|
||||
// Fetch file content from base branch using GitHub API
|
||||
const { data: fileData } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: initFile,
|
||||
ref: `refs/pull/${prNumber}/head`
|
||||
ref: baseRef
|
||||
});
|
||||
|
||||
// Decode base64 content
|
||||
@@ -321,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
|
||||
return { labels, deprecatedInfo };
|
||||
}
|
||||
|
||||
// Strategy: Detect when maintainers cannot modify the PR branch
|
||||
function detectMaintainerAccess(context) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Only relevant for cross-repo PRs (forks)
|
||||
if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pr.maintainer_can_modify) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOrgFork = pr.head.repo.owner.type === 'Organization';
|
||||
console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
|
||||
return { isOrgFork, orgName: pr.head.repo.owner.login };
|
||||
}
|
||||
|
||||
// Strategy: Requirements detection
|
||||
async function detectRequirements(allLabels, prFiles, context) {
|
||||
const labels = new Set();
|
||||
@@ -369,5 +347,6 @@ module.exports = {
|
||||
detectTests,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectDeprecatedComponents,
|
||||
detectMaintainerAccess,
|
||||
detectRequirements
|
||||
};
|
||||
|
||||
19
.github/scripts/auto-label-pr/index.js
vendored
19
.github/scripts/auto-label-pr/index.js
vendored
@@ -12,9 +12,10 @@ const {
|
||||
detectTests,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectDeprecatedComponents,
|
||||
detectMaintainerAccess,
|
||||
detectRequirements
|
||||
} = require('./detectors');
|
||||
const { handleReviews } = require('./reviews');
|
||||
const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
|
||||
const { applyLabels, removeOldLabels } = require('./labels');
|
||||
|
||||
// Fetch API data
|
||||
@@ -35,6 +36,7 @@ async function fetchApiData() {
|
||||
module.exports = async ({ github, context }) => {
|
||||
// Environment variables
|
||||
const SMALL_PR_THRESHOLD = parseInt(process.env.SMALL_PR_THRESHOLD);
|
||||
const MEDIUM_PR_THRESHOLD = parseInt(process.env.MEDIUM_PR_THRESHOLD);
|
||||
const MAX_LABELS = parseInt(process.env.MAX_LABELS);
|
||||
const TOO_BIG_THRESHOLD = parseInt(process.env.TOO_BIG_THRESHOLD);
|
||||
const COMPONENT_LABEL_THRESHOLD = parseInt(process.env.COMPONENT_LABEL_THRESHOLD);
|
||||
@@ -113,20 +115,22 @@ module.exports = async ({ github, context }) => {
|
||||
codeOwnerLabels,
|
||||
testLabels,
|
||||
checkboxLabels,
|
||||
deprecatedResult
|
||||
deprecatedResult,
|
||||
maintainerAccess
|
||||
] = await Promise.all([
|
||||
detectMergeBranch(context),
|
||||
detectComponentPlatforms(changedFiles, apiData),
|
||||
detectNewComponents(prFiles),
|
||||
detectNewPlatforms(prFiles, apiData),
|
||||
detectCoreChanges(changedFiles),
|
||||
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD),
|
||||
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
|
||||
detectDashboardChanges(changedFiles),
|
||||
detectGitHubActionsChanges(changedFiles),
|
||||
detectCodeOwner(github, context, changedFiles),
|
||||
detectTests(changedFiles),
|
||||
detectPRTemplateCheckboxes(context),
|
||||
detectDeprecatedComponents(github, context, changedFiles)
|
||||
detectDeprecatedComponents(github, context, changedFiles),
|
||||
detectMaintainerAccess(context)
|
||||
]);
|
||||
|
||||
// Extract deprecated component info
|
||||
@@ -176,8 +180,11 @@ module.exports = async ({ github, context }) => {
|
||||
|
||||
console.log('Computed labels:', finalLabels.join(', '));
|
||||
|
||||
// Handle reviews
|
||||
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
|
||||
// Handle reviews and org fork comment
|
||||
await Promise.all([
|
||||
handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
|
||||
handleMaintainerAccessComment(github, context, maintainerAccess)
|
||||
]);
|
||||
|
||||
// Apply labels
|
||||
await applyLabels(github, context, finalLabels);
|
||||
|
||||
92
.github/scripts/auto-label-pr/reviews.js
vendored
92
.github/scripts/auto-label-pr/reviews.js
vendored
@@ -2,7 +2,8 @@ const {
|
||||
BOT_COMMENT_MARKER,
|
||||
CODEOWNERS_MARKER,
|
||||
TOO_BIG_MARKER,
|
||||
DEPRECATED_COMPONENT_MARKER
|
||||
DEPRECATED_COMPONENT_MARKER,
|
||||
ORG_FORK_MARKER
|
||||
} = require('./constants');
|
||||
|
||||
// Generate review messages
|
||||
@@ -40,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
|
||||
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
|
||||
|
||||
message +=
|
||||
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
|
||||
`this PR is on the large side `;
|
||||
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
|
||||
message +=
|
||||
`(${nonTestChanges} line changes excluding tests, across ` +
|
||||
`${originalLabelCount} different components/areas)`;
|
||||
} else if (tooManyLabels) {
|
||||
message += `This PR affects ${originalLabelCount} different components/areas.`;
|
||||
message +=
|
||||
`(it touches ${originalLabelCount} different components/areas)`;
|
||||
} else {
|
||||
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
|
||||
message += `(${nonTestChanges} line changes excluding tests)`;
|
||||
}
|
||||
|
||||
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
|
||||
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
|
||||
message += `, which makes it harder for maintainers to review.\n\n`;
|
||||
message +=
|
||||
`Smaller, focused PRs tend to be reviewed much faster since they ` +
|
||||
`fit into the short gaps between other maintainer work; large ones ` +
|
||||
`often have to wait for a rare long uninterrupted block of time. ` +
|
||||
`If you can break this up into smaller pieces that can be reviewed ` +
|
||||
`independently, it will almost certainly land faster overall.\n\n`;
|
||||
message +=
|
||||
`Before putting more time in, it's also worth popping into ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
|
||||
`you scope things and flag anything already in flight.\n\n`;
|
||||
message +=
|
||||
`For more details (including how to split the work up), see: ` +
|
||||
`https://developers.esphome.io/contributing/submitting-your-work/` +
|
||||
`#how-to-approach-large-submissions`;
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
@@ -136,6 +157,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
|
||||
}
|
||||
}
|
||||
|
||||
// Handle maintainer access warning comment
|
||||
async function handleMaintainerAccessComment(github, context, maintainerAccess) {
|
||||
if (!maintainerAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr_number = context.issue.number;
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
||||
// Check if we already posted the warning (iterate pages to exit early)
|
||||
let existingComment;
|
||||
for await (const { data: comments } of github.paginate.iterator(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: pr_number }
|
||||
)) {
|
||||
existingComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body && comment.body.includes(ORG_FORK_MARKER)
|
||||
);
|
||||
if (existingComment) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingComment) {
|
||||
console.log('Maintainer access warning comment already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (maintainerAccess.isOrgFork) {
|
||||
body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
|
||||
`Hey there @${prAuthor},\n` +
|
||||
`It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
|
||||
`GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
|
||||
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
|
||||
`To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
|
||||
`See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
|
||||
} else {
|
||||
body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
|
||||
`Hey there @${prAuthor},\n` +
|
||||
`It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
|
||||
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
|
||||
`Please enable this option in the PR sidebar to allow maintainer collaboration.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr_number,
|
||||
body
|
||||
});
|
||||
console.log('Created maintainer access warning comment');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleReviews
|
||||
handleReviews,
|
||||
handleMaintainerAccessComment
|
||||
};
|
||||
|
||||
227
.github/scripts/codeowners.js
vendored
Normal file
227
.github/scripts/codeowners.js
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
// Shared CODEOWNERS parsing and matching utilities.
|
||||
//
|
||||
// Used by:
|
||||
// - codeowner-review-request.yml
|
||||
// - codeowner-approved-label-update.yml
|
||||
// - auto-label-pr/detectors.js (detectCodeOwner)
|
||||
|
||||
/**
|
||||
* Convert a CODEOWNERS glob pattern to a RegExp.
|
||||
*
|
||||
* Handles **, *, and ? wildcards after escaping regex-special characters.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let regexStr = pattern
|
||||
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1')
|
||||
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace
|
||||
.replace(/\*/g, '[^/]*') // single star
|
||||
.replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar
|
||||
.replace(/\?/g, '.');
|
||||
return new RegExp('^' + regexStr + '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw CODEOWNERS file content into an array of
|
||||
* { pattern, regex, owners } objects.
|
||||
*
|
||||
* Each `owners` entry is the raw string from the file (e.g. "@user" or
|
||||
* "@esphome/core").
|
||||
*/
|
||||
function parseCodeowners(content) {
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
|
||||
const patterns = [];
|
||||
for (const line of lines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pattern = parts[0];
|
||||
const owners = parts.slice(1);
|
||||
const regex = globToRegex(pattern);
|
||||
patterns.push({ pattern, regex, owners });
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the CODEOWNERS file via the GitHub API.
|
||||
*
|
||||
* @param {object} github - octokit instance from actions/github-script
|
||||
* @param {string} owner - repo owner
|
||||
* @param {string} repo - repo name
|
||||
* @param {string} [ref] - git ref (SHA / branch) to read from
|
||||
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
|
||||
*/
|
||||
async function fetchCodeowners(github, owner, repo, ref) {
|
||||
const params = { owner, repo, path: 'CODEOWNERS' };
|
||||
if (ref) params.ref = ref;
|
||||
|
||||
const { data: file } = await github.rest.repos.getContent(params);
|
||||
const content = Buffer.from(file.content, 'base64').toString('utf8');
|
||||
return parseCodeowners(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify raw owner strings into individual users and teams.
|
||||
*
|
||||
* @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"]
|
||||
* @returns {{ users: string[], teams: string[] }}
|
||||
* users – login names without "@"
|
||||
* teams – team slugs without the "org/" prefix
|
||||
*/
|
||||
function classifyOwners(rawOwners) {
|
||||
const users = [];
|
||||
const teams = [];
|
||||
for (const o of rawOwners) {
|
||||
const clean = o.startsWith('@') ? o.slice(1) : o;
|
||||
if (clean.includes('/')) {
|
||||
teams.push(clean.split('/')[1]);
|
||||
} else {
|
||||
users.push(clean);
|
||||
}
|
||||
}
|
||||
return { users, teams };
|
||||
}
|
||||
|
||||
/**
|
||||
* For each file, find its effective codeowners using GitHub's
|
||||
* "last match wins" semantics, then union across all files.
|
||||
*
|
||||
* @param {string[]} files - list of file paths
|
||||
* @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners
|
||||
* @returns {{ users: Set<string>, teams: Set<string>, matchedFileCount: number }}
|
||||
*/
|
||||
function getEffectiveOwners(files, codeownersPatterns) {
|
||||
const users = new Set();
|
||||
const teams = new Set();
|
||||
let matchedFileCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
// Last matching pattern wins for each file
|
||||
let effectiveOwners = null;
|
||||
for (const { regex, owners } of codeownersPatterns) {
|
||||
if (regex.test(file)) {
|
||||
effectiveOwners = owners;
|
||||
}
|
||||
}
|
||||
if (effectiveOwners) {
|
||||
matchedFileCount++;
|
||||
const classified = classifyOwners(effectiveOwners);
|
||||
for (const u of classified.users) users.add(u);
|
||||
for (const t of classified.teams) teams.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
return { users, teams, matchedFileCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the CODEOWNERS file from disk.
|
||||
*
|
||||
* Use this when the repo is already checked out (avoids an API call).
|
||||
*
|
||||
* @param {string} [repoRoot='.'] - path to the repo root
|
||||
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
|
||||
*/
|
||||
function loadCodeowners(repoRoot = '.') {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8');
|
||||
return parseCodeowners(content);
|
||||
}
|
||||
|
||||
/** Possible label actions returned by determineLabelAction. */
|
||||
const LabelAction = Object.freeze({
|
||||
ADD: 'add',
|
||||
REMOVE: 'remove',
|
||||
NONE: 'none',
|
||||
});
|
||||
|
||||
/**
|
||||
* Determine what label action is needed for a PR based on codeowner approvals.
|
||||
*
|
||||
* Checks changed files against CODEOWNERS patterns, reviews, and current labels
|
||||
* to decide if the label should be added, removed, or left unchanged.
|
||||
*
|
||||
* @param {object} github - octokit instance from actions/github-script
|
||||
* @param {string} owner - repo owner
|
||||
* @param {string} repo - repo name
|
||||
* @param {number} pr_number - pull request number
|
||||
* @param {Array} codeownersPatterns - from loadCodeowners / fetchCodeowners
|
||||
* @param {string} labelName - label to manage
|
||||
* @returns {Promise<LabelAction>}
|
||||
*/
|
||||
async function determineLabelAction(github, owner, repo, pr_number, codeownersPatterns, labelName) {
|
||||
// Get the list of changed files in this PR
|
||||
const prFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: pr_number }
|
||||
);
|
||||
|
||||
const changedFiles = prFiles.map(file => file.filename);
|
||||
console.log(`Found ${changedFiles.length} changed files`);
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No changed files found');
|
||||
return LabelAction.NONE;
|
||||
}
|
||||
|
||||
// Get effective owners using last-match-wins semantics
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
const componentCodeowners = effective.users;
|
||||
|
||||
console.log(`Component-specific codeowners: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: pr_number
|
||||
});
|
||||
const hasLabel = currentLabels.some(label => label.name === labelName);
|
||||
|
||||
if (componentCodeowners.size === 0) {
|
||||
console.log('No component-specific codeowners found');
|
||||
return hasLabel ? LabelAction.REMOVE : LabelAction.NONE;
|
||||
}
|
||||
|
||||
// Get all reviews and find latest per user
|
||||
const reviews = await github.paginate(
|
||||
github.rest.pulls.listReviews,
|
||||
{ owner, repo, pull_number: pr_number }
|
||||
);
|
||||
|
||||
const latestReviewByUser = new Map();
|
||||
for (const review of reviews) {
|
||||
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
|
||||
latestReviewByUser.set(review.user.login, review);
|
||||
}
|
||||
|
||||
// Check if any component-specific codeowner has an active approval
|
||||
let hasCodeownerApproval = false;
|
||||
for (const [login, review] of latestReviewByUser) {
|
||||
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
|
||||
console.log(`Codeowner '${login}' has approved`);
|
||||
hasCodeownerApproval = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCodeownerApproval && !hasLabel) return LabelAction.ADD;
|
||||
if (!hasCodeownerApproval && hasLabel) return LabelAction.REMOVE;
|
||||
|
||||
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
|
||||
return LabelAction.NONE;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
globToRegex,
|
||||
parseCodeowners,
|
||||
fetchCodeowners,
|
||||
loadCodeowners,
|
||||
classifyOwners,
|
||||
getEffectiveOwners,
|
||||
LabelAction,
|
||||
determineLabelAction
|
||||
};
|
||||
66
.github/scripts/detect-tags.js
vendored
Normal file
66
.github/scripts/detect-tags.js
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared tag detection from changed file paths.
|
||||
* Used by pr-title-check and auto-label-pr workflows.
|
||||
*/
|
||||
|
||||
const COMPONENT_REGEX = /^esphome\/components\/([^\/]+)\//;
|
||||
|
||||
/**
|
||||
* Detect component names from changed files.
|
||||
* @param {string[]} changedFiles - List of changed file paths
|
||||
* @returns {Set<string>} Set of component names
|
||||
*/
|
||||
function detectComponents(changedFiles) {
|
||||
const components = new Set();
|
||||
for (const file of changedFiles) {
|
||||
const match = file.match(COMPONENT_REGEX);
|
||||
if (match) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if core files were changed.
|
||||
* Core files are in esphome/core/ or top-level esphome/ directory.
|
||||
* @param {string[]} changedFiles - List of changed file paths
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasCoreChanges(changedFiles) {
|
||||
return changedFiles.some(file =>
|
||||
file.startsWith('esphome/core/') ||
|
||||
(file.startsWith('esphome/') && file.split('/').length === 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if dashboard files were changed.
|
||||
* @param {string[]} changedFiles - List of changed file paths
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasDashboardChanges(changedFiles) {
|
||||
return changedFiles.some(file =>
|
||||
file.startsWith('esphome/dashboard/') ||
|
||||
file.startsWith('esphome/components/dashboard_import/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if GitHub Actions files were changed.
|
||||
* @param {string[]} changedFiles - List of changed file paths
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasGitHubActionsChanges(changedFiles) {
|
||||
return changedFiles.some(file =>
|
||||
file.startsWith('.github/workflows/')
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
hasCoreChanges,
|
||||
hasDashboardChanges,
|
||||
hasGitHubActionsChanges,
|
||||
};
|
||||
7
.github/workflows/auto-label-pr.yml
vendored
7
.github/workflows/auto-label-pr.yml
vendored
@@ -12,6 +12,7 @@ permissions:
|
||||
|
||||
env:
|
||||
SMALL_PR_THRESHOLD: 30
|
||||
MEDIUM_PR_THRESHOLD: 100
|
||||
MAX_LABELS: 15
|
||||
TOO_BIG_THRESHOLD: 1000
|
||||
COMPONENT_LABEL_THRESHOLD: 10
|
||||
@@ -19,20 +20,20 @@ env:
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
|
||||
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Auto Label PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
||||
6
.github/workflows/ci-api-proto.yml
vendored
6
.github/workflows/ci-api-proto.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
fi
|
||||
- if: failure()
|
||||
name: Review PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.pulls.createReview({
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
run: git diff
|
||||
- if: failure()
|
||||
name: Archive artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: generated-proto-files
|
||||
path: |
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
esphome/components/api/api_pb2_service.*
|
||||
- if: success()
|
||||
name: Dismiss review
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
let reviews = await github.rest.pulls.listReviews({
|
||||
|
||||
8
.github/workflows/ci-clang-tidy-hash.yml
vendored
8
.github/workflows/ci-clang-tidy-hash.yml
vendored
@@ -40,9 +40,9 @@ jobs:
|
||||
echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY
|
||||
echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY
|
||||
|
||||
- if: failure()
|
||||
- if: failure() && github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Request changes
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.pulls.createReview({
|
||||
@@ -53,9 +53,9 @@ jobs:
|
||||
body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.'
|
||||
})
|
||||
|
||||
- if: success()
|
||||
- if: success() && github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Dismiss review
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
let reviews = await github.rest.pulls.listReviews({
|
||||
|
||||
2
.github/workflows/ci-docker.yml
vendored
2
.github/workflows/ci-docker.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
151
.github/workflows/ci.yml
vendored
151
.github/workflows/ci.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
@@ -106,6 +106,7 @@ jobs:
|
||||
script/build_codeowners.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2040-boards.py --check
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
@@ -153,12 +154,12 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
@@ -170,6 +171,8 @@ jobs:
|
||||
- common
|
||||
outputs:
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
|
||||
integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
@@ -182,6 +185,7 @@ jobs:
|
||||
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
|
||||
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
|
||||
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
|
||||
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -194,7 +198,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Restore components graph cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
@@ -210,6 +214,8 @@ jobs:
|
||||
|
||||
# Extract individual fields
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
@@ -222,9 +228,10 @@ jobs:
|
||||
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
|
||||
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
|
||||
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
|
||||
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
|
||||
- name: Save components graph cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
@@ -246,7 +253,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
@@ -261,9 +268,20 @@ jobs:
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
env:
|
||||
INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
|
||||
INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --no-cov --tb=native -n auto tests/integration/
|
||||
if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
|
||||
echo "Running all integration tests"
|
||||
pytest -vv --no-cov --tb=native -n auto tests/integration/
|
||||
else
|
||||
# Parse JSON array into bash array to avoid shell expansion issues
|
||||
mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
|
||||
echo "Running ${#test_files[@]} specific integration tests"
|
||||
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
|
||||
fi
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
@@ -292,6 +310,40 @@ jobs:
|
||||
script/cpp_unit_test.py $ARGS
|
||||
fi
|
||||
|
||||
benchmarks:
|
||||
name: Run CodSpeed benchmarks
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Build benchmarks
|
||||
id: build
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
|
||||
# --build-only prints BUILD_BINARY=<path> to stdout
|
||||
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0
|
||||
with:
|
||||
run: ${{ steps.build.outputs.binary }}
|
||||
mode: simulation
|
||||
|
||||
clang-tidy-single:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -335,14 +387,14 @@ jobs:
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
@@ -414,14 +466,14 @@ jobs:
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
|
||||
@@ -503,14 +555,14 @@ jobs:
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
|
||||
@@ -671,7 +723,7 @@ jobs:
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
|
||||
env:
|
||||
SKIP: pylint,clang-tidy-hash
|
||||
SKIP: pylint,clang-tidy-hash,ci-custom
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
|
||||
@@ -686,7 +738,7 @@ jobs:
|
||||
ram_usage: ${{ steps.extract.outputs.ram_usage }}
|
||||
flash_usage: ${{ steps.extract.outputs.flash_usage }}
|
||||
cache_hit: ${{ steps.cache-memory-analysis.outputs.cache-hit }}
|
||||
skip: ${{ steps.check-script.outputs.skip }}
|
||||
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -705,10 +757,39 @@ jobs:
|
||||
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
|
||||
fi
|
||||
|
||||
# All remaining steps only run if script exists
|
||||
# Check if test files exist on the target branch for the requested
|
||||
# components and platform. When a PR adds new test files for a platform,
|
||||
# the target branch won't have them yet, so skip instead of failing.
|
||||
# This check must be done here (not in determine-jobs.py) because
|
||||
# determine-jobs runs on the PR branch and cannot see what the target
|
||||
# branch has.
|
||||
- name: Check for test files on target branch
|
||||
id: check-tests
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
run: |
|
||||
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
|
||||
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
|
||||
found=false
|
||||
for component in $(echo "$components" | jq -r '.[]'); do
|
||||
# Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml)
|
||||
for f in tests/components/${component}/test*.${platform}.yaml; do
|
||||
if [ -f "$f" ]; then
|
||||
found=true
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ "$found" = false ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# All remaining steps only run if script and tests exist
|
||||
- name: Generate cache key
|
||||
id: cache-key
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
|
||||
run: |
|
||||
# Get the commit SHA of the target branch
|
||||
target_sha=$(git rev-parse HEAD)
|
||||
@@ -735,14 +816,14 @@ jobs:
|
||||
|
||||
- name: Restore cached memory analysis
|
||||
id: cache-memory-analysis
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: memory-analysis-target.json
|
||||
key: ${{ steps.cache-key.outputs.cache-key }}
|
||||
|
||||
- name: Cache status
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
|
||||
run: |
|
||||
if [ "${{ steps.cache-memory-analysis.outputs.cache-hit }}" == "true" ]; then
|
||||
echo "✓ Cache hit! Using cached memory analysis results."
|
||||
@@ -752,21 +833,21 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Restore Python
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Build, compile, and analyze memory
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
id: build
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -787,7 +868,8 @@ jobs:
|
||||
python script/test_build_components.py \
|
||||
-e compile \
|
||||
-c "$component_list" \
|
||||
-t "$platform" 2>&1 | \
|
||||
-t "$platform" \
|
||||
--base-only 2>&1 | \
|
||||
tee /dev/stderr | \
|
||||
python script/ci_memory_impact_extract.py \
|
||||
--output-env \
|
||||
@@ -800,15 +882,15 @@ jobs:
|
||||
--platform "$platform"
|
||||
|
||||
- name: Save memory analysis to cache
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: memory-analysis-target.json
|
||||
key: ${{ steps.cache-key.outputs.cache-key }}
|
||||
|
||||
- name: Extract memory usage for outputs
|
||||
id: extract
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
|
||||
run: |
|
||||
if [ -f memory-analysis-target.json ]; then
|
||||
ram=$(jq -r '.ram_bytes' memory-analysis-target.json)
|
||||
@@ -822,7 +904,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: memory-analysis-target.json
|
||||
@@ -848,7 +930,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache platformio
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
|
||||
@@ -873,7 +955,8 @@ jobs:
|
||||
python script/test_build_components.py \
|
||||
-e compile \
|
||||
-c "$component_list" \
|
||||
-t "$platform" 2>&1 | \
|
||||
-t "$platform" \
|
||||
--base-only 2>&1 | \
|
||||
tee /dev/stderr | \
|
||||
python script/ci_memory_impact_extract.py \
|
||||
--output-env \
|
||||
@@ -886,7 +969,7 @@ jobs:
|
||||
--platform "$platform"
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: memory-analysis-pr.json
|
||||
@@ -916,13 +999,13 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Download target analysis JSON
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: ./memory-analysis
|
||||
continue-on-error: true
|
||||
- name: Download PR analysis JSON
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: ./memory-analysis
|
||||
|
||||
72
.github/workflows/close-pr-from-fork-default-branch.yml
vendored
Normal file
72
.github/workflows/close-pr-from-fork-default-branch.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Close PR From Fork Default Branch
|
||||
|
||||
on:
|
||||
# pull_request_target is required so we have permission to comment and close PRs from forks.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close:
|
||||
name: Close PR opened from fork's default branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
&& github.event.pull_request.head.ref == github.event.repository.default_branch
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const headRepo = context.payload.pull_request.head.repo.full_name;
|
||||
|
||||
const body = [
|
||||
`Hi @${author}, thanks for opening a pull request! :tada:`,
|
||||
``,
|
||||
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
|
||||
``,
|
||||
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
|
||||
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
|
||||
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
|
||||
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
|
||||
``,
|
||||
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
|
||||
``,
|
||||
`\`\`\`bash`,
|
||||
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
|
||||
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
|
||||
`git fetch upstream`,
|
||||
`git checkout ${defaultBranch}`,
|
||||
`git reset --hard upstream/${defaultBranch}`,
|
||||
`git push --force-with-lease origin ${defaultBranch}`,
|
||||
``,
|
||||
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
|
||||
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
|
||||
`# ...re-apply your changes, then:`,
|
||||
`git push origin my-feature-branch`,
|
||||
`\`\`\``,
|
||||
``,
|
||||
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
|
||||
``,
|
||||
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
81
.github/workflows/codeowner-approved-label-update.yml
vendored
Normal file
81
.github/workflows/codeowner-approved-label-update.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# Adds/removes a 'code-owner-approved' label when a component-specific
|
||||
# codeowner approves (or dismisses) a PR.
|
||||
#
|
||||
# Uses pull_request_target so that fork PRs do not require workflow approval.
|
||||
# The label is reconciled on every PR update; for review events specifically,
|
||||
# this means the label is applied on the next push after a codeowner review.
|
||||
|
||||
name: Codeowner Approved Label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches-ignore:
|
||||
- release
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codeowner-approved:
|
||||
name: Run
|
||||
if: ${{ github.repository == 'esphome/esphome' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/scripts/codeowners.js
|
||||
CODEOWNERS
|
||||
|
||||
- name: Check codeowner approval and update label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
script: |
|
||||
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr_number = parseInt(process.env.PR_NUMBER, 10);
|
||||
const LABEL_NAME = 'code-owner-approved';
|
||||
|
||||
console.log(`Processing PR #${pr_number} for codeowner approval label`);
|
||||
|
||||
const codeownersPatterns = loadCodeowners();
|
||||
const action = await determineLabelAction(
|
||||
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
|
||||
);
|
||||
|
||||
if (action === LabelAction.NONE) {
|
||||
console.log('No label change needed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === LabelAction.ADD) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
|
||||
});
|
||||
console.log(`Added '${LABEL_NAME}' label`);
|
||||
} else if (action === LabelAction.REMOVE) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: pr_number, name: LABEL_NAME
|
||||
});
|
||||
console.log(`Removed '${LABEL_NAME}' label`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 403) {
|
||||
console.log(`Warning: insufficient permissions to update label (expected for fork PRs)`);
|
||||
} else if (error.status === 404) {
|
||||
console.log(`Label '${LABEL_NAME}' not present, nothing to remove`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
123
.github/workflows/codeowner-review-request.yml
vendored
123
.github/workflows/codeowner-review-request.yml
vendored
@@ -13,6 +13,9 @@ on:
|
||||
# Needs to be pull_request_target to get write permissions
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
branches-ignore:
|
||||
- release
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -24,10 +27,17 @@ jobs:
|
||||
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Request reviews from component codeowners
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
@@ -38,12 +48,15 @@ jobs:
|
||||
const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->';
|
||||
|
||||
try {
|
||||
// Get the list of changed files in this PR
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
});
|
||||
// Get the list of changed files in this PR (with pagination)
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
const changedFiles = files.map(file => file.filename);
|
||||
console.log(`Found ${changedFiles.length} changed files`);
|
||||
@@ -53,32 +66,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch CODEOWNERS file from root
|
||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: 'CODEOWNERS',
|
||||
ref: context.payload.pull_request.base.sha
|
||||
});
|
||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
||||
// Parse CODEOWNERS from the checked-out base branch
|
||||
const codeownersPatterns = loadCodeowners();
|
||||
|
||||
// Parse CODEOWNERS file to extract all patterns and their owners
|
||||
const codeownersLines = codeownersContent.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
|
||||
const codeownersPatterns = [];
|
||||
|
||||
// Convert CODEOWNERS pattern to regex (robust glob handling)
|
||||
function globToRegex(pattern) {
|
||||
// Escape regex special characters except for glob wildcards
|
||||
let regexStr = pattern
|
||||
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars
|
||||
.replace(/\*\*/g, '.*') // globstar
|
||||
.replace(/\*/g, '[^/]*') // single star
|
||||
.replace(/\?/g, '.'); // question mark
|
||||
return new RegExp('^' + regexStr + '$');
|
||||
}
|
||||
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
||||
|
||||
// Helper function to create comment body
|
||||
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
|
||||
@@ -93,50 +84,11 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of codeownersLines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pattern = parts[0];
|
||||
const owners = parts.slice(1);
|
||||
|
||||
// Use robust glob-to-regex conversion
|
||||
const regex = globToRegex(pattern);
|
||||
codeownersPatterns.push({ pattern, regex, owners });
|
||||
}
|
||||
|
||||
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
||||
|
||||
// Match changed files against CODEOWNERS patterns
|
||||
const matchedOwners = new Set();
|
||||
const matchedTeams = new Set();
|
||||
const fileMatches = new Map(); // Track which files matched which patterns
|
||||
|
||||
for (const file of changedFiles) {
|
||||
for (const { pattern, regex, owners } of codeownersPatterns) {
|
||||
if (regex.test(file)) {
|
||||
console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`);
|
||||
|
||||
if (!fileMatches.has(file)) {
|
||||
fileMatches.set(file, []);
|
||||
}
|
||||
fileMatches.get(file).push({ pattern, owners });
|
||||
|
||||
// Add owners to the appropriate set (remove @ prefix)
|
||||
for (const owner of owners) {
|
||||
const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
|
||||
if (cleanOwner.includes('/')) {
|
||||
// Team mention (org/team-name)
|
||||
const teamName = cleanOwner.split('/')[1];
|
||||
matchedTeams.add(teamName);
|
||||
} else {
|
||||
// Individual user
|
||||
matchedOwners.add(cleanOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
const matchedOwners = effective.users;
|
||||
const matchedTeams = effective.teams;
|
||||
const matchedFileCount = effective.matchedFileCount;
|
||||
|
||||
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
|
||||
console.log('No codeowners found for any changed files');
|
||||
@@ -170,11 +122,14 @@ jobs:
|
||||
}
|
||||
|
||||
// Check for completed reviews to avoid re-requesting users who have already reviewed
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
});
|
||||
const reviews = await github.paginate(
|
||||
github.rest.pulls.listReviews,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
const reviewedUsers = new Set();
|
||||
reviews.forEach(review => {
|
||||
@@ -247,7 +202,7 @@ jobs:
|
||||
}
|
||||
|
||||
const totalReviewers = reviewersList.length + teamsList.length;
|
||||
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
|
||||
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`);
|
||||
|
||||
// Request reviews
|
||||
try {
|
||||
@@ -279,7 +234,7 @@ jobs:
|
||||
|
||||
// Only add a comment if there are new codeowners to mention (not previously pinged)
|
||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -297,7 +252,7 @@ jobs:
|
||||
|
||||
// Only try to add a comment if there are new codeowners to mention
|
||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
|
||||
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -86,6 +86,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
2
.github/workflows/external-component-bot.yml
vendored
2
.github/workflows/external-component-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add external component comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
2
.github/workflows/issue-codeowner-notify.yml
vendored
2
.github/workflows/issue-codeowner-notify.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify codeowners for component issues
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -8,4 +8,4 @@ on:
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@main
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0
|
||||
|
||||
96
.github/workflows/pr-title-check.yml
vendored
Normal file
96
.github/workflows/pr-title-check.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- release
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const {
|
||||
detectComponents,
|
||||
hasCoreChanges,
|
||||
hasDashboardChanges,
|
||||
hasGitHubActionsChanges,
|
||||
} = require('./.github/scripts/detect-tags.js');
|
||||
|
||||
const title = context.payload.pull_request.title;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
// Skip bot PRs (e.g. dependabot) - they have their own title format
|
||||
if (author === 'dependabot[bot]') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block titles starting with "word:" or "word(scope):" patterns
|
||||
const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/;
|
||||
if (commitStylePattern.test(title)) {
|
||||
core.setFailed(
|
||||
`PR title should not start with a "prefix:" style format.\n` +
|
||||
`Please use the format: [component] Brief description\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get changed files to detect tags
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const filenames = files.map(f => f.filename);
|
||||
|
||||
// Detect tags from changed files using shared logic
|
||||
const tags = new Set();
|
||||
|
||||
for (const comp of detectComponents(filenames)) {
|
||||
tags.add(comp);
|
||||
}
|
||||
if (hasCoreChanges(filenames)) tags.add('core');
|
||||
if (hasDashboardChanges(filenames)) tags.add('dashboard');
|
||||
if (hasGitHubActionsChanges(filenames)) tags.add('ci');
|
||||
|
||||
if (tags.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for angle brackets not wrapped in backticks.
|
||||
// Astro docs MDX treats bare < as JSX component opening tags.
|
||||
const stripped = title.replace(/`[^`]*`/g, '');
|
||||
if (/[<>]/.test(stripped)) {
|
||||
core.setFailed(
|
||||
'PR title contains `<` or `>` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components.\n' +
|
||||
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check title starts with [tag] prefix
|
||||
const bracketPattern = /^\[\w+\]/;
|
||||
if (!bracketPattern.test(title)) {
|
||||
const suggestion = [...tags].map(c => `[${c}]`).join('');
|
||||
// Skip if the suggested prefix would be too long for a readable title
|
||||
if (suggestion.length > 40) {
|
||||
return;
|
||||
}
|
||||
core.setFailed(
|
||||
`PR modifies: ${[...tags].join(', ')}\n` +
|
||||
`Title must start with a [tag] prefix.\n` +
|
||||
`Suggested: ${suggestion} <description>`
|
||||
);
|
||||
}
|
||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
# version: ${{ needs.init.outputs.tag }}
|
||||
|
||||
- name: Upload digests
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ matrix.platform.arch }}
|
||||
path: /tmp/digests
|
||||
@@ -171,24 +171,24 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: digests-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
repositories: home-assistant-addon
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
repositories: esphome-schema
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
repositories: version-notifier
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
27
.github/workflows/status-check-labels.yml
vendored
27
.github/workflows/status-check-labels.yml
vendored
@@ -2,30 +2,29 @@ name: Status check labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled]
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check ${{ matrix.label }}
|
||||
name: Check blocking labels
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
label:
|
||||
- needs-docs
|
||||
- merge-after-release
|
||||
- chained-pr
|
||||
steps:
|
||||
- name: Check for ${{ matrix.label }} label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
const hasLabel = labels.find(label => label.name === '${{ matrix.label }}');
|
||||
if (hasLabel) {
|
||||
core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}');
|
||||
const labelNames = labels.map(l => l.name);
|
||||
const found = blockingLabels.filter(bl => labelNames.includes(bl));
|
||||
if (found.length > 0) {
|
||||
core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`);
|
||||
}
|
||||
|
||||
4
.github/workflows/sync-device-classes.yml
vendored
4
.github/workflows/sync-device-classes.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
python script/run-in-env.py pre-commit run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphomebot <esphome@openhomefoundation.org>
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.0
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -37,7 +37,7 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.20.0
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py311-plus]
|
||||
@@ -58,6 +58,7 @@ repos:
|
||||
entry: python3 script/run-in-env.py pylint
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^esphome/.+\.py$
|
||||
- id: clang-tidy-hash
|
||||
name: Update clang-tidy hash
|
||||
entry: python script/clang_tidy_hash.py --update-if-changed
|
||||
@@ -65,3 +66,7 @@ repos:
|
||||
files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$
|
||||
pass_filenames: false
|
||||
additional_dependencies: []
|
||||
- id: ci-custom
|
||||
name: ci-custom
|
||||
entry: python3 script/run-in-env.py script/ci-custom.py
|
||||
language: system
|
||||
|
||||
32
CODEOWNERS
32
CODEOWNERS
@@ -54,6 +54,9 @@ esphome/components/atm90e32/* @circuitsetup @descipher
|
||||
esphome/components/audio/* @kahrendt
|
||||
esphome/components/audio_adc/* @kbx81
|
||||
esphome/components/audio_dac/* @kbx81
|
||||
esphome/components/audio_file/* @kahrendt
|
||||
esphome/components/audio_file/media_source/* @kahrendt
|
||||
esphome/components/audio_http/* @kahrendt
|
||||
esphome/components/axs15231/* @clydebarrow
|
||||
esphome/components/b_parasite/* @rbaron
|
||||
esphome/components/ballu/* @bazuchan
|
||||
@@ -90,6 +93,7 @@ esphome/components/bmp3xx_i2c/* @latonita
|
||||
esphome/components/bmp3xx_spi/* @latonita
|
||||
esphome/components/bmp581_base/* @danielkent-net @kahrendt
|
||||
esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
|
||||
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
|
||||
esphome/components/bp1658cj/* @Cossid
|
||||
esphome/components/bp5758d/* @Cossid
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
@@ -130,6 +134,7 @@ esphome/components/dashboard_import/* @esphome/core
|
||||
esphome/components/datetime/* @jesserockz @rfdarter
|
||||
esphome/components/debug/* @esphome/core
|
||||
esphome/components/delonghi/* @grob6000
|
||||
esphome/components/dew_point/* @CFlix
|
||||
esphome/components/dfplayer/* @glmnet
|
||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||
esphome/components/dht/* @OttoWinter
|
||||
@@ -138,12 +143,13 @@ esphome/components/dlms_meter/* @SimonFischer04
|
||||
esphome/components/dps310/* @kbx81
|
||||
esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose @zuidwijk
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ee895/* @Stock-M
|
||||
esphome/components/ektf2232/touchscreen/* @jesserockz
|
||||
esphome/components/emc2101/* @ellull
|
||||
esphome/components/emmeti/* @E440QF
|
||||
esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea
|
||||
esphome/components/ens160/* @latonita
|
||||
esphome/components/ens160_base/* @latonita @vincentscode
|
||||
esphome/components/ens160_i2c/* @latonita
|
||||
@@ -213,6 +219,8 @@ esphome/components/hbridge/light/* @DotNetDann
|
||||
esphome/components/hbridge/switch/* @dwmw2
|
||||
esphome/components/hc8/* @omartijn
|
||||
esphome/components/hdc2010/* @optimusprimespace @ssieb
|
||||
esphome/components/hdc2080/* @G-Pereira @jesserockz
|
||||
esphome/components/hdc302x/* @joshuasing
|
||||
esphome/components/he60r/* @clydebarrow
|
||||
esphome/components/heatpumpir/* @rob-deutsch
|
||||
esphome/components/hitachi_ac424/* @sourabhjaiswal
|
||||
@@ -240,7 +248,6 @@ esphome/components/hyt271/* @Philippe12
|
||||
esphome/components/i2c/* @esphome/core
|
||||
esphome/components/i2c_device/* @gabest11
|
||||
esphome/components/i2s_audio/* @jesserockz
|
||||
esphome/components/i2s_audio/media_player/* @jesserockz
|
||||
esphome/components/i2s_audio/microphone/* @jesserockz
|
||||
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/iaqcore/* @yozik04
|
||||
@@ -315,6 +322,7 @@ esphome/components/mcp9808/* @k7hpn
|
||||
esphome/components/md5/* @esphome/core
|
||||
esphome/components/mdns/* @esphome/core
|
||||
esphome/components/media_player/* @jesserockz
|
||||
esphome/components/media_source/* @kahrendt
|
||||
esphome/components/micro_wake_word/* @jesserockz @kahrendt
|
||||
esphome/components/micronova/* @edenhaus @jorre05
|
||||
esphome/components/microphone/* @jesserockz @kahrendt
|
||||
@@ -325,6 +333,7 @@ esphome/components/mipi_dsi/* @clydebarrow
|
||||
esphome/components/mipi_rgb/* @clydebarrow
|
||||
esphome/components/mipi_spi/* @clydebarrow
|
||||
esphome/components/mitsubishi/* @RubyBailey
|
||||
esphome/components/mitsubishi_cn105/* @crnjan
|
||||
esphome/components/mixer/speaker/* @kahrendt
|
||||
esphome/components/mlx90393/* @functionpointer
|
||||
esphome/components/mlx90614/* @jesserockz
|
||||
@@ -395,6 +404,7 @@ esphome/components/qmp6988/* @andrewpc
|
||||
esphome/components/qr_code/* @wjtje
|
||||
esphome/components/qspi_dbi/* @clydebarrow
|
||||
esphome/components/qwiic_pir/* @kahrendt
|
||||
esphome/components/radio_frequency/* @kbx81
|
||||
esphome/components/radon_eye_ble/* @jeffeb3
|
||||
esphome/components/radon_eye_rd200/* @jeffeb3
|
||||
esphome/components/rc522/* @glmnet
|
||||
@@ -406,11 +416,13 @@ esphome/components/restart/* @esphome/core
|
||||
esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtttl/* @glmnet
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
|
||||
esphome/components/runtime_stats/* @bdraco
|
||||
esphome/components/rx8130/* @beormund
|
||||
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
|
||||
@@ -427,8 +439,15 @@ esphome/components/select/* @esphome/core
|
||||
esphome/components/sen0321/* @notjj
|
||||
esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
esphome/components/sendspin/text_sensor/* @kahrendt
|
||||
esphome/components/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
esphome/components/serial_proxy/* @kbx81
|
||||
esphome/components/sfa30/* @ghsensdev
|
||||
esphome/components/sgp40/* @SenexCrenshaw
|
||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||
@@ -449,8 +468,12 @@ esphome/components/sn74hc165/* @jesserockz
|
||||
esphome/components/socket/* @esphome/core
|
||||
esphome/components/sonoff_d1/* @anatoly-savchenkov
|
||||
esphome/components/sound_level/* @kahrendt
|
||||
esphome/components/spa06_base/* @danielkent-net
|
||||
esphome/components/spa06_i2c/* @danielkent-net
|
||||
esphome/components/spa06_spi/* @danielkent-net
|
||||
esphome/components/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
|
||||
esphome/components/speaker_source/* @kahrendt
|
||||
esphome/components/spi/* @clydebarrow @esphome/core
|
||||
esphome/components/spi_device/* @clydebarrow
|
||||
esphome/components/spi_led_strip/* @clydebarrow
|
||||
@@ -581,7 +604,8 @@ esphome/components/xl9535/* @mreditor97
|
||||
esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @tomaszduda23
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
esphome/components/zwave_proxy/* @kbx81
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -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.2.0b2
|
||||
PROJECT_NUMBER = 2026.5.0-dev
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,4 +4,5 @@ include requirements.txt
|
||||
recursive-include esphome *.yaml
|
||||
recursive-include esphome *.cpp *.h *.tcc *.c
|
||||
recursive-include esphome *.py.script
|
||||
recursive-include esphome *.jinja
|
||||
recursive-include esphome LICENSE.txt
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ESPHome [](https://discord.gg/KhAMKrd) [](https://GitHub.com/esphome/esphome/releases/)
|
||||
# ESPHome [](https://discord.gg/KhAMKrd) [](https://GitHub.com/esphome/esphome/releases/) [](https://codspeed.io/esphome/esphome)
|
||||
|
||||
<a href="https://esphome.io/">
|
||||
<picture>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# PYTHON_ARGCOMPLETE_OK
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
@@ -9,6 +10,8 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
@@ -23,6 +26,7 @@ import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
CONF_API,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
@@ -35,6 +39,7 @@ from esphome.const import (
|
||||
CONF_MDNS,
|
||||
CONF_MQTT,
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
@@ -43,7 +48,9 @@ from esphome.const import (
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_NATIVE_IDF,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_RP2040,
|
||||
@@ -55,18 +62,40 @@ from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.types import ConfigType
|
||||
from esphome.util import (
|
||||
PICOTOOL_PACKAGE,
|
||||
detect_rp2040_bootsel,
|
||||
get_picotool_path,
|
||||
get_serial_ports,
|
||||
is_picotool_usb_permission_error,
|
||||
list_yaml_files,
|
||||
run_external_command,
|
||||
run_external_process,
|
||||
safe_print,
|
||||
)
|
||||
from esphome.zeroconf import discover_mdns_devices
|
||||
|
||||
_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
|
||||
|
||||
_RP2040_BOOTSEL_INSTRUCTIONS = (
|
||||
"To enter BOOTSEL mode:\n"
|
||||
" 1. Unplug the device\n"
|
||||
" 2. Hold the BOOT/BOOTSEL button\n"
|
||||
" 3. Plug in the USB cable while holding the button\n"
|
||||
" 4. Release the button - the device should appear as a USB drive (RPI-RP2)\n"
|
||||
"Then run the upload command again."
|
||||
)
|
||||
|
||||
_RP2040_UDEV_HINT = (
|
||||
"You may need to add a udev rule for RP2040 devices. "
|
||||
"See: https://github.com/raspberrypi/picotool"
|
||||
"/blob/master/udev/60-picotool.rules"
|
||||
)
|
||||
|
||||
# Special non-component keys that appear in configs
|
||||
_NON_COMPONENT_KEYS = frozenset(
|
||||
{
|
||||
@@ -162,6 +191,7 @@ class PortType(StrEnum):
|
||||
NETWORK = "NETWORK"
|
||||
MQTT = "MQTT"
|
||||
MQTTIP = "MQTTIP"
|
||||
BOOTSEL = "BOOTSEL"
|
||||
|
||||
|
||||
# Magic MQTT port types that require special handling
|
||||
@@ -176,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
|
||||
return [address]
|
||||
|
||||
|
||||
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
|
||||
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
|
||||
|
||||
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
|
||||
the downstream resolution path (``resolve_ip_address``) can skip opening a
|
||||
second Zeroconf client.
|
||||
"""
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
if CORE.address_cache is None:
|
||||
CORE.address_cache = AddressCache()
|
||||
for host, addresses in hosts_to_addresses.items():
|
||||
if addresses:
|
||||
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
|
||||
CORE.address_cache.add_mdns_addresses(host, addresses)
|
||||
|
||||
|
||||
def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
|
||||
|
||||
Returns:
|
||||
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
|
||||
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
|
||||
then fall back to whatever default OTA address they normally use.
|
||||
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
|
||||
back to the base name: with ``name_add_mac_suffix`` enabled, the base
|
||||
name by definition doesn't exist on the network.
|
||||
- A non-empty sorted list of ``.local`` hostnames on success.
|
||||
|
||||
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
|
||||
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
|
||||
already have without opening a second Zeroconf client.
|
||||
"""
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
|
||||
return None
|
||||
_LOGGER.info("Discovering devices...")
|
||||
if not (discovered := discover_mdns_devices(CORE.name)):
|
||||
_LOGGER.warning(
|
||||
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
|
||||
)
|
||||
return []
|
||||
_populate_mdns_cache(discovered)
|
||||
return list(discovered)
|
||||
|
||||
|
||||
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
|
||||
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
|
||||
|
||||
When ``name_add_mac_suffix`` is enabled, returns discovered
|
||||
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
|
||||
caller should not fall back to the base name). Otherwise falls back to
|
||||
the cache-resolved ``CORE.address``.
|
||||
"""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
return discovered
|
||||
return _resolve_with_cache(CORE.address, purpose)
|
||||
|
||||
|
||||
def choose_upload_log_host(
|
||||
default: list[str] | str | None,
|
||||
check_default: str | None,
|
||||
@@ -214,14 +302,14 @@ def choose_upload_log_host(
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
if has_ota() and has_mqtt_ip_lookup():
|
||||
resolved.append("MQTTIP")
|
||||
|
||||
if has_ota() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
else:
|
||||
resolved.append(device)
|
||||
if not resolved:
|
||||
@@ -240,22 +328,61 @@ def choose_upload_log_host(
|
||||
(f"{port.path} ({port.description})", port.path) for port in get_serial_ports()
|
||||
]
|
||||
|
||||
# Add RP2040 BOOTSEL device option when uploading
|
||||
bootsel_permission_error = False
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
and (picotool := _find_picotool()) is not None
|
||||
):
|
||||
bootsel = detect_rp2040_bootsel(picotool)
|
||||
if bootsel.device_count > 0:
|
||||
options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL"))
|
||||
elif bootsel.permission_error:
|
||||
bootsel_permission_error = True
|
||||
|
||||
def add_ota_options() -> None:
|
||||
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
# Discovery was applicable. Use whatever we found — on empty,
|
||||
# intentionally skip the base-name fallback since with
|
||||
# name_add_mac_suffix on, the base name doesn't exist on the net.
|
||||
for host in discovered:
|
||||
options.append((f"Over The Air ({host})", host))
|
||||
elif has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
|
||||
if purpose == Purpose.LOGGING:
|
||||
if has_mqtt_logging():
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
|
||||
):
|
||||
if bootsel_permission_error:
|
||||
_LOGGER.warning(
|
||||
"An RP2040 device in BOOTSEL mode was detected but could "
|
||||
"not be accessed due to USB permissions."
|
||||
)
|
||||
if sys.platform.startswith("linux"):
|
||||
_LOGGER.warning(_RP2040_UDEV_HINT)
|
||||
if not options:
|
||||
raise EsphomeError(
|
||||
f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}"
|
||||
)
|
||||
_LOGGER.info("Tip: %s", _RP2040_BOOTSEL_INSTRUCTIONS)
|
||||
|
||||
if check_default is not None and check_default in [opt[1] for opt in options]:
|
||||
return [check_default]
|
||||
@@ -347,7 +474,17 @@ def has_resolvable_address() -> bool:
|
||||
return not CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
|
||||
def has_name_add_mac_suffix() -> bool:
|
||||
"""Check if name_add_mac_suffix is enabled in the config."""
|
||||
if CORE.config is None:
|
||||
return False
|
||||
esphome_config = CORE.config.get(CONF_ESPHOME, {})
|
||||
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
@@ -360,6 +497,9 @@ def _resolve_network_devices(
|
||||
|
||||
This function filters the devices list to:
|
||||
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
|
||||
- Expand hostnames that are already in ``CORE.address_cache`` to their
|
||||
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
|
||||
Zeroconf client to resolve them
|
||||
- Deduplicate addresses while preserving order
|
||||
- Only resolve MQTT once even if multiple MQTT strings are present
|
||||
- If MQTT resolution fails, log a warning and continue with other devices
|
||||
@@ -384,13 +524,29 @@ def _resolve_network_devices(
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(mqtt_ips)
|
||||
# pylint can't infer mqtt_get_ip's return through its
|
||||
# lazy ``from esphome import mqtt`` import, so it flags
|
||||
# the genexpr below.
|
||||
network_devices.extend(
|
||||
addr
|
||||
for addr in mqtt_ips # pylint: disable=not-an-iterable
|
||||
if addr not in network_devices
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
mqtt_resolved = True
|
||||
continue
|
||||
|
||||
# If the hostname is already in the address cache (e.g. populated by
|
||||
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
|
||||
# open its own Zeroconf to re-resolve it.
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(
|
||||
addr for addr in cached if addr not in network_devices
|
||||
)
|
||||
elif device not in network_devices:
|
||||
# Regular network address or IP - add if not already present
|
||||
network_devices.append(device)
|
||||
@@ -403,10 +559,13 @@ def get_port_type(port: str) -> PortType:
|
||||
|
||||
Returns:
|
||||
PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
|
||||
PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool
|
||||
PortType.MQTT for MQTT logging
|
||||
PortType.MQTTIP for MQTT IP lookup
|
||||
PortType.NETWORK for IP addresses, hostnames, or mDNS names
|
||||
"""
|
||||
if port == "BOOTSEL":
|
||||
return PortType.BOOTSEL
|
||||
if port.startswith("/") or port.startswith("COM"):
|
||||
return PortType.SERIAL
|
||||
if port == "MQTT":
|
||||
@@ -431,6 +590,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
return 1
|
||||
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
|
||||
|
||||
process_stacktrace = None
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = getattr(module, "process_stacktrace")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
backtrace_state = False
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = baud_rate
|
||||
@@ -472,9 +639,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
|
||||
backtrace_state = platformio_api.process_stacktrace(
|
||||
config, line, backtrace_state=backtrace_state
|
||||
)
|
||||
if process_stacktrace:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
else:
|
||||
backtrace_state = platformio_api.process_stacktrace(
|
||||
config, line, backtrace_state=backtrace_state
|
||||
)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -614,6 +786,47 @@ def _check_and_emit_build_info() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _get_configured_xtal_freq() -> int | None:
|
||||
"""Read the configured crystal frequency from the sdkconfig file."""
|
||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
||||
if not sdkconfig_path.is_file():
|
||||
return None
|
||||
with suppress(OSError, ValueError):
|
||||
content = sdkconfig_path.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("CONFIG_XTAL_FREQ="):
|
||||
return int(line.split("=", 1)[1])
|
||||
return None
|
||||
|
||||
|
||||
def _make_crystal_freq_callback(
|
||||
configured_freq: int,
|
||||
) -> Callable[[str], str | None]:
|
||||
"""Create a callback that checks esptool crystal frequency output."""
|
||||
crystal_re = re.compile(r"Crystal frequency:\s+(\d+)\s*MHz")
|
||||
|
||||
def check_crystal_line(line: str) -> str | None:
|
||||
if not (match := crystal_re.search(line)):
|
||||
return None
|
||||
detected = int(match.group(1))
|
||||
if detected == configured_freq:
|
||||
return None
|
||||
return (
|
||||
f"\n\033[33mWARNING: Crystal frequency mismatch! "
|
||||
f"Device reports {detected}MHz but firmware is configured "
|
||||
f"for {configured_freq}MHz.\n"
|
||||
f"UART logging and other clock-dependent features will not "
|
||||
f"work correctly.\n"
|
||||
f"Set the correct crystal frequency with sdkconfig_options:\n"
|
||||
f" esp32:\n"
|
||||
f" framework:\n"
|
||||
f" sdkconfig_options:\n"
|
||||
f" CONFIG_XTAL_FREQ_{detected}: 'y'\033[0m\n\n"
|
||||
)
|
||||
|
||||
return check_crystal_line
|
||||
|
||||
|
||||
def upload_using_esptool(
|
||||
config: ConfigType, port: str, file: str, speed: int
|
||||
) -> str | int:
|
||||
@@ -633,8 +846,15 @@ def upload_using_esptool(
|
||||
platformio_api.FlashImage(
|
||||
path=idedata.firmware_bin_path, offset=firmware_offset
|
||||
),
|
||||
*idedata.extra_flash_images,
|
||||
]
|
||||
for image in idedata.extra_flash_images:
|
||||
if not image.path.is_file():
|
||||
_LOGGER.warning(
|
||||
"Skipping missing flash image declared by platform: %s",
|
||||
image.path,
|
||||
)
|
||||
continue
|
||||
flash_images.append(image)
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
@@ -642,6 +862,14 @@ def upload_using_esptool(
|
||||
|
||||
mcu = get_esp32_variant().lower()
|
||||
|
||||
line_callbacks: list[Callable[[str], str | None]] = []
|
||||
if (
|
||||
CORE.is_esp32
|
||||
and file is None
|
||||
and (configured_freq := _get_configured_xtal_freq()) is not None
|
||||
):
|
||||
line_callbacks.append(_make_crystal_freq_callback(configured_freq))
|
||||
|
||||
def run_esptool(baud_rate):
|
||||
cmd = [
|
||||
"esptool",
|
||||
@@ -666,9 +894,13 @@ def upload_using_esptool(
|
||||
if os.environ.get("ESPHOME_USE_SUBPROCESS") is None:
|
||||
import esptool
|
||||
|
||||
return run_external_command(esptool.main, *cmd) # pylint: disable=no-member
|
||||
return run_external_command(
|
||||
esptool.main, # pylint: disable=no-member
|
||||
*cmd,
|
||||
line_callbacks=line_callbacks,
|
||||
)
|
||||
|
||||
return run_external_process(*cmd)
|
||||
return run_external_process(*cmd, line_callbacks=line_callbacks)
|
||||
|
||||
rc = run_esptool(first_baudrate)
|
||||
if rc == 0 or first_baudrate == 115200:
|
||||
@@ -681,15 +913,140 @@ def upload_using_esptool(
|
||||
return run_esptool(115200)
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str):
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
from esphome import platformio_api
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||
# Create it here so the upload doesn't fail.
|
||||
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040:
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
build_dir = Path(idedata.firmware_elf_path).parent
|
||||
firmware_bin = build_dir / "firmware.bin"
|
||||
signed_bin = build_dir / "firmware.bin.signed"
|
||||
if firmware_bin.is_file() and not signed_bin.is_file():
|
||||
shutil.copy2(firmware_bin, signed_bin)
|
||||
|
||||
upload_args = ["-t", "upload", "-t", "nobuild"]
|
||||
if port is not None:
|
||||
upload_args += ["--upload-port", port]
|
||||
return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args)
|
||||
|
||||
|
||||
def _find_picotool() -> Path | None:
|
||||
"""Find the picotool binary from PlatformIO packages."""
|
||||
from esphome import platformio_api
|
||||
|
||||
try:
|
||||
idedata = platformio_api.get_idedata(CORE.config)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
return get_picotool_path(idedata.cc_path)
|
||||
|
||||
|
||||
def upload_using_picotool(config: ConfigType) -> int:
|
||||
"""Upload firmware to RP2040 in BOOTSEL mode using picotool.
|
||||
|
||||
Uses picotool to load the ELF firmware directly via USB, avoiding
|
||||
the mass storage copy approach that causes "disk not ejected properly"
|
||||
warnings on macOS.
|
||||
"""
|
||||
from esphome import platformio_api
|
||||
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
firmware_elf = Path(idedata.firmware_elf_path)
|
||||
|
||||
if not firmware_elf.is_file():
|
||||
_LOGGER.error(
|
||||
"Firmware ELF file not found at %s. "
|
||||
"Make sure the project has been compiled first.",
|
||||
firmware_elf,
|
||||
)
|
||||
return 1
|
||||
|
||||
picotool = get_picotool_path(idedata.cc_path)
|
||||
if picotool is None:
|
||||
_LOGGER.error(
|
||||
"picotool not found. Ensure the RP2040 PlatformIO platform "
|
||||
"is installed (%s).",
|
||||
PICOTOOL_PACKAGE,
|
||||
)
|
||||
return 1
|
||||
|
||||
_LOGGER.info("Uploading firmware to RP2040 via picotool...")
|
||||
try:
|
||||
# Don't capture stdout — let picotool write directly to the terminal
|
||||
# so progress bars display in real-time with \r updates.
|
||||
# Capture stderr only so we can detect permission errors.
|
||||
result = subprocess.run(
|
||||
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOGGER.error("picotool upload timed out after 60 seconds.")
|
||||
return 1
|
||||
except OSError as err:
|
||||
_LOGGER.error("Failed to run picotool: %s", err)
|
||||
return 1
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
if stderr:
|
||||
for line in stderr.splitlines():
|
||||
safe_print(line)
|
||||
if is_picotool_usb_permission_error(stderr):
|
||||
msg = "Permission denied accessing USB device."
|
||||
if sys.platform.startswith("linux"):
|
||||
msg += f" {_RP2040_UDEV_HINT}"
|
||||
_LOGGER.error(msg)
|
||||
else:
|
||||
_LOGGER.error("picotool upload failed (exit code %d).", result.returncode)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_for_serial_port(
|
||||
port: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
known_ports: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Wait for a serial port to appear, e.g. after a device reboot.
|
||||
|
||||
USB-CDC devices disappear briefly after flashing while the device
|
||||
reboots and re-enumerates on the USB bus.
|
||||
|
||||
If port is given, wait for that specific path. If known_ports is
|
||||
given, wait for a new port that wasn't in the set. Otherwise wait
|
||||
for any serial port to appear.
|
||||
"""
|
||||
|
||||
def _port_found() -> bool:
|
||||
if port is not None:
|
||||
if os.name == "posix":
|
||||
return os.path.exists(port)
|
||||
return any(p.path == port for p in get_serial_ports())
|
||||
ports = get_serial_ports()
|
||||
if known_ports is not None:
|
||||
return any(p.path not in known_ports for p in ports)
|
||||
return bool(ports)
|
||||
|
||||
if _port_found():
|
||||
return
|
||||
if port is not None:
|
||||
_LOGGER.info("Waiting for %s to come online...", port)
|
||||
else:
|
||||
_LOGGER.info("Waiting for device to reboot...")
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
time.sleep(0.05)
|
||||
if _port_found():
|
||||
time.sleep(0.05)
|
||||
return
|
||||
|
||||
|
||||
def check_permissions(port: str):
|
||||
if os.name == "posix" and get_port_type(port) == PortType.SERIAL:
|
||||
# Check if we can open selected serial port
|
||||
@@ -719,7 +1076,15 @@ def upload_program(
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if get_port_type(host) == PortType.SERIAL:
|
||||
port_type = get_port_type(host)
|
||||
|
||||
if port_type == PortType.BOOTSEL:
|
||||
exit_code = upload_using_picotool(config)
|
||||
# Return None for device - BOOTSEL can't be used for logging,
|
||||
# so command_run will show the interactive chooser for log source
|
||||
return exit_code, None
|
||||
|
||||
if port_type == PortType.SERIAL:
|
||||
check_permissions(host)
|
||||
|
||||
exit_code = 1
|
||||
@@ -773,6 +1138,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
port_type = get_port_type(port)
|
||||
|
||||
if port_type == PortType.SERIAL:
|
||||
_wait_for_serial_port(port)
|
||||
check_permissions(port)
|
||||
return run_miniterm(config, port, args)
|
||||
|
||||
@@ -783,7 +1149,11 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
):
|
||||
from esphome.components.api.client import run_logs
|
||||
|
||||
return run_logs(config, network_devices)
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=not getattr(args, "no_states", False),
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
from esphome import mqtt
|
||||
@@ -816,7 +1186,7 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# add the console decoration so the front-end can hide the secrets
|
||||
if not args.show_secrets:
|
||||
output = re.sub(
|
||||
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[5m\2\\033[6m", output
|
||||
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
|
||||
)
|
||||
if not CORE.quiet:
|
||||
safe_print(output)
|
||||
@@ -911,6 +1281,9 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
purpose=Purpose.UPLOADING,
|
||||
)
|
||||
|
||||
# Snapshot current serial ports before upload so we can detect new ones
|
||||
pre_upload_ports = {p.path for p in get_serial_ports()}
|
||||
|
||||
exit_code, successful_device = upload_program(config, args, devices)
|
||||
if exit_code == 0:
|
||||
_LOGGER.info("Successfully uploaded program.")
|
||||
@@ -921,6 +1294,19 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if args.no_logs:
|
||||
return 0
|
||||
|
||||
# After BOOTSEL upload, wait for a new serial port to appear
|
||||
# so it shows up in the log chooser
|
||||
if (
|
||||
successful_device is None
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
):
|
||||
_wait_for_serial_port(known_ports=pre_upload_ports)
|
||||
# If exactly one new serial port appeared, use it directly
|
||||
serial_ports = get_serial_ports()
|
||||
new_ports = [p for p in serial_ports if p.path not in pre_upload_ports]
|
||||
if len(new_ports) == 1:
|
||||
successful_device = new_ports[0].path
|
||||
|
||||
# For logs, prefer the device we successfully uploaded to
|
||||
devices = choose_upload_log_host(
|
||||
default=successful_device,
|
||||
@@ -944,12 +1330,6 @@ def command_clean_all(args: ArgsProtocol) -> int | None:
|
||||
return 0
|
||||
|
||||
|
||||
def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_fingerprint(config)
|
||||
|
||||
|
||||
def command_version(args: ArgsProtocol) -> int | None:
|
||||
safe_print(f"Version: {const.__version__}")
|
||||
return 0
|
||||
@@ -965,6 +1345,38 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return 0
|
||||
|
||||
|
||||
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
|
||||
|
||||
creator = ConfigBundleCreator(config)
|
||||
|
||||
if args.list_only:
|
||||
files = creator.discover_files()
|
||||
for bf in sorted(files, key=lambda f: f.path):
|
||||
safe_print(f" {bf.path}")
|
||||
_LOGGER.info("Found %d files", len(files))
|
||||
return 0
|
||||
|
||||
result = creator.create_bundle()
|
||||
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
stem = CORE.config_path.stem
|
||||
output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}"
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(result.data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Bundle created: %s (%d files, %.1f KB)",
|
||||
output_path,
|
||||
len(result.files),
|
||||
len(result.data) / 1024,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_dashboard(args: ArgsProtocol) -> int | None:
|
||||
from esphome.dashboard import dashboard
|
||||
|
||||
@@ -1036,9 +1448,8 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
files = list_yaml_files(args.configuration)
|
||||
|
||||
def build_command(f):
|
||||
if CORE.dashboard:
|
||||
return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"]
|
||||
return ["esphome", "run", f, "--no-logs", "--device", "OTA"]
|
||||
dashboard = ["--dashboard"] if CORE.dashboard else []
|
||||
return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"]
|
||||
|
||||
return run_multiple_configs(files, build_command)
|
||||
|
||||
@@ -1187,7 +1598,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path.write_text(new_raw, encoding="utf-8")
|
||||
|
||||
rc = run_external_process("esphome", "config", str(new_path))
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
@@ -1205,7 +1616,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
cli_args.insert(0, "--dashboard")
|
||||
|
||||
try:
|
||||
rc = run_external_process("esphome", *cli_args)
|
||||
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
|
||||
except KeyboardInterrupt:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
@@ -1237,11 +1648,11 @@ POST_CONFIG_ACTIONS = {
|
||||
"run": command_run,
|
||||
"clean": command_clean,
|
||||
"clean-mqtt": command_clean_mqtt,
|
||||
"mqtt-fingerprint": command_mqtt_fingerprint,
|
||||
"idedata": command_idedata,
|
||||
"rename": command_rename,
|
||||
"discover": command_discover,
|
||||
"analyze-memory": command_analyze_memory,
|
||||
"bundle": command_bundle,
|
||||
}
|
||||
|
||||
SIMPLE_CONFIG_ACTIONS = [
|
||||
@@ -1361,7 +1772,7 @@ def parse_args(argv):
|
||||
parser_upload.add_argument(
|
||||
"--device",
|
||||
action="append",
|
||||
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
|
||||
help=ARGUMENT_HELP_DEVICE,
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--upload_speed",
|
||||
@@ -1384,7 +1795,7 @@ def parse_args(argv):
|
||||
parser_logs.add_argument(
|
||||
"--device",
|
||||
action="append",
|
||||
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
|
||||
help=ARGUMENT_HELP_DEVICE,
|
||||
)
|
||||
parser_logs.add_argument(
|
||||
"--reset",
|
||||
@@ -1393,6 +1804,11 @@ def parse_args(argv):
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
parser_logs.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
|
||||
parser_discover = subparsers.add_parser(
|
||||
"discover",
|
||||
@@ -1414,7 +1830,7 @@ def parse_args(argv):
|
||||
parser_run.add_argument(
|
||||
"--device",
|
||||
action="append",
|
||||
help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.",
|
||||
help=ARGUMENT_HELP_DEVICE,
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--upload_speed",
|
||||
@@ -1451,13 +1867,6 @@ def parse_args(argv):
|
||||
)
|
||||
parser_wizard.add_argument("configuration", help="Your YAML configuration file.")
|
||||
|
||||
parser_fingerprint = subparsers.add_parser(
|
||||
"mqtt-fingerprint", help="Get the SSL fingerprint from a MQTT broker."
|
||||
)
|
||||
parser_fingerprint.add_argument(
|
||||
"configuration", help="Your YAML configuration file(s).", nargs="+"
|
||||
)
|
||||
|
||||
subparsers.add_parser("version", help="Print the ESPHome version and exit.")
|
||||
|
||||
parser_clean = subparsers.add_parser(
|
||||
@@ -1545,6 +1954,24 @@ def parse_args(argv):
|
||||
"configuration", help="Your YAML configuration file(s).", nargs="+"
|
||||
)
|
||||
|
||||
parser_bundle = subparsers.add_parser(
|
||||
"bundle",
|
||||
help="Create a self-contained config bundle for remote compilation.",
|
||||
)
|
||||
parser_bundle.add_argument(
|
||||
"configuration", help="Your YAML configuration file(s).", nargs="+"
|
||||
)
|
||||
parser_bundle.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
help="Output path for the bundle archive.",
|
||||
)
|
||||
parser_bundle.add_argument(
|
||||
"--list-only",
|
||||
help="List discovered files without creating the archive.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
# Keep backward compatibility with the old command line format of
|
||||
# esphome <config> <command>.
|
||||
#
|
||||
@@ -1610,7 +2037,7 @@ def run_esphome(argv):
|
||||
# argv[0] is the program path, skip it since we prefix with "esphome"
|
||||
def build_command(f):
|
||||
return (
|
||||
["esphome"]
|
||||
[*ESPHOME_COMMAND]
|
||||
+ [arg for arg in argv[1:] if arg not in args.configuration]
|
||||
+ [str(f)]
|
||||
)
|
||||
@@ -1623,6 +2050,16 @@ def run_esphome(argv):
|
||||
_LOGGER.warning("Skipping secrets file %s", conf_path)
|
||||
return 0
|
||||
|
||||
# Bundle support: if the configuration is a .esphomebundle, extract it
|
||||
# and rewrite conf_path to the extracted YAML config.
|
||||
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
|
||||
|
||||
if is_bundle_path(conf_path):
|
||||
_LOGGER.info("Extracting config bundle %s...", conf_path)
|
||||
conf_path = prepare_bundle_for_compile(conf_path)
|
||||
# Update the argument so downstream code sees the extracted path
|
||||
args.configuration[0] = str(conf_path)
|
||||
|
||||
CORE.config_path = conf_path
|
||||
CORE.dashboard = args.dashboard
|
||||
|
||||
|
||||
@@ -101,6 +101,17 @@ class AddressCache:
|
||||
"""Check if any cache entries exist."""
|
||||
return bool(self.mdns_cache or self.dns_cache)
|
||||
|
||||
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
|
||||
"""Store resolved mDNS addresses for ``hostname`` in the cache.
|
||||
|
||||
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
|
||||
this to avoid a second resolution round-trip during the upload path.
|
||||
No-op when ``addresses`` is empty.
|
||||
"""
|
||||
if not addresses:
|
||||
return
|
||||
self.mdns_cache[normalize_hostname(hostname)] = addresses
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(
|
||||
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Memory usage analyzer for ESPHome compiled binaries."""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -40,6 +40,15 @@ _READELF_SECTION_PATTERN = re.compile(
|
||||
r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)"
|
||||
)
|
||||
|
||||
# Regex for extracting call targets from objdump disassembly
|
||||
# Matches direct call instructions across architectures:
|
||||
# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12 <addr> <symbol>
|
||||
# ARM: bl/blx <addr> <symbol>
|
||||
# Captures the mangled symbol name inside angle brackets.
|
||||
_CALL_TARGET_PATTERN = re.compile(
|
||||
r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>"
|
||||
)
|
||||
|
||||
# Component category prefixes
|
||||
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
|
||||
_COMPONENT_PREFIX_EXTERNAL = "[external]"
|
||||
@@ -47,6 +56,10 @@ _COMPONENT_PREFIX_LIB = "[lib]"
|
||||
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
|
||||
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
|
||||
|
||||
# Placement new storage suffix (generated by codegen Pvariable)
|
||||
_PSTORAGE_SUFFIX = "__pstorage"
|
||||
|
||||
|
||||
# C++ namespace prefixes
|
||||
_NAMESPACE_ESPHOME = "esphome::"
|
||||
_NAMESPACE_STD = "std::"
|
||||
@@ -192,20 +205,27 @@ class MemoryAnalyzer:
|
||||
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
|
||||
# Library symbol mapping: symbol_name -> library_name
|
||||
self._lib_symbol_map: dict[str, str] = {}
|
||||
# Source file symbol mapping: symbol_name -> component_name
|
||||
# Used for extern "C" and other symbols without C++ namespace
|
||||
self._source_symbol_map: dict[str, str] = {}
|
||||
# Library dir to name mapping: "lib641" -> "espsoftwareserial",
|
||||
# "espressif__mdns" -> "mdns"
|
||||
self._lib_hash_to_name: dict[str, str] = {}
|
||||
# Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns"
|
||||
self._heuristic_to_lib: dict[str, str] = {}
|
||||
# Function call counts: mangled_name -> call_count
|
||||
self._function_call_counts: Counter[str] = Counter()
|
||||
|
||||
def analyze(self) -> dict[str, ComponentMemory]:
|
||||
"""Analyze the ELF file and return component memory usage."""
|
||||
self._parse_sections()
|
||||
self._parse_symbols()
|
||||
self._scan_libraries()
|
||||
self._scan_source_symbols()
|
||||
self._categorize_symbols()
|
||||
self._analyze_cswtch_symbols()
|
||||
self._analyze_sdk_libraries()
|
||||
self._analyze_function_calls()
|
||||
return dict(self.components)
|
||||
|
||||
def _parse_sections(self) -> None:
|
||||
@@ -316,6 +336,13 @@ class MemoryAnalyzer:
|
||||
# Demangle C++ names if needed
|
||||
demangled = self._demangle_symbol(symbol_name)
|
||||
|
||||
# Check for placement new storage symbols (generated by codegen)
|
||||
# Format: {component}__{id}__pstorage
|
||||
if demangled.endswith(_PSTORAGE_SUFFIX) and (
|
||||
component := self._match_pstorage_component(demangled)
|
||||
):
|
||||
return component
|
||||
|
||||
# Check for special component classes first (before namespace pattern)
|
||||
# This handles cases like esphome::ESPHomeOTAComponent which should map to ota
|
||||
if _NAMESPACE_ESPHOME in demangled:
|
||||
@@ -351,6 +378,11 @@ class MemoryAnalyzer:
|
||||
if lib_name := self._lib_symbol_map.get(symbol_name):
|
||||
return f"{_COMPONENT_PREFIX_LIB}{lib_name}"
|
||||
|
||||
# Check source file mapping (catches extern "C" functions in ESPHome sources)
|
||||
# Must be before heuristic patterns since source attribution is authoritative
|
||||
if component := self._source_symbol_map.get(symbol_name):
|
||||
return component
|
||||
|
||||
# Check against symbol patterns
|
||||
for component, patterns in SYMBOL_PATTERNS.items():
|
||||
if any(pattern in symbol_name for pattern in patterns):
|
||||
@@ -378,14 +410,33 @@ class MemoryAnalyzer:
|
||||
# Track uncategorized symbols for analysis
|
||||
return "other"
|
||||
|
||||
def _match_pstorage_component(self, symbol_name: str) -> str | None:
|
||||
"""Match a __pstorage symbol to its ESPHome component.
|
||||
|
||||
Symbol format: {component}__{id}__pstorage
|
||||
The component namespace is embedded by codegen before the double underscore.
|
||||
"""
|
||||
prefix = symbol_name[: -len(_PSTORAGE_SUFFIX)]
|
||||
# Extract component namespace before the first double underscore
|
||||
dunder_pos = prefix.find("__")
|
||||
if dunder_pos == -1:
|
||||
return None
|
||||
component_name = prefix[:dunder_pos]
|
||||
if component_name in get_esphome_components():
|
||||
return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
|
||||
if component_name in self.external_components:
|
||||
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
|
||||
return None
|
||||
|
||||
def _batch_demangle_symbols(self, symbols: list[str]) -> None:
|
||||
"""Batch demangle C++ symbol names for efficiency."""
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
_LOGGER.info("Demangling %d symbols", len(symbols))
|
||||
self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path)
|
||||
_LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache))
|
||||
demangled = batch_demangle(symbols, objdump_path=self.objdump_path)
|
||||
self._demangle_cache.update(demangled)
|
||||
_LOGGER.info("Successfully demangled %d symbols", len(demangled))
|
||||
|
||||
def _demangle_symbol(self, symbol: str) -> str:
|
||||
"""Get demangled C++ symbol name from cache."""
|
||||
@@ -640,6 +691,7 @@ class MemoryAnalyzer:
|
||||
return None
|
||||
|
||||
symbol_map: dict[str, str] = {}
|
||||
source_symbol_map: dict[str, str] = {}
|
||||
current_symbol: str | None = None
|
||||
section_prefixes = (".text.", ".rodata.", ".data.", ".bss.", ".literal.")
|
||||
|
||||
@@ -675,9 +727,18 @@ class MemoryAnalyzer:
|
||||
if dir_key in source_path:
|
||||
symbol_map[current_symbol] = lib_name
|
||||
break
|
||||
else:
|
||||
# Map ESPHome source files to components for extern "C"
|
||||
# and other symbols without C++ namespace
|
||||
component = self._source_file_to_component(source_path)
|
||||
if component.startswith(
|
||||
(_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
|
||||
):
|
||||
source_symbol_map[current_symbol] = component
|
||||
|
||||
current_symbol = None
|
||||
|
||||
self._source_symbol_map = source_symbol_map
|
||||
return symbol_map or None
|
||||
|
||||
def _scan_libraries(self) -> None:
|
||||
@@ -728,6 +789,112 @@ class MemoryAnalyzer:
|
||||
len(libraries),
|
||||
)
|
||||
|
||||
def _scan_source_symbols(self) -> None:
|
||||
"""Scan ESPHome source object files to map extern "C" symbols to components.
|
||||
|
||||
When no linker map file is available, this uses ``nm`` to scan ``.o`` files
|
||||
under ``src/esphome/`` and build a symbol-to-component mapping. This catches
|
||||
``extern "C"`` functions and other symbols that lack C++ namespace prefixes.
|
||||
|
||||
Skips scanning if ``_source_symbol_map`` was already populated by
|
||||
``_parse_map_file()``.
|
||||
"""
|
||||
if self._source_symbol_map or not self.nm_path:
|
||||
return
|
||||
|
||||
obj_dir = self._find_object_files_dir()
|
||||
if obj_dir is None:
|
||||
return
|
||||
|
||||
# Find ESPHome source object files
|
||||
esphome_src_dir = obj_dir / "src" / "esphome"
|
||||
if not esphome_src_dir.is_dir():
|
||||
return
|
||||
|
||||
obj_files = sorted(esphome_src_dir.rglob("*.o"))
|
||||
if not obj_files:
|
||||
return
|
||||
|
||||
# Run nm with --print-file-name to get file:symbol mapping
|
||||
result = run_tool(
|
||||
[self.nm_path, "--print-file-name", "-g", "--defined-only"]
|
||||
+ [str(f) for f in obj_files],
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("nm scan of source objects failed")
|
||||
return
|
||||
|
||||
self._source_symbol_map = self._parse_nm_source_output(result.stdout, obj_dir)
|
||||
if self._source_symbol_map:
|
||||
_LOGGER.info(
|
||||
"Built source symbol map from nm: %d symbols",
|
||||
len(self._source_symbol_map),
|
||||
)
|
||||
|
||||
def _parse_nm_source_output(self, output: str, base_dir: Path) -> dict[str, str]:
|
||||
"""Parse nm output to map non-namespaced symbols to ESPHome components.
|
||||
|
||||
Extracts global defined symbols from ESPHome source object files that
|
||||
don't use C++ namespacing (e.g. ``extern "C"`` functions).
|
||||
|
||||
Args:
|
||||
output: Raw stdout from ``nm --print-file-name -g --defined-only``
|
||||
or ``nm --print-file-name -S``.
|
||||
base_dir: Build directory for computing relative paths.
|
||||
|
||||
Returns:
|
||||
Dict mapping symbol names to component names.
|
||||
"""
|
||||
source_map: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
# Format: /path/to/file.o: addr type name
|
||||
# or: /path/to/file.o: addr size type name (with -S)
|
||||
colon_idx = line.rfind(".o:")
|
||||
if colon_idx == -1:
|
||||
continue
|
||||
|
||||
file_path = line[: colon_idx + 2]
|
||||
fields = line[colon_idx + 3 :].split()
|
||||
if len(fields) < 3:
|
||||
continue
|
||||
|
||||
# With -S flag, format is: addr size type name
|
||||
# Without -S flag: addr type name
|
||||
# type is a single char; size is hex digits
|
||||
# Detect by checking if fields[1] is a single uppercase letter (type)
|
||||
if len(fields[1]) == 1 and fields[1].isalpha():
|
||||
# addr type name
|
||||
sym_type = fields[1]
|
||||
symbol_name = fields[2]
|
||||
elif len(fields) >= 4:
|
||||
# addr size type name
|
||||
sym_type = fields[2]
|
||||
symbol_name = fields[3]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Only global defined symbols (uppercase type)
|
||||
if not sym_type.isupper() or sym_type == "U":
|
||||
continue
|
||||
|
||||
# Skip symbols already in esphome:: namespace
|
||||
if symbol_name.startswith("_ZN7esphome"):
|
||||
continue
|
||||
|
||||
# Make path relative to base_dir for _source_file_to_component
|
||||
try:
|
||||
rel_path = str(Path(file_path).relative_to(base_dir))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
component = self._source_file_to_component(rel_path)
|
||||
if component.startswith(
|
||||
(_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
|
||||
):
|
||||
source_map[symbol_name] = component
|
||||
|
||||
return source_map
|
||||
|
||||
def _find_object_files_dir(self) -> Path | None:
|
||||
"""Find the directory containing object files for this build.
|
||||
|
||||
@@ -1011,6 +1178,43 @@ class MemoryAnalyzer:
|
||||
total_size,
|
||||
)
|
||||
|
||||
def _analyze_function_calls(self) -> None:
|
||||
"""Count function call sites by parsing disassembly output.
|
||||
|
||||
Parses direct call instructions (call0/call8/bl/blx) from objdump -d
|
||||
to count how many times each function is called. This helps identify
|
||||
inlining candidates — frequently called small functions benefit most
|
||||
from inlining.
|
||||
"""
|
||||
result = run_tool(
|
||||
[self.objdump_path, "-d", str(self.elf_path)],
|
||||
timeout=60,
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("Failed to disassemble ELF for function call analysis")
|
||||
return
|
||||
|
||||
self._function_call_counts = Counter(
|
||||
match.group(1)
|
||||
for line in result.stdout.splitlines()
|
||||
if (match := _CALL_TARGET_PATTERN.search(line))
|
||||
)
|
||||
|
||||
# Demangle any call targets not already in the cache
|
||||
missing = [
|
||||
name
|
||||
for name in self._function_call_counts
|
||||
if name not in self._demangle_cache
|
||||
]
|
||||
if missing:
|
||||
self._batch_demangle_symbols(missing)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Function call analysis: %d unique targets, %d total calls",
|
||||
len(self._function_call_counts),
|
||||
sum(self._function_call_counts.values()),
|
||||
)
|
||||
|
||||
def get_unattributed_ram(self) -> tuple[int, int, int]:
|
||||
"""Get unattributed RAM sizes (SDK/framework overhead).
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from . import (
|
||||
_COMPONENT_PREFIX_ESPHOME,
|
||||
_COMPONENT_PREFIX_EXTERNAL,
|
||||
_COMPONENT_PREFIX_LIB,
|
||||
_PSTORAGE_SUFFIX,
|
||||
RAM_SECTIONS,
|
||||
MemoryAnalyzer,
|
||||
)
|
||||
@@ -23,6 +24,17 @@ if TYPE_CHECKING:
|
||||
from . import ComponentMemory
|
||||
|
||||
|
||||
def _format_pstorage_name(name: str) -> str:
|
||||
"""Format a __pstorage symbol as 'storage for {id}'."""
|
||||
if not name.endswith(_PSTORAGE_SUFFIX):
|
||||
return name
|
||||
prefix = name[: -len(_PSTORAGE_SUFFIX)]
|
||||
# Strip component namespace prefix: {component}__{id} -> {id}
|
||||
dunder_pos = prefix.find("__")
|
||||
var_id = prefix[dunder_pos + 2 :] if dunder_pos != -1 else prefix
|
||||
return f"storage for {var_id}"
|
||||
|
||||
|
||||
class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
"""Memory analyzer with CLI-specific report generation."""
|
||||
|
||||
@@ -148,11 +160,14 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
If section is one of the RAM sections (.data or .bss), a label like
|
||||
" [data]" or " [bss]" is appended. For non-RAM sections or when
|
||||
section is None, no section label is added.
|
||||
|
||||
Placement new storage symbols are formatted as "storage for {id}".
|
||||
"""
|
||||
display_name = _format_pstorage_name(demangled)
|
||||
section_label = ""
|
||||
if section in RAM_SECTIONS:
|
||||
section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss]
|
||||
return f"{demangled} ({size:,} B){section_label}"
|
||||
return f"{display_name} ({size:,} B){section_label}"
|
||||
|
||||
def _add_top_symbols(self, lines: list[str]) -> None:
|
||||
"""Add a section showing the top largest symbols in the binary."""
|
||||
@@ -175,11 +190,13 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
for i, (_, demangled, size, section, component) in enumerate(top_symbols):
|
||||
# Format section label
|
||||
section_label = f"[{section[1:]}]" if section else ""
|
||||
# Truncate demangled name if too long
|
||||
# Format storage symbols readably
|
||||
display_name = _format_pstorage_name(demangled)
|
||||
# Truncate if too long
|
||||
demangled_display = (
|
||||
f"{demangled[:truncate_limit]}..."
|
||||
if len(demangled) > self.COL_TOP_SYMBOL_NAME
|
||||
else demangled
|
||||
f"{display_name[:truncate_limit]}..."
|
||||
if len(display_name) > self.COL_TOP_SYMBOL_NAME
|
||||
else display_name
|
||||
)
|
||||
lines.append(
|
||||
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
|
||||
@@ -231,6 +248,110 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(f" {size:>6,} B {sym_name}")
|
||||
lines.append("")
|
||||
|
||||
# Number of top called functions to show
|
||||
TOP_CALLS_LIMIT: int = 50
|
||||
# Number of inlining candidates to show
|
||||
INLINE_CANDIDATES_LIMIT: int = 25
|
||||
# Maximum function size in bytes to consider for inlining
|
||||
INLINE_SIZE_THRESHOLD: int = 16
|
||||
|
||||
def _build_symbol_sizes(self) -> dict[str, int]:
|
||||
"""Build a size lookup from all component symbols: mangled_name -> size."""
|
||||
return {
|
||||
symbol: size
|
||||
for symbols in self._component_symbols.values()
|
||||
for symbol, _, size, _ in symbols
|
||||
}
|
||||
|
||||
def _format_call_row(
|
||||
self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int]
|
||||
) -> str:
|
||||
"""Format a single row for call frequency tables."""
|
||||
demangled = self._demangle_cache.get(mangled, mangled)
|
||||
if len(demangled) > 80:
|
||||
demangled = f"{demangled[:77]}..."
|
||||
size = symbol_sizes.get(mangled)
|
||||
size_str = f"{size:>5,} B" if size is not None else " ?"
|
||||
return f"{index:>3} {count:>5} {size_str} {demangled}"
|
||||
|
||||
def _add_call_table_header(self, lines: list[str]) -> None:
|
||||
"""Add the header row for call frequency tables."""
|
||||
lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function")
|
||||
lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}")
|
||||
|
||||
def _add_function_call_analysis(self, lines: list[str]) -> None:
|
||||
"""Add function call frequency analysis section.
|
||||
|
||||
Shows the most frequently called functions by call site count.
|
||||
"""
|
||||
self._add_section_header(lines, "Top Called Functions")
|
||||
|
||||
symbol_sizes = self._build_symbol_sizes()
|
||||
|
||||
# Sort by call count descending
|
||||
sorted_calls = sorted(
|
||||
self._function_call_counts.items(), key=lambda x: x[1], reverse=True
|
||||
)
|
||||
|
||||
self._add_call_table_header(lines)
|
||||
|
||||
for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]):
|
||||
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
|
||||
|
||||
total_calls = sum(self._function_call_counts.values())
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Total: {len(self._function_call_counts)} unique targets, "
|
||||
f"{total_calls:,} call sites"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
def _add_inline_candidates(self, lines: list[str]) -> None:
|
||||
"""Add inlining candidates section.
|
||||
|
||||
Shows frequently called functions that are small enough to benefit
|
||||
from inlining (< 16 bytes). These are the best candidates for
|
||||
reducing call overhead.
|
||||
"""
|
||||
self._add_section_header(
|
||||
lines,
|
||||
f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)",
|
||||
)
|
||||
|
||||
symbol_sizes = self._build_symbol_sizes()
|
||||
|
||||
# Filter to small functions with known size, sort by call count
|
||||
candidates = sorted(
|
||||
(
|
||||
(mangled, count)
|
||||
for mangled, count in self._function_call_counts.items()
|
||||
if mangled in symbol_sizes
|
||||
and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD
|
||||
),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
lines.append("No candidates found.")
|
||||
lines.append("")
|
||||
return
|
||||
|
||||
self._add_call_table_header(lines)
|
||||
|
||||
for i, (mangled, count) in enumerate(
|
||||
candidates[: self.INLINE_CANDIDATES_LIMIT]
|
||||
):
|
||||
lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} "
|
||||
f"of {len(candidates)} functions under "
|
||||
f"{self.INLINE_SIZE_THRESHOLD} B"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
def generate_report(self, detailed: bool = False) -> str:
|
||||
"""Generate a formatted memory report."""
|
||||
components = sorted(
|
||||
@@ -469,15 +590,16 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(f"Total size: {comp_mem.flash_total:,} B")
|
||||
lines.append("")
|
||||
|
||||
# Show all symbols above threshold for better visibility
|
||||
# Show symbols above threshold, always include storage symbols
|
||||
large_symbols = [
|
||||
(sym, dem, size, sec)
|
||||
for sym, dem, size, sec in sorted_symbols
|
||||
if size > self.SYMBOL_SIZE_THRESHOLD
|
||||
or dem.endswith(_PSTORAGE_SUFFIX)
|
||||
]
|
||||
|
||||
lines.append(
|
||||
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):"
|
||||
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
|
||||
)
|
||||
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
|
||||
lines.append(
|
||||
@@ -500,7 +622,10 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
# Sort by size descending
|
||||
sorted_ram_syms = sorted(ram_syms, key=lambda x: x[2], reverse=True)
|
||||
large_ram_syms = [
|
||||
s for s in sorted_ram_syms if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
|
||||
s
|
||||
for s in sorted_ram_syms
|
||||
if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
|
||||
or s[1].endswith(_PSTORAGE_SUFFIX)
|
||||
]
|
||||
|
||||
lines.append(f"{name} ({mem.ram_total:,} B total RAM):")
|
||||
@@ -518,13 +643,14 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
for symbol, demangled, size, section in large_ram_syms[:10]:
|
||||
# Format section label consistently by stripping leading dot
|
||||
section_label = section.lstrip(".") if section else ""
|
||||
display_name = _format_pstorage_name(demangled)
|
||||
# Add ellipsis if name is truncated
|
||||
demangled_display = (
|
||||
f"{demangled[:70]}..." if len(demangled) > 70 else demangled
|
||||
)
|
||||
lines.append(
|
||||
f" {size:>6,} B [{section_label}] {demangled_display}"
|
||||
display_name = (
|
||||
f"{display_name[:70]}..."
|
||||
if len(display_name) > 70
|
||||
else display_name
|
||||
)
|
||||
lines.append(f" {size:>6,} B [{section_label}] {display_name}")
|
||||
if len(large_ram_syms) > 10:
|
||||
lines.append(f" ... and {len(large_ram_syms) - 10} more")
|
||||
lines.append("")
|
||||
@@ -533,6 +659,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
if self._cswtch_symbols:
|
||||
self._add_cswtch_analysis(lines)
|
||||
|
||||
# Function call frequency analysis
|
||||
if self._function_call_counts:
|
||||
self._add_function_call_analysis(lines)
|
||||
self._add_inline_candidates(lines)
|
||||
|
||||
lines.append(
|
||||
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
|
||||
)
|
||||
|
||||
@@ -256,7 +256,7 @@ SYMBOL_PATTERNS = {
|
||||
"ipv6_stack": ["nd6_", "ip6_", "mld6_", "icmp6_", "icmp6_input"],
|
||||
# Order matters! More specific categories must come before general ones.
|
||||
# mdns must come before bluetooth to avoid "_mdns_disable_pcb" matching "ble_" pattern
|
||||
"mdns_lib": ["mdns"],
|
||||
"mdns_lib": ["mdns", "packet$"],
|
||||
# memory_mgmt must come before wifi_stack to catch mmu_hal_* symbols
|
||||
"memory_mgmt": [
|
||||
"mem_",
|
||||
@@ -408,7 +408,6 @@ SYMBOL_PATTERNS = {
|
||||
],
|
||||
"arduino_core": [
|
||||
"pinMode",
|
||||
"resetPins",
|
||||
"millis",
|
||||
"micros",
|
||||
"delay(", # More specific - Arduino delay function with parenthesis
|
||||
@@ -794,7 +793,6 @@ SYMBOL_PATTERNS = {
|
||||
"s_dp",
|
||||
"s_ni",
|
||||
"s_reg_dump",
|
||||
"packet$",
|
||||
"d_mult_table",
|
||||
"K",
|
||||
"fcstab",
|
||||
|
||||
56
esphome/async_thread.py
Normal file
56
esphome/async_thread.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Helpers for running an async coroutine from sync code via a daemon thread.
|
||||
|
||||
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
|
||||
cycle finishes, which can add hundreds of milliseconds before the caller
|
||||
receives the result. Running the loop in a daemon thread lets the caller
|
||||
observe the result as soon as the coroutine completes while cleanup finishes
|
||||
in the background.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import threading
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
``exception`` so ``event`` is always set — this prevents callers waiting
|
||||
on ``event`` from hanging forever when the coroutine crashes.
|
||||
|
||||
Typical usage::
|
||||
|
||||
runner = AsyncThreadRunner(lambda: my_coro(arg))
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout=5.0):
|
||||
... # timed out
|
||||
if runner.exception is not None:
|
||||
raise runner.exception
|
||||
result = runner.result
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self._coro_factory = coro_factory
|
||||
self.result: _T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self._runner())
|
||||
@@ -1,3 +1,6 @@
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -57,8 +60,42 @@ def maybe_conf(conf, *validators):
|
||||
return validate
|
||||
|
||||
|
||||
def register_action(name: str, action_type: MockObjClass, schema: cv.Schema):
|
||||
return ACTION_REGISTRY.register(name, action_type, schema)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_action(
|
||||
name: str,
|
||||
action_type: MockObjClass,
|
||||
schema: cv.Schema,
|
||||
*,
|
||||
synchronous: bool | None = None,
|
||||
):
|
||||
"""Register an action type.
|
||||
|
||||
All callers must pass ``synchronous`` explicitly.
|
||||
|
||||
``synchronous=True`` — the action never defers ``play_next_()`` to a
|
||||
later point (callback, timer, or ``loop()``). Trigger arguments are
|
||||
only used during the initial call, so string args can use non-owning
|
||||
StringRef for zero-copy access.
|
||||
|
||||
``synchronous=False`` — the action defers ``play_next_()`` via a
|
||||
callback, timer, or ``Component::loop()``. Trigger arguments must
|
||||
outlive the initial call, so string args use owning std::string to
|
||||
prevent dangling references.
|
||||
"""
|
||||
if synchronous is None:
|
||||
_LOGGER.warning(
|
||||
"register_action('%s', ...) is missing the synchronous= parameter. "
|
||||
"Defaulting to synchronous=False (safe but prevents StringRef "
|
||||
"optimization). Check the C++ class: use synchronous=False if "
|
||||
"play_next_() is deferred to a callback, timer, or loop(); "
|
||||
"use synchronous=True if play_next_() always runs before the "
|
||||
"initial play/play_complex call returns",
|
||||
name,
|
||||
)
|
||||
synchronous = False
|
||||
return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous)
|
||||
|
||||
|
||||
def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema):
|
||||
@@ -101,6 +138,9 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action)
|
||||
SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action)
|
||||
ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action)
|
||||
Automation = cg.esphome_ns.class_("Automation")
|
||||
TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder")
|
||||
TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder")
|
||||
TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder")
|
||||
|
||||
LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition)
|
||||
StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition)
|
||||
@@ -159,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
|
||||
return cv.Schema([schema])(value)
|
||||
except cv.Invalid as err2:
|
||||
if "extra keys not allowed" in str(err2) and len(err2.path) == 2:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise err
|
||||
raise err from None
|
||||
if "Unable to find action" in str(err):
|
||||
raise err2
|
||||
raise cv.MultipleInvalid([err, err2])
|
||||
raise err2 from None
|
||||
raise cv.MultipleInvalid([err, err2]) from None
|
||||
elif isinstance(value, dict):
|
||||
if CONF_THEN in value:
|
||||
return [schema(value)]
|
||||
@@ -211,7 +250,9 @@ async def and_condition_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(condition_id, template_arg, conditions)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
@@ -222,7 +263,9 @@ async def or_condition_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(condition_id, template_arg, conditions)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("all", AndCondition, validate_condition_list)
|
||||
@@ -233,7 +276,9 @@ async def all_condition_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(condition_id, template_arg, conditions)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("any", OrCondition, validate_condition_list)
|
||||
@@ -244,7 +289,9 @@ async def any_condition_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(condition_id, template_arg, conditions)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("not", NotCondition, validate_potentially_and_condition)
|
||||
@@ -266,7 +313,9 @@ async def xor_condition_to_code(
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(condition_id, template_arg, conditions)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
|
||||
@@ -335,7 +384,10 @@ async def component_is_idle_condition_to_code(
|
||||
|
||||
|
||||
@register_action(
|
||||
"delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds)
|
||||
"delay",
|
||||
DelayAction,
|
||||
cv.templatable(cv.positive_time_period_milliseconds),
|
||||
synchronous=False,
|
||||
)
|
||||
async def delay_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -366,6 +418,7 @@ async def delay_action_to_code(
|
||||
cv.has_at_least_one_key(CONF_THEN, CONF_ELSE),
|
||||
cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL),
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def if_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -373,13 +426,16 @@ async def if_action_to_code(
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
has_else = CONF_ELSE in config
|
||||
# Prepend HasElse bool to template arguments: IfAction<HasElse, Ts...>
|
||||
if_template_arg = cg.TemplateArguments(has_else, *template_arg)
|
||||
cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION))
|
||||
condition = await build_condition(config[cond_conf], template_arg, args)
|
||||
var = cg.new_Pvariable(action_id, template_arg, condition)
|
||||
var = cg.new_Pvariable(action_id, if_template_arg, condition)
|
||||
if CONF_THEN in config:
|
||||
actions = await build_action_list(config[CONF_THEN], template_arg, args)
|
||||
cg.add(var.add_then(actions))
|
||||
if CONF_ELSE in config:
|
||||
if has_else:
|
||||
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
|
||||
cg.add(var.add_else(actions))
|
||||
return var
|
||||
@@ -394,6 +450,7 @@ async def if_action_to_code(
|
||||
cv.Required(CONF_THEN): validate_action_list,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def while_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -417,6 +474,7 @@ async def while_action_to_code(
|
||||
cv.Required(CONF_THEN): validate_action_list,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def repeat_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -445,7 +503,7 @@ _validate_wait_until = cv.maybe_simple_value(
|
||||
)
|
||||
|
||||
|
||||
@register_action("wait_until", WaitUntilAction, _validate_wait_until)
|
||||
@register_action("wait_until", WaitUntilAction, _validate_wait_until, synchronous=False)
|
||||
async def wait_until_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
@@ -461,7 +519,12 @@ async def wait_until_action_to_code(
|
||||
return var
|
||||
|
||||
|
||||
@register_action("lambda", LambdaAction, cv.lambda_)
|
||||
# Lambda executes user C++ inline and returns — synchronous by execution model.
|
||||
# User code could theoretically store the StringRef for deferred use, but StringRef
|
||||
# is a view type and storing views beyond their scope is always unsafe regardless
|
||||
# of this optimization. Marking non-synchronous would disable StringRef for nearly
|
||||
# all user services since most use lambda.
|
||||
@register_action("lambda", LambdaAction, cv.lambda_, synchronous=True)
|
||||
async def lambda_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
@@ -480,6 +543,7 @@ async def lambda_action_to_code(
|
||||
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_update_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -499,6 +563,7 @@ async def component_update_action_to_code(
|
||||
cv.Required(CONF_ID): cv.use_id(cg.PollingComponent),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_suspend_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -521,6 +586,7 @@ async def component_suspend_action_to_code(
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def component_resume_action_to_code(
|
||||
config: ConfigType,
|
||||
@@ -578,6 +644,27 @@ async def build_condition_list(
|
||||
return conditions
|
||||
|
||||
|
||||
def has_non_synchronous_actions(actions: ConfigType) -> bool:
|
||||
"""Check if a validated action list contains any non-synchronous actions.
|
||||
|
||||
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
|
||||
trigger args for later execution, making non-owning types like StringRef
|
||||
unsafe.
|
||||
"""
|
||||
if isinstance(actions, list):
|
||||
return any(has_non_synchronous_actions(item) for item in actions)
|
||||
if isinstance(actions, dict):
|
||||
for key in actions:
|
||||
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
|
||||
return True
|
||||
return any(
|
||||
has_non_synchronous_actions(v)
|
||||
for v in actions.values()
|
||||
if isinstance(v, (list, dict))
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def build_automation(
|
||||
trigger: MockObj, args: TemplateArgsType, config: ConfigType
|
||||
) -> MockObj:
|
||||
@@ -587,3 +674,76 @@ async def build_automation(
|
||||
actions = await build_action_list(config[CONF_THEN], templ, args)
|
||||
cg.add(obj.add_actions(actions))
|
||||
return obj
|
||||
|
||||
|
||||
async def build_callback_automation(
|
||||
parent: MockObj,
|
||||
callback_method: str,
|
||||
args: TemplateArgsType,
|
||||
config: ConfigType,
|
||||
forwarder: MockObj | MockObjClass | None = None,
|
||||
) -> None:
|
||||
"""Build an Automation and register it as a callback on the parent.
|
||||
|
||||
Eliminates the need for a Trigger wrapper object by registering the
|
||||
automation's trigger() directly as a callback on the parent component.
|
||||
|
||||
Uses template forwarder structs so the compiler deduplicates the operator()
|
||||
body across all call sites with the same signature. The forwarder must be
|
||||
pointer-sized (single Automation* field) to fit inline in Callback::ctx_
|
||||
and avoid heap allocation.
|
||||
|
||||
:param parent: The component object (e.g., button, sensor).
|
||||
:param callback_method: Name of the callback method (e.g., "add_on_press_callback").
|
||||
:param args: Automation template args as list of (type, name) tuples.
|
||||
:param config: The automation config dict.
|
||||
:param forwarder: Optional forwarder type to use instead of the default
|
||||
TriggerForwarder<Ts...>. Pass any struct type whose aggregate init takes
|
||||
a single Automation pointer (e.g., TriggerOnTrueForwarder).
|
||||
"""
|
||||
arg_types = [arg[0] for arg in args]
|
||||
templ = cg.TemplateArguments(*arg_types)
|
||||
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ)
|
||||
actions = await build_action_list(config[CONF_THEN], templ, args)
|
||||
cg.add(obj.add_actions(actions))
|
||||
# Use template forwarder structs for deduplication. The compiler generates
|
||||
# one operator() per forwarder type; different automation pointers are just
|
||||
# data in the struct.
|
||||
if forwarder is None:
|
||||
forwarder = TriggerForwarder.template(templ)
|
||||
# RawExpression for aggregate init — both forwarder and obj are codegen
|
||||
# MockObjs (not user input), and there's no Expression type for positional
|
||||
# aggregate initialization (StructInitializer uses named fields).
|
||||
cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallbackAutomation:
|
||||
"""A single callback automation entry for build_callback_automations."""
|
||||
|
||||
conf_key: str
|
||||
callback_method: str
|
||||
args: TemplateArgsType = field(default_factory=list)
|
||||
forwarder: MockObj | MockObjClass | None = None
|
||||
|
||||
|
||||
async def build_callback_automations(
|
||||
parent: MockObj,
|
||||
config: ConfigType,
|
||||
entries: tuple[CallbackAutomation, ...],
|
||||
) -> None:
|
||||
"""Build multiple callback automations from a tuple of entries.
|
||||
|
||||
:param parent: The component object (e.g., button, sensor).
|
||||
:param config: The full component config dict.
|
||||
:param entries: Tuple of CallbackAutomation entries to process.
|
||||
"""
|
||||
for entry in entries:
|
||||
for conf in config.get(entry.conf_key, []):
|
||||
await build_callback_automation(
|
||||
parent,
|
||||
entry.callback_method,
|
||||
entry.args,
|
||||
conf,
|
||||
forwarder=entry.forwarder,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,13 @@ def get_project_cmakelists() -> str:
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
|
||||
# Extract compile definitions from build flags (-DXXX -> XXX)
|
||||
compile_defs = [flag for flag in CORE.build_flags if flag.startswith("-D")]
|
||||
extra_compile_options = "\n".join(
|
||||
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
|
||||
for compile_def in compile_defs
|
||||
)
|
||||
|
||||
return f"""\
|
||||
# Auto-generated by ESPHome
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
@@ -61,6 +68,9 @@ set(IDF_TARGET {idf_target})
|
||||
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||
|
||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
project({CORE.name})
|
||||
"""
|
||||
|
||||
@@ -70,21 +80,17 @@ def get_component_cmakelists(minimal: bool = False) -> str:
|
||||
idf_requires = [] if minimal else (get_available_components() or [])
|
||||
requires_str = " ".join(idf_requires)
|
||||
|
||||
# Extract compile definitions from build flags (-DXXX -> XXX)
|
||||
compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")]
|
||||
compile_defs_str = "\n ".join(compile_defs) if compile_defs else ""
|
||||
|
||||
# Extract compile options (-W flags, excluding linker flags)
|
||||
compile_opts = [
|
||||
flag
|
||||
for flag in CORE.build_flags
|
||||
if flag.startswith("-W") and not flag.startswith("-Wl,")
|
||||
]
|
||||
compile_opts_str = "\n ".join(compile_opts) if compile_opts else ""
|
||||
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
|
||||
|
||||
# Extract linker options (-Wl, flags)
|
||||
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
|
||||
link_opts_str = "\n ".join(link_opts) if link_opts else ""
|
||||
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
|
||||
|
||||
return f"""\
|
||||
# Auto-generated by ESPHome
|
||||
@@ -104,11 +110,6 @@ idf_component_register(
|
||||
# Apply C++ standard
|
||||
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
|
||||
|
||||
# ESPHome compile definitions
|
||||
target_compile_definitions(${{COMPONENT_LIB}} PUBLIC
|
||||
{compile_defs_str}
|
||||
)
|
||||
|
||||
# ESPHome compile options
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{compile_opts_str}
|
||||
|
||||
765
esphome/bundle.py
Normal file
765
esphome/bundle.py
Normal file
@@ -0,0 +1,765 @@
|
||||
"""Config bundle creator and extractor for ESPHome.
|
||||
|
||||
A bundle is a self-contained .tar.gz archive containing a YAML config
|
||||
and every local file it depends on. Bundles can be created from a config
|
||||
and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
from typing import Any
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_INCLUDES,
|
||||
CONF_INCLUDES_C,
|
||||
CONF_PATH,
|
||||
CONF_SOURCE,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB
|
||||
|
||||
# Directories preserved across bundle extractions (build caches)
|
||||
_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio")
|
||||
_BUNDLE_STAGING_DIR = ".bundle_staging"
|
||||
|
||||
|
||||
class ManifestKey(StrEnum):
|
||||
"""Keys used in bundle manifest.json."""
|
||||
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
|
||||
# String prefixes that are never local file paths
|
||||
_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<")
|
||||
|
||||
# File extensions recognized when resolving relative path strings.
|
||||
# A relative string with one of these extensions is resolved against the
|
||||
# config directory and included if the file exists.
|
||||
_KNOWN_FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
# Fonts
|
||||
".ttf",
|
||||
".otf",
|
||||
".woff",
|
||||
".woff2",
|
||||
".pcf",
|
||||
".bdf",
|
||||
# Images
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".bmp",
|
||||
".gif",
|
||||
".svg",
|
||||
".ico",
|
||||
".webp",
|
||||
# Certificates
|
||||
".pem",
|
||||
".crt",
|
||||
".key",
|
||||
".der",
|
||||
".p12",
|
||||
".pfx",
|
||||
# C/C++ includes
|
||||
".h",
|
||||
".hpp",
|
||||
".c",
|
||||
".cpp",
|
||||
".ino",
|
||||
# Web assets
|
||||
".css",
|
||||
".js",
|
||||
".html",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Matches !secret references in YAML text. This is intentionally a simple
|
||||
# regex scan rather than a YAML parse — it may match inside comments or
|
||||
# multi-line strings, which is the conservative direction (include more
|
||||
# secrets rather than fewer).
|
||||
_SECRET_RE = re.compile(r"!secret\s+(\S+)")
|
||||
|
||||
|
||||
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
"""Scan YAML files for ``!secret <key>`` references."""
|
||||
keys: set[str] = set()
|
||||
for fpath in yaml_files:
|
||||
try:
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
for match in _SECRET_RE.finditer(text):
|
||||
keys.add(match.group(1))
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
|
||||
path: str # Relative path inside the archive
|
||||
source: Path # Absolute path on disk
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleResult:
|
||||
"""Result of creating a bundle."""
|
||||
|
||||
data: bytes
|
||||
manifest: dict[str, Any]
|
||||
files: list[BundleFile]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleManifest:
|
||||
"""Parsed and validated bundle manifest."""
|
||||
|
||||
manifest_version: int
|
||||
esphome_version: str
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
"""Creates a self-contained bundle from an ESPHome config."""
|
||||
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self._config = config
|
||||
self._config_dir = Path(CORE.config_dir).resolve()
|
||||
self._config_path = Path(CORE.config_path).resolve()
|
||||
self._files: list[BundleFile] = []
|
||||
self._seen_paths: set[Path] = set()
|
||||
self._secrets_paths: set[Path] = set()
|
||||
|
||||
def discover_files(self) -> list[BundleFile]:
|
||||
"""Discover all files needed for the bundle."""
|
||||
self._files = []
|
||||
self._seen_paths = set()
|
||||
self._secrets_paths = set()
|
||||
|
||||
# The main config file
|
||||
self._add_file(self._config_path)
|
||||
|
||||
# Phase 1: YAML includes (tracked during config loading)
|
||||
self._discover_yaml_includes()
|
||||
|
||||
# Phase 2: Component-referenced files from validated config
|
||||
self._discover_component_files()
|
||||
|
||||
return list(self._files)
|
||||
|
||||
def create_bundle(self) -> BundleResult:
|
||||
"""Create the bundle archive."""
|
||||
files = self.discover_files()
|
||||
|
||||
# Determine which secret keys are actually referenced by the
|
||||
# bundled YAML files so we only ship those, not the entire
|
||||
# secrets.yaml which may contain secrets for other devices.
|
||||
yaml_sources = [
|
||||
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
|
||||
]
|
||||
used_secret_keys = _find_used_secret_keys(yaml_sources)
|
||||
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
|
||||
|
||||
has_secrets = bool(filtered_secrets)
|
||||
if has_secrets:
|
||||
_LOGGER.warning(
|
||||
"Bundle contains secrets (e.g. Wi-Fi passwords). "
|
||||
"Do not share it with untrusted parties."
|
||||
)
|
||||
|
||||
manifest = self._build_manifest(files, has_secrets=has_secrets)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
# Add manifest first
|
||||
manifest_data = json.dumps(manifest, indent=2).encode("utf-8")
|
||||
_add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data)
|
||||
|
||||
# Add filtered secrets files
|
||||
for rel_path, data in sorted(filtered_secrets.items()):
|
||||
_add_bytes_to_tar(tar, rel_path, data)
|
||||
|
||||
# Add files in sorted order for determinism, skipping secrets
|
||||
# files which were already added above with filtered content
|
||||
for bf in sorted(files, key=lambda f: f.path):
|
||||
if bf.source in self._secrets_paths:
|
||||
continue
|
||||
self._add_to_tar(tar, bf)
|
||||
|
||||
return BundleResult(data=buf.getvalue(), manifest=manifest, files=files)
|
||||
|
||||
def _add_file(self, abs_path: Path) -> bool:
|
||||
"""Add a file to the bundle. Returns False if already added."""
|
||||
abs_path = abs_path.resolve()
|
||||
if abs_path in self._seen_paths:
|
||||
return False
|
||||
if not abs_path.is_file():
|
||||
_LOGGER.warning("Bundle: skipping missing file %s", abs_path)
|
||||
return False
|
||||
|
||||
rel_path = self._relative_to_config_dir(abs_path)
|
||||
if rel_path is None:
|
||||
_LOGGER.warning(
|
||||
"Bundle: skipping file outside config directory: %s", abs_path
|
||||
)
|
||||
return False
|
||||
|
||||
self._seen_paths.add(abs_path)
|
||||
self._files.append(BundleFile(path=rel_path, source=abs_path))
|
||||
return True
|
||||
|
||||
def _add_directory(self, abs_path: Path) -> None:
|
||||
"""Recursively add all files in a directory."""
|
||||
abs_path = abs_path.resolve()
|
||||
if not abs_path.is_dir():
|
||||
_LOGGER.warning("Bundle: skipping missing directory %s", abs_path)
|
||||
return
|
||||
for child in sorted(abs_path.rglob("*")):
|
||||
if child.is_file() and "__pycache__" not in child.parts:
|
||||
self._add_file(child)
|
||||
|
||||
def _relative_to_config_dir(self, abs_path: Path) -> str | None:
|
||||
"""Get a path relative to the config directory. Returns None if outside.
|
||||
|
||||
Always uses forward slashes for consistency in tar archives.
|
||||
"""
|
||||
try:
|
||||
return abs_path.relative_to(self._config_dir).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _discover_yaml_includes(self) -> None:
|
||||
"""Discover YAML files loaded during config parsing.
|
||||
|
||||
Deliberately uses a fresh re-parse and force-loads every deferred
|
||||
``IncludeFile`` to include *all* potentially-reachable includes,
|
||||
even branches not selected by the local substitutions. Bundles are
|
||||
meant to be compiled on another system where command-line
|
||||
substitution overrides may choose a different branch — e.g.
|
||||
``!include network/${eth_model}/config.yaml`` must ship every
|
||||
candidate so the remote build can pick any one.
|
||||
|
||||
Entries with unresolved substitution variables in the filename
|
||||
path are skipped with a warning (they cannot be resolved without
|
||||
the substitution pass).
|
||||
|
||||
Secrets files are tracked separately so we can filter them to
|
||||
only include the keys this config actually references.
|
||||
"""
|
||||
# Must be a fresh parse: IncludeFile.load() caches its result in
|
||||
# _content, and we discover files by listening for loader calls. On
|
||||
# an already-parsed tree the cache is populated, .load() returns
|
||||
# without calling the loader, the listener never fires, and the
|
||||
# referenced files would be silently dropped from the bundle.
|
||||
with yaml_util.track_yaml_loads() as loaded_files:
|
||||
try:
|
||||
data = yaml_util.load_yaml(self._config_path)
|
||||
except EsphomeError:
|
||||
_LOGGER.debug(
|
||||
"Bundle: re-loading YAML for include discovery failed, "
|
||||
"proceeding with partial file list"
|
||||
)
|
||||
else:
|
||||
_force_load_include_files(data)
|
||||
|
||||
for fpath in loaded_files:
|
||||
if fpath == self._config_path.resolve():
|
||||
continue # Already added as config
|
||||
if fpath.name in const.SECRETS_FILES:
|
||||
self._secrets_paths.add(fpath)
|
||||
self._add_file(fpath)
|
||||
|
||||
def _discover_component_files(self) -> None:
|
||||
"""Walk the validated config for file references.
|
||||
|
||||
Uses a generic recursive walk to find file paths instead of
|
||||
hardcoding per-component knowledge about config dict formats.
|
||||
After validation, components typically resolve paths to absolute
|
||||
using CORE.relative_config_path() or cv.file_(). Relative paths
|
||||
with known file extensions are also resolved and checked.
|
||||
|
||||
Core ESPHome concepts that use relative paths or directories
|
||||
are handled explicitly.
|
||||
"""
|
||||
config = self._config
|
||||
|
||||
# Generic walk: find all file paths in the validated config
|
||||
self._walk_config_for_files(config)
|
||||
|
||||
# --- Core ESPHome concepts needing explicit handling ---
|
||||
|
||||
# esphome.includes / includes_c - can be relative paths and directories
|
||||
esphome_conf = config.get(CONF_ESPHOME, {})
|
||||
for include_path in esphome_conf.get(CONF_INCLUDES, []):
|
||||
resolved = _resolve_include_path(include_path)
|
||||
if resolved is None:
|
||||
continue
|
||||
if resolved.is_dir():
|
||||
self._add_directory(resolved)
|
||||
else:
|
||||
self._add_file(resolved)
|
||||
for include_path in esphome_conf.get(CONF_INCLUDES_C, []):
|
||||
resolved = _resolve_include_path(include_path)
|
||||
if resolved is not None:
|
||||
self._add_file(resolved)
|
||||
|
||||
# external_components with source: local - directories
|
||||
for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []):
|
||||
source = ext_conf.get(CONF_SOURCE, {})
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
if source.get(CONF_TYPE) != "local":
|
||||
continue
|
||||
path = source.get(CONF_PATH)
|
||||
if not path:
|
||||
continue
|
||||
p = Path(path)
|
||||
if not p.is_absolute():
|
||||
p = CORE.relative_config_path(p)
|
||||
self._add_directory(p)
|
||||
|
||||
def _walk_config_for_files(self, obj: Any) -> None:
|
||||
"""Recursively walk the config dict looking for file path references."""
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
self._walk_config_for_files(value)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
for item in obj:
|
||||
self._walk_config_for_files(item)
|
||||
elif isinstance(obj, Path):
|
||||
if obj.is_absolute() and obj.is_file():
|
||||
self._add_file(obj)
|
||||
elif isinstance(obj, str):
|
||||
self._check_string_path(obj)
|
||||
|
||||
def _check_string_path(self, value: str) -> None:
|
||||
"""Check if a string value is a local file reference."""
|
||||
# Fast exits for strings that cannot be file paths
|
||||
if len(value) < 2 or "\n" in value:
|
||||
return
|
||||
if value.startswith(_NON_PATH_PREFIXES):
|
||||
return
|
||||
# File paths must contain a path separator or a dot (for extension)
|
||||
if "/" not in value and "\\" not in value and "." not in value:
|
||||
return
|
||||
|
||||
p = Path(value)
|
||||
|
||||
# Absolute path - check if it points to an existing file
|
||||
if p.is_absolute():
|
||||
if p.is_file():
|
||||
self._add_file(p)
|
||||
return
|
||||
|
||||
# Relative path with a known file extension - likely a component
|
||||
# validator that forgot to resolve to absolute via cv.file_() or
|
||||
# CORE.relative_config_path(). Warn and try to resolve.
|
||||
if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS:
|
||||
_LOGGER.warning(
|
||||
"Bundle: non-absolute path in validated config: %s "
|
||||
"(component validator should return absolute paths)",
|
||||
value,
|
||||
)
|
||||
resolved = CORE.relative_config_path(p)
|
||||
if resolved.is_file():
|
||||
self._add_file(resolved)
|
||||
|
||||
def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]:
|
||||
"""Build filtered secrets files containing only the referenced keys.
|
||||
|
||||
Returns a dict mapping relative archive path to YAML bytes.
|
||||
"""
|
||||
if not used_keys or not self._secrets_paths:
|
||||
return {}
|
||||
|
||||
result: dict[str, bytes] = {}
|
||||
for secrets_path in self._secrets_paths:
|
||||
rel_path = self._relative_to_config_dir(secrets_path)
|
||||
if rel_path is None:
|
||||
continue
|
||||
try:
|
||||
all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False)
|
||||
except EsphomeError:
|
||||
_LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path)
|
||||
continue
|
||||
if not isinstance(all_secrets, dict):
|
||||
continue
|
||||
filtered = {k: v for k, v in all_secrets.items() if k in used_keys}
|
||||
if filtered:
|
||||
data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8")
|
||||
result[rel_path] = data
|
||||
return result
|
||||
|
||||
def _build_manifest(
|
||||
self, files: list[BundleFile], *, has_secrets: bool
|
||||
) -> dict[str, Any]:
|
||||
"""Build the manifest.json content."""
|
||||
return {
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
|
||||
"""Add a BundleFile to the tar archive with deterministic metadata."""
|
||||
with open(bf.source, "rb") as f:
|
||||
_add_bytes_to_tar(tar, bf.path, f.read())
|
||||
|
||||
|
||||
def extract_bundle(
|
||||
bundle_path: Path,
|
||||
target_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Extract a bundle archive and return the path to the config YAML.
|
||||
|
||||
Sanity checks reject path traversal, symlinks, absolute paths, and
|
||||
oversized archives to prevent accidental file overwrites or extraction
|
||||
outside the target directory. These are **not** a security boundary —
|
||||
bundles are assumed to come from the user's own machine or a trusted
|
||||
build pipeline.
|
||||
|
||||
Args:
|
||||
bundle_path: Path to the .tar.gz bundle file.
|
||||
target_dir: Directory to extract into. If None, extracts next to
|
||||
the bundle file in a directory named after it.
|
||||
|
||||
Returns:
|
||||
Absolute path to the extracted config YAML file.
|
||||
|
||||
Raises:
|
||||
EsphomeError: If the bundle is invalid or extraction fails.
|
||||
"""
|
||||
|
||||
bundle_path = bundle_path.resolve()
|
||||
if not bundle_path.is_file():
|
||||
raise EsphomeError(f"Bundle file not found: {bundle_path}")
|
||||
|
||||
if target_dir is None:
|
||||
target_dir = _default_target_dir(bundle_path)
|
||||
|
||||
target_dir = target_dir.resolve()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read and validate the archive
|
||||
try:
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
manifest = _read_manifest_from_tar(tar)
|
||||
_validate_tar_members(tar, target_dir)
|
||||
tar.extractall(path=target_dir, filter="data")
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to extract bundle: {err}") from err
|
||||
|
||||
config_filename = manifest[ManifestKey.CONFIG_FILENAME]
|
||||
config_path = target_dir / config_filename
|
||||
if not config_path.is_file():
|
||||
raise EsphomeError(
|
||||
f"Bundle manifest references config '{config_filename}' "
|
||||
f"but it was not found in the archive"
|
||||
)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
"""Read and validate the manifest from a bundle without full extraction.
|
||||
|
||||
Args:
|
||||
bundle_path: Path to the .tar.gz bundle file.
|
||||
|
||||
Returns:
|
||||
Parsed BundleManifest.
|
||||
|
||||
Raises:
|
||||
EsphomeError: If the manifest is missing, invalid, or version unsupported.
|
||||
"""
|
||||
|
||||
try:
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
manifest = _read_manifest_from_tar(tar)
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
)
|
||||
|
||||
|
||||
def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]:
|
||||
"""Read and validate manifest.json from an open tar archive."""
|
||||
|
||||
try:
|
||||
member = tar.getmember(MANIFEST_FILENAME)
|
||||
except KeyError:
|
||||
raise EsphomeError("Invalid bundle: missing manifest.json") from None
|
||||
|
||||
f = tar.extractfile(member)
|
||||
if f is None:
|
||||
raise EsphomeError("Invalid bundle: manifest.json is not a regular file")
|
||||
|
||||
if member.size > MAX_MANIFEST_SIZE:
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: manifest.json too large "
|
||||
f"({member.size} bytes, max {MAX_MANIFEST_SIZE})"
|
||||
)
|
||||
|
||||
try:
|
||||
manifest = json.loads(f.read())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as err:
|
||||
raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err
|
||||
|
||||
# Version check
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if version is None:
|
||||
raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'")
|
||||
if not isinstance(version, int) or version < 1:
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: manifest_version must be a positive integer, got {version!r}"
|
||||
)
|
||||
if version > CURRENT_MANIFEST_VERSION:
|
||||
raise EsphomeError(
|
||||
f"Bundle manifest version {version} is newer than this ESPHome "
|
||||
f"version supports (max {CURRENT_MANIFEST_VERSION}). "
|
||||
f"Please upgrade ESPHome to compile this bundle."
|
||||
)
|
||||
|
||||
# Required fields
|
||||
if ManifestKey.CONFIG_FILENAME not in manifest:
|
||||
raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'")
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
|
||||
"""Sanity-check tar members to prevent mistakes and accidental overwrites.
|
||||
|
||||
This is not a security boundary — bundles are created locally or come
|
||||
from a trusted build pipeline. The checks catch malformed archives
|
||||
and common mistakes (stray absolute paths, ``..`` components) that
|
||||
could silently overwrite unrelated files.
|
||||
"""
|
||||
|
||||
total_size = 0
|
||||
for member in tar.getmembers():
|
||||
# Reject absolute paths (Unix and Windows)
|
||||
if member.name.startswith(("/", "\\")):
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: absolute path in archive: {member.name}"
|
||||
)
|
||||
|
||||
# Reject path traversal (split on both / and \ for cross-platform)
|
||||
parts = re.split(r"[/\\]", member.name)
|
||||
if ".." in parts:
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: path traversal in archive: {member.name}"
|
||||
)
|
||||
|
||||
# Reject symlinks
|
||||
if member.issym() or member.islnk():
|
||||
raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}")
|
||||
|
||||
# Ensure extraction stays within target_dir
|
||||
target_path = (target_dir / member.name).resolve()
|
||||
if not target_path.is_relative_to(target_dir):
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: file would extract outside target: {member.name}"
|
||||
)
|
||||
|
||||
# Track total decompressed size
|
||||
total_size += member.size
|
||||
if total_size > MAX_DECOMPRESSED_SIZE:
|
||||
raise EsphomeError(
|
||||
f"Invalid bundle: decompressed size exceeds "
|
||||
f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit"
|
||||
)
|
||||
|
||||
|
||||
def is_bundle_path(path: Path) -> bool:
|
||||
"""Check if a path looks like a bundle file."""
|
||||
return path.name.lower().endswith(BUNDLE_EXTENSION)
|
||||
|
||||
|
||||
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
"""Add in-memory bytes to a tar archive with deterministic metadata."""
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.mode = 0o644
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
|
||||
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
|
||||
|
||||
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
|
||||
resolved during the substitution pass. During bundle discovery we need
|
||||
the referenced files to actually load so the ``track_yaml_loads``
|
||||
listener fires for them.
|
||||
|
||||
``IncludeFile`` instances with unresolved substitution variables in the
|
||||
filename cannot be loaded — we skip and warn about those.
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
|
||||
if isinstance(obj, yaml_util.IncludeFile):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
if obj.has_unresolved_expressions():
|
||||
_LOGGER.warning(
|
||||
"Bundle: cannot resolve !include %s (referenced from %s) "
|
||||
"with substitutions in path",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
)
|
||||
return
|
||||
try:
|
||||
loaded = obj.load()
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Bundle: failed to load !include %s (referenced from %s): %s",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
err,
|
||||
)
|
||||
return
|
||||
_force_load_include_files(loaded, _seen)
|
||||
elif isinstance(obj, dict):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for value in obj.values():
|
||||
_force_load_include_files(value, _seen)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for item in obj:
|
||||
_force_load_include_files(item, _seen)
|
||||
|
||||
|
||||
def _resolve_include_path(include_path: Any) -> Path | None:
|
||||
"""Resolve an include path to absolute, skipping system includes."""
|
||||
if isinstance(include_path, str) and include_path.startswith("<"):
|
||||
return None # System include, not a local file
|
||||
p = Path(include_path)
|
||||
if not p.is_absolute():
|
||||
p = CORE.relative_config_path(p)
|
||||
return p
|
||||
|
||||
|
||||
def _default_target_dir(bundle_path: Path) -> Path:
|
||||
"""Compute the default extraction directory for a bundle."""
|
||||
name = bundle_path.name
|
||||
if name.lower().endswith(BUNDLE_EXTENSION):
|
||||
name = name[: -len(BUNDLE_EXTENSION)]
|
||||
return bundle_path.parent / name
|
||||
|
||||
|
||||
def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None:
|
||||
"""Move preserved build cache directories back into target_dir.
|
||||
|
||||
If the bundle contained entries under a preserved directory name,
|
||||
the extracted copy is removed so the original cache always wins.
|
||||
"""
|
||||
for dirname, src in preserved.items():
|
||||
dst = target_dir / dirname
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.move(str(src), str(dst))
|
||||
|
||||
|
||||
def prepare_bundle_for_compile(
|
||||
bundle_path: Path,
|
||||
target_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Extract a bundle for compilation, preserving build caches.
|
||||
|
||||
Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/
|
||||
directories in the target if they already exist (for incremental builds).
|
||||
|
||||
Args:
|
||||
bundle_path: Path to the .tar.gz bundle file.
|
||||
target_dir: Directory to extract into. Must be specified for
|
||||
build server use.
|
||||
|
||||
Returns:
|
||||
Absolute path to the extracted config YAML file.
|
||||
"""
|
||||
|
||||
bundle_path = bundle_path.resolve()
|
||||
if not bundle_path.is_file():
|
||||
raise EsphomeError(f"Bundle file not found: {bundle_path}")
|
||||
|
||||
if target_dir is None:
|
||||
target_dir = _default_target_dir(bundle_path)
|
||||
|
||||
target_dir = target_dir.resolve()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preserved: dict[str, Path] = {}
|
||||
|
||||
# Temporarily move preserved dirs out of the way
|
||||
staging = target_dir / _BUNDLE_STAGING_DIR
|
||||
for dirname in _PRESERVE_DIRS:
|
||||
src = target_dir / dirname
|
||||
if src.is_dir():
|
||||
dst = staging / dirname
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(dst))
|
||||
preserved[dirname] = dst
|
||||
|
||||
try:
|
||||
# Clean non-preserved content and extract fresh
|
||||
for item in target_dir.iterdir():
|
||||
if item.name == _BUNDLE_STAGING_DIR:
|
||||
continue
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
|
||||
config_path = extract_bundle(bundle_path, target_dir)
|
||||
finally:
|
||||
# Restore preserved dirs (idempotent) and clean staging
|
||||
_restore_preserved_dirs(preserved, target_dir)
|
||||
if staging.is_dir():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
return config_path
|
||||
@@ -11,6 +11,7 @@
|
||||
from esphome.cpp_generator import ( # noqa: F401
|
||||
ArrayInitializer,
|
||||
Expression,
|
||||
FlashStringLiteral,
|
||||
LineComment,
|
||||
LogStringLiteral,
|
||||
MockObj,
|
||||
@@ -78,6 +79,7 @@ from esphome.cpp_types import ( # noqa: F401
|
||||
float_,
|
||||
global_ns,
|
||||
gpio_Flags,
|
||||
int8,
|
||||
int16,
|
||||
int32,
|
||||
int64,
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "absolute_humidity.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace absolute_humidity {
|
||||
namespace esphome::absolute_humidity {
|
||||
|
||||
static const char *const TAG = "absolute_humidity.sensor";
|
||||
static const char *const TAG{"absolute_humidity.sensor"};
|
||||
|
||||
void AbsoluteHumidityComponent::setup() {
|
||||
this->temperature_sensor_->add_on_state_callback([this](float state) {
|
||||
this->temperature_ = state;
|
||||
this->enable_loop();
|
||||
});
|
||||
ESP_LOGD(TAG, " Added callback for temperature '%s'", this->temperature_sensor_->get_name().c_str());
|
||||
this->temperature_sensor_->add_on_state_callback([this](float state) { this->temperature_callback_(state); });
|
||||
// Get initial value
|
||||
if (this->temperature_sensor_->has_state()) {
|
||||
this->temperature_callback_(this->temperature_sensor_->get_state());
|
||||
this->temperature_ = this->temperature_sensor_->get_state();
|
||||
}
|
||||
|
||||
this->humidity_sensor_->add_on_state_callback([this](float state) {
|
||||
this->humidity_ = state;
|
||||
this->enable_loop();
|
||||
});
|
||||
ESP_LOGD(TAG, " Added callback for relative humidity '%s'", this->humidity_sensor_->get_name().c_str());
|
||||
this->humidity_sensor_->add_on_state_callback([this](float state) { this->humidity_callback_(state); });
|
||||
// Get initial value
|
||||
if (this->humidity_sensor_->has_state()) {
|
||||
this->humidity_callback_(this->humidity_sensor_->get_state());
|
||||
this->humidity_ = this->humidity_sensor_->get_state();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +53,12 @@ void AbsoluteHumidityComponent::dump_config() {
|
||||
}
|
||||
|
||||
void AbsoluteHumidityComponent::loop() {
|
||||
if (!this->next_update_) {
|
||||
return;
|
||||
}
|
||||
this->next_update_ = false;
|
||||
// Only run once
|
||||
this->disable_loop();
|
||||
|
||||
// Ensure we have source data
|
||||
const bool no_temperature = std::isnan(this->temperature_);
|
||||
const bool no_humidity = std::isnan(this->humidity_);
|
||||
const bool no_temperature{std::isnan(this->temperature_)};
|
||||
const bool no_humidity{std::isnan(this->humidity_)};
|
||||
if (no_temperature || no_humidity) {
|
||||
if (no_temperature) {
|
||||
ESP_LOGW(TAG, "No valid state from temperature sensor!");
|
||||
@@ -67,9 +72,9 @@ void AbsoluteHumidityComponent::loop() {
|
||||
}
|
||||
|
||||
// Convert to desired units
|
||||
const float temperature_c = this->temperature_;
|
||||
const float temperature_k = temperature_c + 273.15;
|
||||
const float hr = this->humidity_ / 100;
|
||||
const float temperature_c{this->temperature_};
|
||||
const float temperature_k{temperature_c + 273.15f};
|
||||
const float hr{this->humidity_ / 100.0f};
|
||||
|
||||
// Calculate saturation vapor pressure
|
||||
float es;
|
||||
@@ -90,12 +95,9 @@ void AbsoluteHumidityComponent::loop() {
|
||||
}
|
||||
|
||||
// Calculate absolute humidity
|
||||
const float absolute_humidity = vapor_density(es, hr, temperature_k);
|
||||
const float absolute_humidity{vapor_density(es, hr, temperature_k)};
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
"Saturation vapor pressure %f kPa\n"
|
||||
"Publishing absolute humidity %f g/m³",
|
||||
es, absolute_humidity);
|
||||
ESP_LOGD(TAG, "Saturation vapor pressure %f kPa, absolute humidity %f g/m³", es, absolute_humidity);
|
||||
|
||||
// Publish absolute humidity
|
||||
this->status_clear_warning();
|
||||
@@ -106,16 +108,16 @@ void AbsoluteHumidityComponent::loop() {
|
||||
// More accurate than Tetens in normal meteorologic conditions
|
||||
float AbsoluteHumidityComponent::es_buck(float temperature_c) {
|
||||
float a, b, c, d;
|
||||
if (temperature_c >= 0) {
|
||||
a = 0.61121;
|
||||
b = 18.678;
|
||||
c = 234.5;
|
||||
d = 257.14;
|
||||
if (temperature_c >= 0.0f) {
|
||||
a = 0.61121f;
|
||||
b = 18.678f;
|
||||
c = 234.5f;
|
||||
d = 257.14f;
|
||||
} else {
|
||||
a = 0.61115;
|
||||
b = 18.678;
|
||||
c = 233.7;
|
||||
d = 279.82;
|
||||
a = 0.61115f;
|
||||
b = 18.678f;
|
||||
c = 233.7f;
|
||||
d = 279.82f;
|
||||
}
|
||||
return a * expf((b - (temperature_c / c)) * (temperature_c / (d + temperature_c)));
|
||||
}
|
||||
@@ -123,14 +125,14 @@ float AbsoluteHumidityComponent::es_buck(float temperature_c) {
|
||||
// Tetens equation (https://en.wikipedia.org/wiki/Tetens_equation)
|
||||
float AbsoluteHumidityComponent::es_tetens(float temperature_c) {
|
||||
float a, b;
|
||||
if (temperature_c >= 0) {
|
||||
a = 17.27;
|
||||
b = 237.3;
|
||||
if (temperature_c >= 0.0f) {
|
||||
a = 17.27f;
|
||||
b = 237.3f;
|
||||
} else {
|
||||
a = 21.875;
|
||||
b = 265.5;
|
||||
a = 21.875f;
|
||||
b = 265.5f;
|
||||
}
|
||||
return 0.61078 * expf((a * temperature_c) / (temperature_c + b));
|
||||
return 0.61078f * expf((a * temperature_c) / (temperature_c + b));
|
||||
}
|
||||
|
||||
// Wobus equation
|
||||
@@ -149,18 +151,18 @@ float AbsoluteHumidityComponent::es_wobus(float t) {
|
||||
//
|
||||
// Baker, Schlatter 17-MAY-1982 Original version.
|
||||
|
||||
const float c0 = +0.99999683e00;
|
||||
const float c1 = -0.90826951e-02;
|
||||
const float c2 = +0.78736169e-04;
|
||||
const float c3 = -0.61117958e-06;
|
||||
const float c4 = +0.43884187e-08;
|
||||
const float c5 = -0.29883885e-10;
|
||||
const float c6 = +0.21874425e-12;
|
||||
const float c7 = -0.17892321e-14;
|
||||
const float c8 = +0.11112018e-16;
|
||||
const float c9 = -0.30994571e-19;
|
||||
const float p = c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))));
|
||||
return 0.61078 / pow(p, 8);
|
||||
constexpr float c0{+0.99999683e+00f};
|
||||
constexpr float c1{-0.90826951e-02f};
|
||||
constexpr float c2{+0.78736169e-04f};
|
||||
constexpr float c3{-0.61117958e-06f};
|
||||
constexpr float c4{+0.43884187e-08f};
|
||||
constexpr float c5{-0.29883885e-10f};
|
||||
constexpr float c6{+0.21874425e-12f};
|
||||
constexpr float c7{-0.17892321e-14f};
|
||||
constexpr float c8{+0.11112018e-16f};
|
||||
constexpr float c9{-0.30994571e-19f};
|
||||
const float p{c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))))};
|
||||
return 0.61078f / powf(p, 8.0f);
|
||||
}
|
||||
|
||||
// From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/
|
||||
@@ -171,11 +173,10 @@ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) {
|
||||
// hr = relative humidity [0-1]
|
||||
// ta = absolute temperature (K)
|
||||
|
||||
const float ea = hr * es * 1000; // vapor pressure of the air (Pa)
|
||||
const float mw = 18.01528; // molar mass of water (g⋅mol⁻¹)
|
||||
const float r = 8.31446261815324; // molar gas constant (J⋅K⁻¹)
|
||||
const float ea{hr * es * 1000.0f}; // vapor pressure of the air (Pa)
|
||||
const float mw{18.01528f}; // molar mass of water (g⋅mol⁻¹)
|
||||
const float r{8.31446261815324f}; // molar gas constant (J⋅K⁻¹)
|
||||
return (ea * mw) / (r * ta);
|
||||
}
|
||||
|
||||
} // namespace absolute_humidity
|
||||
} // namespace esphome
|
||||
} // namespace esphome::absolute_humidity
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace absolute_humidity {
|
||||
namespace esphome::absolute_humidity {
|
||||
|
||||
/// Enum listing all implemented saturation vapor pressure equations.
|
||||
enum SaturationVaporPressureEquation {
|
||||
@@ -16,8 +15,6 @@ enum SaturationVaporPressureEquation {
|
||||
/// This class implements calculation of absolute humidity from temperature and relative humidity.
|
||||
class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
|
||||
public:
|
||||
AbsoluteHumidityComponent() = default;
|
||||
|
||||
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
|
||||
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
|
||||
void set_equation(SaturationVaporPressureEquation equation) { this->equation_ = equation; }
|
||||
@@ -27,15 +24,6 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
|
||||
void loop() override;
|
||||
|
||||
protected:
|
||||
void temperature_callback_(float state) {
|
||||
this->next_update_ = true;
|
||||
this->temperature_ = state;
|
||||
}
|
||||
void humidity_callback_(float state) {
|
||||
this->next_update_ = true;
|
||||
this->humidity_ = state;
|
||||
}
|
||||
|
||||
/** Buck equation for saturation vapor pressure in kPa.
|
||||
*
|
||||
* @param temperature_c Air temperature in °C.
|
||||
@@ -57,19 +45,15 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
|
||||
* @param es Saturation vapor pressure in kPa.
|
||||
* @param hr Relative humidity 0 to 1.
|
||||
* @param ta Absolute temperature in K.
|
||||
* @param heater_duration The duration in ms that the heater should turn on for when measuring.
|
||||
*/
|
||||
static float vapor_density(float es, float hr, float ta);
|
||||
|
||||
sensor::Sensor *temperature_sensor_{nullptr};
|
||||
sensor::Sensor *humidity_sensor_{nullptr};
|
||||
|
||||
bool next_update_{false};
|
||||
|
||||
float temperature_{NAN};
|
||||
float humidity_{NAN};
|
||||
SaturationVaporPressureEquation equation_;
|
||||
};
|
||||
|
||||
} // namespace absolute_humidity
|
||||
} // namespace esphome
|
||||
} // namespace esphome::absolute_humidity
|
||||
|
||||
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
|
||||
this->zero_cross_pin_->setup();
|
||||
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
|
||||
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
|
||||
gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->zero_cross_interrupt_type_);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
@@ -199,12 +199,19 @@ void AcDimmer::setup() {
|
||||
setTimer1Callback(&timer_interrupt);
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
dimmer_timer = timer_begin(TIMER_FREQUENCY_HZ);
|
||||
timer_attach_interrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr);
|
||||
// For ESP32, we can't use dynamic interval calculation because the timerX functions
|
||||
// are not callable from ISR (placed in flash storage).
|
||||
// Here we just use an interrupt firing every 50 µs.
|
||||
timer_alarm(dimmer_timer, TIMER_INTERVAL_US, true, 0);
|
||||
if (dimmer_timer == nullptr) {
|
||||
dimmer_timer = timer_begin(TIMER_FREQUENCY_HZ);
|
||||
if (dimmer_timer == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create GPTimer for AC dimmer");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
timer_attach_interrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr);
|
||||
// For ESP32, we can't use dynamic interval calculation because the timerX functions
|
||||
// are not callable from ISR (placed in flash storage).
|
||||
// Here we just use an interrupt firing every 50 µs.
|
||||
timer_alarm(dimmer_timer, TIMER_INTERVAL_US, true, 0);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -219,19 +226,25 @@ void AcDimmer::write_state(float state) {
|
||||
void AcDimmer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"AcDimmer:\n"
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
|
||||
LOG_PIN(" Output Pin: ", this->gate_pin_);
|
||||
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
|
||||
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
|
||||
}
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
}
|
||||
|
||||
LOG_FLOAT_OUTPUT(this);
|
||||
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
void dump_config() override;
|
||||
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
|
||||
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
|
||||
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
|
||||
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
|
||||
void set_method(DimMethod method) { method_ = method; }
|
||||
|
||||
@@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
|
||||
InternalGPIOPin *gate_pin_;
|
||||
InternalGPIOPin *zero_cross_pin_;
|
||||
gpio::InterruptType zero_cross_interrupt_type_;
|
||||
AcDimmerDataStore store_;
|
||||
bool init_with_half_cycle_;
|
||||
DimMethod method_;
|
||||
|
||||
@@ -7,6 +7,8 @@ from esphome.core import CORE
|
||||
|
||||
CODEOWNERS = ["@glmnet"]
|
||||
|
||||
gpio_ns = cg.esphome_ns.namespace("gpio")
|
||||
|
||||
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
|
||||
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
|
||||
|
||||
@@ -17,15 +19,26 @@ DIM_METHODS = {
|
||||
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
|
||||
}
|
||||
|
||||
ZC_INTERRUPT_TYPES = {
|
||||
"RISING": gpio_ns.INTERRUPT_RISING_EDGE,
|
||||
"FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
|
||||
"ANY": gpio_ns.INTERRUPT_ANY_EDGE,
|
||||
}
|
||||
|
||||
CONF_GATE_PIN = "gate_pin"
|
||||
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
|
||||
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
|
||||
CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
|
||||
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
|
||||
ZC_INTERRUPT_TYPES, upper=True, space="_"
|
||||
),
|
||||
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
|
||||
DIM_METHODS, upper=True, space="_"
|
||||
@@ -54,5 +67,6 @@ async def to_code(config):
|
||||
cg.add(var.set_gate_pin(pin))
|
||||
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
|
||||
cg.add(var.set_zero_cross_pin(pin))
|
||||
cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
|
||||
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
|
||||
cg.add(var.set_method(config[CONF_METHOD]))
|
||||
|
||||
@@ -22,7 +22,8 @@ namespace adc {
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// clang-format off
|
||||
#if (ESP_IDF_VERSION_MAJOR == 5 && \
|
||||
#if ESP_IDF_VERSION_MAJOR >= 6 || \
|
||||
(ESP_IDF_VERSION_MAJOR == 5 && \
|
||||
((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \
|
||||
(ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \
|
||||
(ESP_IDF_VERSION_MINOR >= 2)) \
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "adc_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome {
|
||||
namespace adc {
|
||||
@@ -346,7 +347,8 @@ float ADCSensor::sample_autorange_() {
|
||||
ESP_LOGVV(TAG, "Autorange summary:");
|
||||
ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0);
|
||||
ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0);
|
||||
ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum);
|
||||
ESP_LOGVV(TAG, " Coefficients: c12=%" PRIu32 ", c6=%" PRIu32 ", c2=%" PRIu32 ", c0=%" PRIu32 ", sum=%" PRIu32, c12,
|
||||
c6, c2, c0, csum);
|
||||
|
||||
if (csum == 0) {
|
||||
ESP_LOGE(TAG, "Invalid weight sum in autorange calculation");
|
||||
@@ -354,8 +356,10 @@ float ADCSensor::sample_autorange_() {
|
||||
}
|
||||
|
||||
const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum;
|
||||
ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0,
|
||||
c0, csum, final_result);
|
||||
ESP_LOGV(TAG,
|
||||
"Autorange final: (%.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 ")/%" PRIu32
|
||||
" = %.6fV",
|
||||
mv12, c12, mv6, c6, mv2, c2, mv0, c0, csum, final_result);
|
||||
|
||||
return final_result;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#include <hardware/adc.h>
|
||||
|
||||
// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h),
|
||||
// but the Arduino framework's config_autogen.h includes a generic board header
|
||||
// that doesn't define it. Provide the standard value (pin 29) as a fallback.
|
||||
#ifndef PICO_VSYS_PIN
|
||||
#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage)
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
namespace adc {
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, voltage_sampler
|
||||
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
include_builtin_idf_component,
|
||||
require_adc_oneshot_iram,
|
||||
)
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
@@ -24,6 +28,7 @@ from esphome.const import (
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
ATTENUATION_MODES,
|
||||
@@ -65,6 +70,13 @@ def validate_config(config):
|
||||
return config
|
||||
|
||||
|
||||
def _require_adc_iram(config: ConfigType) -> ConfigType:
|
||||
"""Register ADC oneshot IRAM requirement during config validation."""
|
||||
if CORE.is_esp32:
|
||||
require_adc_oneshot_iram()
|
||||
return config
|
||||
|
||||
|
||||
ADCSensor = adc_ns.class_(
|
||||
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
|
||||
)
|
||||
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s")),
|
||||
validate_config,
|
||||
_require_adc_iram,
|
||||
)
|
||||
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
@@ -58,7 +58,10 @@ void HOT AddressableLightDisplay::draw_absolute_pixel_internal(int x, int y, Col
|
||||
|
||||
if (this->pixel_mapper_f_.has_value()) {
|
||||
// Params are passed by reference, so they may be modified in call.
|
||||
this->addressable_light_buffer_[(*this->pixel_mapper_f_)(x, y)] = color;
|
||||
int index = (*this->pixel_mapper_f_)(x, y);
|
||||
if (index < 0 || static_cast<size_t>(index) >= this->addressable_light_buffer_.size())
|
||||
return;
|
||||
this->addressable_light_buffer_[index] = color;
|
||||
} else {
|
||||
this->addressable_light_buffer_[y * this->get_width_internal() + x] = color;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer {
|
||||
// - Save the current effect index.
|
||||
this->last_effect_index_ = light_state_->get_current_effect_index();
|
||||
// - Disable any current effect.
|
||||
light_state_->make_call().set_effect(0).perform();
|
||||
light_state_->make_call().set_effect(uint32_t{0}).perform();
|
||||
}
|
||||
}
|
||||
enabled_ = enabled;
|
||||
|
||||
@@ -121,7 +121,7 @@ void ADE7880::update() {
|
||||
this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) {
|
||||
return chan->forward_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, AFWATTHR, [&chan](float val) {
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) {
|
||||
return chan->reverse_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ void ADE7880::update() {
|
||||
this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) {
|
||||
return chan->forward_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BFWATTHR, [&chan](float val) {
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) {
|
||||
return chan->reverse_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
}
|
||||
@@ -153,7 +153,7 @@ void ADE7880::update() {
|
||||
this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) {
|
||||
return chan->forward_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CFWATTHR, [&chan](float val) {
|
||||
this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) {
|
||||
return chan->reverse_active_energy_total += val / 14400.0f;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ constexpr uint16_t CWATTHR = 0xE402;
|
||||
constexpr uint16_t AFWATTHR = 0xE403;
|
||||
constexpr uint16_t BFWATTHR = 0xE404;
|
||||
constexpr uint16_t CFWATTHR = 0xE405;
|
||||
constexpr uint16_t ARWATTHR = 0xE406;
|
||||
constexpr uint16_t BRWATTHR = 0xE407;
|
||||
constexpr uint16_t CRWATTHR = 0xE408;
|
||||
constexpr uint16_t AFVARHR = 0xE409;
|
||||
constexpr uint16_t BFVARHR = 0xE40A;
|
||||
constexpr uint16_t CFVARHR = 0xE40B;
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace ade7953_base {
|
||||
|
||||
static const char *const TAG = "ade7953";
|
||||
|
||||
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
|
||||
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
|
||||
|
||||
static const float ADE_POWER_FACTOR = 154.0f;
|
||||
static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600;
|
||||
|
||||
@@ -18,7 +21,12 @@ void ADE7953::setup() {
|
||||
|
||||
// The chip might take up to 100ms to initialise
|
||||
this->set_timeout(100, [this]() {
|
||||
// this->ade_write_8(0x0010, 0x04);
|
||||
// Lock communication interface (SPI or I2C)
|
||||
uint16_t config_v = CONFIG_DEFAULT;
|
||||
this->ade_read_16(CONFIG_16, &config_v);
|
||||
config_v &= static_cast<uint16_t>(~CONFIG_LOCK_BIT); // Clear the lock bit
|
||||
this->ade_write_16(CONFIG_16, config_v);
|
||||
// Configure optimum settings according to datasheet
|
||||
this->ade_write_8(0x00FE, 0xAD);
|
||||
this->ade_write_16(0x0120, 0x0030);
|
||||
// Set gains
|
||||
|
||||
@@ -9,31 +9,35 @@
|
||||
namespace esphome {
|
||||
namespace ade7953_base {
|
||||
|
||||
static const uint8_t PGA_V_8 =
|
||||
static constexpr uint8_t PGA_V_8 =
|
||||
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
|
||||
static const uint8_t PGA_IA_8 =
|
||||
static constexpr uint8_t PGA_IA_8 =
|
||||
0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0])
|
||||
static const uint8_t PGA_IB_8 =
|
||||
static constexpr uint8_t PGA_IB_8 =
|
||||
0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0])
|
||||
|
||||
static const uint32_t AIGAIN_32 =
|
||||
static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register
|
||||
|
||||
static constexpr uint16_t AIGAIN_32 =
|
||||
0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit)
|
||||
static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
|
||||
static const uint32_t AWGAIN_32 =
|
||||
static constexpr uint16_t AVGAIN_32 =
|
||||
0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
|
||||
static constexpr uint16_t AWGAIN_32 =
|
||||
0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit)
|
||||
static const uint32_t AVARGAIN_32 =
|
||||
static constexpr uint16_t AVARGAIN_32 =
|
||||
0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit)
|
||||
static const uint32_t AVAGAIN_32 =
|
||||
static constexpr uint16_t AVAGAIN_32 =
|
||||
0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit)
|
||||
|
||||
static const uint32_t BIGAIN_32 =
|
||||
static constexpr uint16_t BIGAIN_32 =
|
||||
0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit)
|
||||
static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
|
||||
static const uint32_t BWGAIN_32 =
|
||||
static constexpr uint16_t BVGAIN_32 =
|
||||
0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
|
||||
static constexpr uint16_t BWGAIN_32 =
|
||||
0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit)
|
||||
static const uint32_t BVARGAIN_32 =
|
||||
static constexpr uint16_t BVARGAIN_32 =
|
||||
0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit)
|
||||
static const uint32_t BVAGAIN_32 =
|
||||
static constexpr uint16_t BVAGAIN_32 =
|
||||
0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit)
|
||||
|
||||
class ADE7953 : public PollingComponent, public sensor::Sensor {
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace ade7953_spi {
|
||||
|
||||
static const char *const TAG = "ade7953";
|
||||
|
||||
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
|
||||
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
|
||||
|
||||
void AdE7953Spi::setup() {
|
||||
this->spi_setup();
|
||||
ade7953_base::ADE7953::setup();
|
||||
@@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) {
|
||||
this->write_byte16(reg);
|
||||
this->transfer_byte(0);
|
||||
this->write_byte16(value);
|
||||
if (reg == ade7953_base::CONFIG_16) {
|
||||
delayMicroseconds(CONFIG_LOCK_SETTLE_US);
|
||||
}
|
||||
this->disable();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace esphome {
|
||||
namespace ade7953_spi {
|
||||
|
||||
class AdE7953Spi : public ade7953_base::ADE7953,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_LEADING,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_TRAILING,
|
||||
spi::DATA_RATE_1MHZ> {
|
||||
public:
|
||||
void setup() override;
|
||||
|
||||
@@ -173,19 +173,8 @@ float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1
|
||||
}
|
||||
|
||||
if (resolution == ADS1015_12_BITS) {
|
||||
bool negative = (raw_conversion >> 15) == 1;
|
||||
|
||||
// shift raw_conversion as it's only 12-bits, left justified
|
||||
raw_conversion = raw_conversion >> (16 - ADS1015_12_BITS);
|
||||
|
||||
// check if number was negative in order to keep the sign
|
||||
if (negative) {
|
||||
// the number was negative
|
||||
// 1) set the negative bit back
|
||||
raw_conversion |= 0x8000;
|
||||
// 2) reset the former (shifted) negative bit
|
||||
raw_conversion &= 0xF7FF;
|
||||
}
|
||||
// ADS1015 returns 12-bit value left-justified in 16 bits; shift right and sign-extend
|
||||
raw_conversion = static_cast<uint16_t>(static_cast<int16_t>(raw_conversion) >> (16 - ADS1015_12_BITS));
|
||||
}
|
||||
|
||||
auto signed_conversion = static_cast<int16_t>(raw_conversion);
|
||||
|
||||
@@ -12,11 +12,15 @@ CONF_ADS1118_ID = "ads1118_id"
|
||||
ads1118_ns = cg.esphome_ns.namespace("ads1118")
|
||||
ADS1118 = ads1118_ns.class_("ADS1118", cg.Component, spi.SPIDevice)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ADS1118),
|
||||
}
|
||||
).extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ADS1118),
|
||||
}
|
||||
)
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
|
||||
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AGS10Component),
|
||||
cv.Optional(CONF_TVOC): sensor.sensor_schema(
|
||||
cv.Required(CONF_TVOC): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_BILLION,
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
@@ -92,11 +92,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
"ags10.new_i2c_address",
|
||||
AGS10NewI2cAddressAction,
|
||||
AGS10_NEW_I2C_ADDRESS_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
address = await cg.templatable(config[CONF_ADDRESS], args, int)
|
||||
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
|
||||
cg.add(var.set_new_address(address))
|
||||
return var
|
||||
|
||||
@@ -111,7 +112,9 @@ AGS10_SET_ZERO_POINT_ACTION_MODE = {
|
||||
AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(AGS10Component),
|
||||
cv.Required(CONF_MODE): cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True),
|
||||
cv.Required(CONF_MODE): cv.templatable(
|
||||
cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True)
|
||||
),
|
||||
cv.Optional(CONF_VALUE, default=0xFFFF): cv.templatable(cv.uint16_t),
|
||||
},
|
||||
)
|
||||
@@ -121,12 +124,15 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
|
||||
"ags10.set_zero_point",
|
||||
AGS10SetZeroPointAction,
|
||||
AGS10_SET_ZERO_POINT_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
mode = await cg.templatable(config.get(CONF_MODE), args, enumerate)
|
||||
mode = await cg.templatable(
|
||||
config.get(CONF_MODE), args, AGS10SetZeroPointActionMode
|
||||
)
|
||||
cg.add(var.set_mode(mode))
|
||||
value = await cg.templatable(config[CONF_VALUE], args, int)
|
||||
value = await cg.templatable(config[CONF_VALUE], args, cg.uint16)
|
||||
cg.add(var.set_value(value))
|
||||
return var
|
||||
|
||||
@@ -34,13 +34,16 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"aic3204.set_auto_mute_mode", SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA
|
||||
"aic3204.set_auto_mute_mode",
|
||||
SetAutoMuteAction,
|
||||
SET_AUTO_MUTE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
template_ = await cg.templatable(config.get(CONF_MODE), args, int)
|
||||
template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8)
|
||||
cg.add(var.set_auto_mute_mode(template_))
|
||||
|
||||
return var
|
||||
|
||||
@@ -10,11 +10,14 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MQTT_ID,
|
||||
CONF_ON_STATE,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
)
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
|
||||
CODEOWNERS = ["@grahambrown11", "@hwstar"]
|
||||
@@ -34,39 +37,9 @@ CONF_ON_READY = "on_ready"
|
||||
alarm_control_panel_ns = cg.esphome_ns.namespace("alarm_control_panel")
|
||||
AlarmControlPanel = alarm_control_panel_ns.class_("AlarmControlPanel", cg.EntityBase)
|
||||
|
||||
StateTrigger = alarm_control_panel_ns.class_(
|
||||
"StateTrigger", automation.Trigger.template()
|
||||
)
|
||||
TriggeredTrigger = alarm_control_panel_ns.class_(
|
||||
"TriggeredTrigger", automation.Trigger.template()
|
||||
)
|
||||
ClearedTrigger = alarm_control_panel_ns.class_(
|
||||
"ClearedTrigger", automation.Trigger.template()
|
||||
)
|
||||
ArmingTrigger = alarm_control_panel_ns.class_(
|
||||
"ArmingTrigger", automation.Trigger.template()
|
||||
)
|
||||
PendingTrigger = alarm_control_panel_ns.class_(
|
||||
"PendingTrigger", automation.Trigger.template()
|
||||
)
|
||||
ArmedHomeTrigger = alarm_control_panel_ns.class_(
|
||||
"ArmedHomeTrigger", automation.Trigger.template()
|
||||
)
|
||||
ArmedNightTrigger = alarm_control_panel_ns.class_(
|
||||
"ArmedNightTrigger", automation.Trigger.template()
|
||||
)
|
||||
ArmedAwayTrigger = alarm_control_panel_ns.class_(
|
||||
"ArmedAwayTrigger", automation.Trigger.template()
|
||||
)
|
||||
DisarmedTrigger = alarm_control_panel_ns.class_(
|
||||
"DisarmedTrigger", automation.Trigger.template()
|
||||
)
|
||||
ChimeTrigger = alarm_control_panel_ns.class_(
|
||||
"ChimeTrigger", automation.Trigger.template()
|
||||
)
|
||||
ReadyTrigger = alarm_control_panel_ns.class_(
|
||||
"ReadyTrigger", automation.Trigger.template()
|
||||
)
|
||||
StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder")
|
||||
StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder")
|
||||
AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState")
|
||||
|
||||
ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action)
|
||||
ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action)
|
||||
@@ -89,61 +62,17 @@ _ALARM_CONTROL_PANEL_SCHEMA = (
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt.MQTTAlarmControlPanelComponent
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TriggeredTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_ARMING): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmingTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PENDING): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PendingTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedHomeTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedNightTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedAwayTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_DISARMED): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisarmedTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_CLEARED): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClearedTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_CHIME): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ChimeTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_READY): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReadyTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ARMING): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_PENDING): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DISARMED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_CLEARED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_CHIME): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_READY): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -186,41 +115,66 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_TRIGGERED,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_TRIGGERED
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_ARMING,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_PENDING,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_PENDING
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_ARMED_HOME,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_ARMED_HOME
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_ARMED_NIGHT,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_ARMED_NIGHT
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_ARMED_AWAY,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_ARMED_AWAY
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DISARMED,
|
||||
"add_on_state_callback",
|
||||
forwarder=StateEnterForwarder.template(
|
||||
AlarmControlPanelState.ACP_STATE_DISARMED
|
||||
),
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"),
|
||||
automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"),
|
||||
)
|
||||
|
||||
|
||||
@setup_entity("alarm_control_panel")
|
||||
async def setup_alarm_control_panel_core_(var, config):
|
||||
await setup_entity(var, config, "alarm_control_panel")
|
||||
for conf in config.get(CONF_ON_STATE, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_TRIGGERED, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_ARMING, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_PENDING, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_ARMED_HOME, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_ARMED_NIGHT, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_ARMED_AWAY, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_DISARMED, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_CLEARED, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_CHIME, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
for conf in config.get(CONF_ON_READY, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
@@ -231,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config):
|
||||
async def register_alarm_control_panel(var, config):
|
||||
if not CORE.has_id(config[CONF_ID]):
|
||||
var = cg.Pvariable(config[CONF_ID], var)
|
||||
cg.add(cg.App.register_alarm_control_panel(var))
|
||||
queue_entity_register("alarm_control_panel", config)
|
||||
CORE.register_platform_component("alarm_control_panel", var)
|
||||
await setup_alarm_control_panel_core_(var, config)
|
||||
|
||||
@@ -243,7 +197,10 @@ async def new_alarm_control_panel(config, *args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.arm_away", ArmAwayAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.arm_away",
|
||||
ArmAwayAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_arm_away_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -255,7 +212,10 @@ async def alarm_action_arm_away_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.arm_home", ArmHomeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.arm_home",
|
||||
ArmHomeAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_arm_home_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -267,7 +227,10 @@ async def alarm_action_arm_home_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.arm_night", ArmNightAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.arm_night",
|
||||
ArmNightAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_arm_night_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -279,7 +242,10 @@ async def alarm_action_arm_night_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.disarm", DisarmAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.disarm",
|
||||
DisarmAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_disarm_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -291,7 +257,10 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.pending",
|
||||
PendingAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_pending_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -299,7 +268,10 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.triggered",
|
||||
TriggeredAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_trigger_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -307,7 +279,10 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.chime",
|
||||
ChimeAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def alarm_action_chime_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
@@ -315,7 +290,10 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args):
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
"alarm_control_panel.ready",
|
||||
ReadyAction,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_condition(
|
||||
"alarm_control_panel.ready",
|
||||
|
||||
@@ -31,12 +31,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
|
||||
this->last_update_ = millis();
|
||||
if (state != this->current_state_) {
|
||||
auto prev_state = this->current_state_;
|
||||
ESP_LOGD(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
|
||||
ESP_LOGV(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
|
||||
LOG_STR_ARG(alarm_control_panel_state_to_string(state)),
|
||||
LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state)));
|
||||
this->current_state_ = state;
|
||||
// Single state callback - triggers check get_state() for specific states
|
||||
this->state_callback_.call();
|
||||
// Single state callback - listeners receive the new state as an argument
|
||||
this->state_callback_.call(state);
|
||||
#if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY)
|
||||
ControllerRegistry::notify_alarm_control_panel_update(this);
|
||||
#endif
|
||||
@@ -51,22 +51,6 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
|
||||
}
|
||||
}
|
||||
|
||||
void AlarmControlPanel::add_on_state_callback(std::function<void()> &&callback) {
|
||||
this->state_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
void AlarmControlPanel::add_on_cleared_callback(std::function<void()> &&callback) {
|
||||
this->cleared_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
void AlarmControlPanel::add_on_chime_callback(std::function<void()> &&callback) {
|
||||
this->chime_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
void AlarmControlPanel::add_on_ready_callback(std::function<void()> &&callback) {
|
||||
this->ready_callback_.add(std::move(callback));
|
||||
}
|
||||
|
||||
void AlarmControlPanel::arm_with_code_(AlarmControlPanelCall &(AlarmControlPanelCall::*arm_method)(),
|
||||
const char *code) {
|
||||
auto call = this->make_call();
|
||||
|
||||
@@ -37,25 +37,24 @@ class AlarmControlPanel : public EntityBase {
|
||||
*
|
||||
* @param callback The callback function
|
||||
*/
|
||||
void add_on_state_callback(std::function<void()> &&callback);
|
||||
template<typename F> void add_on_state_callback(F &&callback) {
|
||||
this->state_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/** Add a callback for when the state of the alarm_control_panel clears from triggered
|
||||
*
|
||||
* @param callback The callback function
|
||||
*/
|
||||
void add_on_cleared_callback(std::function<void()> &&callback);
|
||||
/** Add a callback for when the state of the alarm_control_panel clears from triggered. */
|
||||
template<typename F> void add_on_cleared_callback(F &&callback) {
|
||||
this->cleared_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/** Add a callback for when a chime zone goes from closed to open
|
||||
*
|
||||
* @param callback The callback function
|
||||
*/
|
||||
void add_on_chime_callback(std::function<void()> &&callback);
|
||||
/** Add a callback for when a chime zone goes from closed to open. */
|
||||
template<typename F> void add_on_chime_callback(F &&callback) {
|
||||
this->chime_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/** Add a callback for when a ready state changes
|
||||
*
|
||||
* @param callback The callback function
|
||||
*/
|
||||
void add_on_ready_callback(std::function<void()> &&callback);
|
||||
/** Add a callback for when a ready state changes. */
|
||||
template<typename F> void add_on_ready_callback(F &&callback) {
|
||||
this->ready_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/** A numeric representation of the supported features as per HomeAssistant
|
||||
*
|
||||
@@ -146,8 +145,8 @@ class AlarmControlPanel : public EntityBase {
|
||||
uint32_t last_update_;
|
||||
// the call control function
|
||||
virtual void control(const AlarmControlPanelCall &call) = 0;
|
||||
// state callback - triggers check get_state() for specific state
|
||||
LazyCallbackManager<void()> state_callback_{};
|
||||
// state callback - passes the new state to listeners
|
||||
LazyCallbackManager<void(AlarmControlPanelState)> state_callback_{};
|
||||
// clear callback - fires when leaving TRIGGERED state
|
||||
LazyCallbackManager<void()> cleared_callback_{};
|
||||
// chime callback
|
||||
|
||||
@@ -12,7 +12,14 @@ AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent
|
||||
|
||||
AlarmControlPanelCall &AlarmControlPanelCall::set_code(const char *code) {
|
||||
if (code != nullptr) {
|
||||
this->code_ = std::string(code);
|
||||
return this->set_code(code, strlen(code));
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
AlarmControlPanelCall &AlarmControlPanelCall::set_code(const char *code, size_t len) {
|
||||
if (code != nullptr) {
|
||||
this->code_ = std::string(code, len);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class AlarmControlPanelCall {
|
||||
AlarmControlPanelCall(AlarmControlPanel *parent);
|
||||
|
||||
AlarmControlPanelCall &set_code(const char *code);
|
||||
AlarmControlPanelCall &set_code(const std::string &code) { return this->set_code(code.c_str()); }
|
||||
AlarmControlPanelCall &set_code(const char *code, size_t len);
|
||||
AlarmControlPanelCall &set_code(const std::string &code) { return this->set_code(code.c_str(), code.size()); }
|
||||
AlarmControlPanelCall &arm_away();
|
||||
AlarmControlPanelCall &arm_home();
|
||||
AlarmControlPanelCall &arm_night();
|
||||
|
||||
@@ -5,60 +5,27 @@
|
||||
|
||||
namespace esphome::alarm_control_panel {
|
||||
|
||||
/// Trigger on any state change
|
||||
class StateTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit StateTrigger(AlarmControlPanel *alarm_control_panel) {
|
||||
alarm_control_panel->add_on_state_callback([this]() { this->trigger(); });
|
||||
/// Callback forwarder that triggers an Automation<> on any state change.
|
||||
/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
|
||||
struct StateAnyForwarder {
|
||||
Automation<> *automation;
|
||||
void operator()(AlarmControlPanelState /*state*/) const { this->automation->trigger(); }
|
||||
};
|
||||
|
||||
/// Callback forwarder that triggers an Automation<> only when the alarm enters a specific state.
|
||||
/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
|
||||
template<AlarmControlPanelState State> struct StateEnterForwarder {
|
||||
Automation<> *automation;
|
||||
void operator()(AlarmControlPanelState state) const {
|
||||
if (state == State)
|
||||
this->automation->trigger();
|
||||
}
|
||||
};
|
||||
|
||||
/// Template trigger that fires when entering a specific state
|
||||
template<AlarmControlPanelState State> class StateEnterTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {
|
||||
alarm_control_panel->add_on_state_callback([this]() {
|
||||
if (this->alarm_control_panel_->get_state() == State)
|
||||
this->trigger();
|
||||
});
|
||||
}
|
||||
|
||||
protected:
|
||||
AlarmControlPanel *alarm_control_panel_;
|
||||
};
|
||||
|
||||
// Type aliases for state-specific triggers
|
||||
using TriggeredTrigger = StateEnterTrigger<ACP_STATE_TRIGGERED>;
|
||||
using ArmingTrigger = StateEnterTrigger<ACP_STATE_ARMING>;
|
||||
using PendingTrigger = StateEnterTrigger<ACP_STATE_PENDING>;
|
||||
using ArmedHomeTrigger = StateEnterTrigger<ACP_STATE_ARMED_HOME>;
|
||||
using ArmedNightTrigger = StateEnterTrigger<ACP_STATE_ARMED_NIGHT>;
|
||||
using ArmedAwayTrigger = StateEnterTrigger<ACP_STATE_ARMED_AWAY>;
|
||||
using DisarmedTrigger = StateEnterTrigger<ACP_STATE_DISARMED>;
|
||||
|
||||
/// Trigger when leaving TRIGGERED state (alarm cleared)
|
||||
class ClearedTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) {
|
||||
alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
/// Trigger on chime event (zone opened while disarmed)
|
||||
class ChimeTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) {
|
||||
alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
/// Trigger on ready state change
|
||||
class ReadyTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) {
|
||||
alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
static_assert(sizeof(StateAnyForwarder) <= sizeof(void *));
|
||||
static_assert(std::is_trivially_copyable_v<StateAnyForwarder>);
|
||||
static_assert(sizeof(StateEnterForwarder<ACP_STATE_TRIGGERED>) <= sizeof(void *));
|
||||
static_assert(std::is_trivially_copyable_v<StateEnterForwarder<ACP_STATE_TRIGGERED>>);
|
||||
|
||||
template<typename... Ts> class ArmAwayAction : public Action<Ts...> {
|
||||
public:
|
||||
|
||||
@@ -125,7 +125,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc
|
||||
this->current_sensor_->publish_state(NAN);
|
||||
if (this->speed_sensor_ != nullptr)
|
||||
this->speed_sensor_->publish_state(NAN);
|
||||
if (this->speed_sensor_ != nullptr)
|
||||
if (this->voltage_sensor_ != nullptr)
|
||||
this->voltage_sensor_->publish_state(NAN);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ from esphome.const import (
|
||||
CONF_POWER,
|
||||
CONF_SPEED,
|
||||
CONF_VOLTAGE,
|
||||
DEVICE_CLASS_CURRENT,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_AMPERE,
|
||||
UNIT_CUBIC_METER_PER_HOUR,
|
||||
UNIT_METER,
|
||||
@@ -27,26 +31,35 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_FLOW): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR,
|
||||
accuracy_decimals=2,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_HEAD): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_METER,
|
||||
accuracy_decimals=2,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_POWER): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_WATT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_CURRENT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_SPEED): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE,
|
||||
accuracy_decimals=2,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -63,8 +63,9 @@ void Am43Component::control(const CoverCall &call) {
|
||||
ESP_LOGW(TAG, "[%s] Error writing stop command to device, error = %d", this->get_name().c_str(), status);
|
||||
}
|
||||
}
|
||||
if (call.get_position().has_value()) {
|
||||
auto pos = *call.get_position();
|
||||
auto opt_pos = call.get_position();
|
||||
if (opt_pos.has_value()) {
|
||||
auto pos = *opt_pos;
|
||||
|
||||
if (this->invert_position_)
|
||||
pos = 1 - pos;
|
||||
|
||||
@@ -8,6 +8,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_BATTERY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_BRIGHTNESS_5,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
|
||||
@@ -26,11 +27,13 @@ CONFIG_SCHEMA = (
|
||||
device_class=DEVICE_CLASS_BATTERY,
|
||||
accuracy_decimals=0,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
icon=ICON_BRIGHTNESS_5,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent
|
||||
uint8_t current_sensor_;
|
||||
// The AM43 often gets into a state where it spams loads of battery update
|
||||
// notifications. Here we will limit to no more than every 10s.
|
||||
uint8_t last_battery_update_;
|
||||
uint32_t last_battery_update_;
|
||||
};
|
||||
|
||||
} // namespace am43
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
@@ -18,8 +19,8 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
|
||||
|
||||
protected:
|
||||
sensor::Sensor *sensor_{nullptr};
|
||||
TemplatableValue<float> upper_threshold_{};
|
||||
TemplatableValue<float> lower_threshold_{};
|
||||
TemplatableFn<float> upper_threshold_{};
|
||||
TemplatableFn<float> lower_threshold_{};
|
||||
bool raw_state_{false}; // Pre-filter state for hysteresis logic
|
||||
};
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ async def to_code(config):
|
||||
cg.add(var.set_sensor(sens))
|
||||
|
||||
if isinstance(config[CONF_THRESHOLD], dict):
|
||||
lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], float)
|
||||
upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], float)
|
||||
lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_)
|
||||
upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], cg.float_)
|
||||
else:
|
||||
lower = await cg.templatable(config[CONF_THRESHOLD], [], float)
|
||||
lower = await cg.templatable(config[CONF_THRESHOLD], [], cg.float_)
|
||||
upper = lower
|
||||
cg.add(var.set_upper_threshold(upper))
|
||||
cg.add(var.set_lower_threshold(lower))
|
||||
|
||||
@@ -69,9 +69,15 @@ SET_FRAME_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action("animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA)
|
||||
@automation.register_action("animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA)
|
||||
@automation.register_action("animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA)
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -24,8 +24,9 @@ void Anova::loop() {
|
||||
}
|
||||
|
||||
void Anova::control(const ClimateCall &call) {
|
||||
if (call.get_mode().has_value()) {
|
||||
ClimateMode mode = *call.get_mode();
|
||||
auto mode_val = call.get_mode();
|
||||
if (mode_val.has_value()) {
|
||||
ClimateMode mode = *mode_val;
|
||||
AnovaPacket *pkt;
|
||||
switch (mode) {
|
||||
case climate::CLIMATE_MODE_OFF:
|
||||
@@ -45,8 +46,9 @@ void Anova::control(const ClimateCall &call) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
if (call.get_target_temperature().has_value()) {
|
||||
auto *pkt = this->codec_->get_set_target_temp_request(*call.get_target_temperature());
|
||||
auto target_temp = call.get_target_temperature();
|
||||
if (target_temp.has_value()) {
|
||||
auto *pkt = this->codec_->get_set_target_temp_request(*target_temp);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
@@ -67,10 +69,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
auto *chr = this->parent_->get_characteristic(ANOVA_SERVICE_UUID, ANOVA_CHARACTERISTIC_UUID);
|
||||
if (chr == nullptr) {
|
||||
ESP_LOGW(TAG,
|
||||
"[%s] No control service found at device, not an Anova..?\n"
|
||||
"[%s] Note, this component does not currently support Anova Nano.",
|
||||
this->get_name().c_str(), this->get_name().c_str());
|
||||
ESP_LOGW(TAG, "[%s] No control service found at device, not an Anova..?", this->get_name().c_str());
|
||||
ESP_LOGW(TAG, "[%s] Note, this component does not currently support Anova Nano.", this->get_name().c_str());
|
||||
break;
|
||||
}
|
||||
this->char_handle_ = chr->handle;
|
||||
@@ -144,9 +144,12 @@ void Anova::update() {
|
||||
return;
|
||||
|
||||
if (this->current_request_ < 2) {
|
||||
auto *pkt = this->codec_->get_read_device_status_request();
|
||||
if (this->current_request_ == 0)
|
||||
this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
|
||||
AnovaPacket *pkt;
|
||||
if (this->current_request_ == 0) {
|
||||
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
|
||||
} else {
|
||||
pkt = this->codec_->get_read_device_status_request();
|
||||
}
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/alloc_helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace anova {
|
||||
|
||||
@@ -105,14 +107,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) {
|
||||
}
|
||||
case READ_TARGET_TEMPERATURE:
|
||||
case SET_TARGET_TEMPERATURE: {
|
||||
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
|
||||
this->target_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
|
||||
if (this->fahrenheit_)
|
||||
this->target_temp_ = ftoc(this->target_temp_);
|
||||
this->has_target_temp_ = true;
|
||||
break;
|
||||
}
|
||||
case READ_CURRENT_TEMPERATURE: {
|
||||
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f);
|
||||
this->current_temp_ = parse_number<float>(str_until(buf, '\r')).value_or(0.0f); // NOLINT
|
||||
if (this->fahrenheit_)
|
||||
this->current_temp_ = ftoc(this->current_temp_);
|
||||
this->has_current_temp_ = true;
|
||||
|
||||
@@ -251,11 +251,11 @@ void APDS9960::read_gesture_data_() {
|
||||
|
||||
uint8_t buf[128];
|
||||
for (uint8_t pos = 0; pos < fifo_level * 4; pos += 32) {
|
||||
// The ESP's i2c driver has a limited buffer size.
|
||||
// This way of retrieving the data should be wrong according to the datasheet
|
||||
// but it seems to work.
|
||||
// Read in 32-byte chunks due to ESP8266 I2C buffer limit.
|
||||
// Always read from 0xFC — the FIFO auto-increments through 0xFC-0xFF
|
||||
// and advances its internal pointer after every 4th byte.
|
||||
uint8_t read = std::min(32, fifo_level * 4 - pos);
|
||||
APDS9960_WARNING_CHECK(this->read_bytes(0xFC + pos, buf + pos, read), "Reading FIFO buffer failed.");
|
||||
APDS9960_WARNING_CHECK(this->read_bytes(0xFC, buf + pos, read), "Reading FIFO buffer failed.");
|
||||
}
|
||||
|
||||
if (millis() - this->gesture_start_ > 500) {
|
||||
|
||||
@@ -76,7 +76,7 @@ SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
|
||||
"bool": cg.bool_,
|
||||
"int": cg.int32,
|
||||
"float": cg.float_,
|
||||
"string": cg.std_string,
|
||||
"string": cg.StringRef,
|
||||
"bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"),
|
||||
"int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"),
|
||||
"float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"),
|
||||
@@ -233,8 +233,8 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
|
||||
# API needs 1 listening socket + typically 3 concurrent client connections
|
||||
# (not max_connections, which is the upper limit rarely reached)
|
||||
sockets_needed = 1 + 3
|
||||
socket.consume_sockets(sockets_needed, "api")(config)
|
||||
socket.consume_sockets(3, "api")(config)
|
||||
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -291,21 +291,22 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.SplitDefault(
|
||||
CONF_MAX_CONNECTIONS,
|
||||
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
|
||||
esp32=8, # 520KB RAM available
|
||||
esp32=5, # 520KB RAM available
|
||||
rp2040=4, # 264KB RAM but LWIP constraints
|
||||
bk72xx=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
bk72xx=5, # Moderate RAM
|
||||
rtl87xx=5, # Moderate RAM
|
||||
host=8, # Abundant resources
|
||||
ln882x=8, # Moderate RAM
|
||||
ln882x=5, # Moderate RAM
|
||||
): cv.int_range(min=1, max=20),
|
||||
# Maximum queued send buffers per connection before dropping connection
|
||||
# Each buffer uses ~8-12 bytes overhead plus actual message size
|
||||
# Platform defaults based on available RAM and typical message rates:
|
||||
# CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo
|
||||
cv.SplitDefault(
|
||||
CONF_MAX_SEND_QUEUE,
|
||||
esp8266=5, # Limited RAM, need to fail fast
|
||||
esp8266=4, # Limited RAM, need to fail fast
|
||||
esp32=8, # More RAM, can buffer more
|
||||
rp2040=5, # Limited RAM
|
||||
rp2040=8, # Moderate RAM
|
||||
bk72xx=8, # Moderate RAM
|
||||
nrf52=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
@@ -335,8 +336,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
|
||||
if CONF_LISTEN_BACKLOG in config:
|
||||
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
|
||||
if CONF_MAX_CONNECTIONS in config:
|
||||
cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS]))
|
||||
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
|
||||
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
|
||||
|
||||
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
|
||||
@@ -380,9 +380,18 @@ async def to_code(config: ConfigType) -> None:
|
||||
if is_optional:
|
||||
func_args.append((cg.bool_, "return_response"))
|
||||
|
||||
# Check if action chain has non-synchronous actions that would make
|
||||
# non-owning StringRef dangle (rx_buf_ reused after delay)
|
||||
has_non_synchronous = automation.has_non_synchronous_actions(
|
||||
conf.get(CONF_THEN, [])
|
||||
)
|
||||
|
||||
service_arg_names: list[str] = []
|
||||
for name, var_ in conf[CONF_VARIABLES].items():
|
||||
native = SERVICE_ARG_NATIVE_TYPES[var_]
|
||||
# Fall back to std::string for string args if non-synchronous actions exist
|
||||
if has_non_synchronous and native is cg.StringRef:
|
||||
native = cg.std_string
|
||||
service_template_args.append(native)
|
||||
func_args.append((native, name))
|
||||
service_arg_names.append(name)
|
||||
@@ -444,7 +453,10 @@ 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")
|
||||
cg.add_library("esphome/noise-c", "0.1.10")
|
||||
cg.add_library("esphome/noise-c", "0.1.11")
|
||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||
else:
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
@@ -509,11 +521,13 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
"homeassistant.action",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"homeassistant.service",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_service_to_code(
|
||||
config: ConfigType,
|
||||
@@ -524,24 +538,31 @@ async def homeassistant_service_to_code(
|
||||
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, None)
|
||||
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)
|
||||
cg.add(var.add_data(key, templ))
|
||||
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)
|
||||
cg.add(var.add_data_template(key, templ))
|
||||
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)
|
||||
cg.add(var.add_variable(key, templ))
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
|
||||
@@ -604,29 +625,37 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
"homeassistant.event",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
||||
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, None)
|
||||
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)
|
||||
cg.add(var.add_data(key, templ))
|
||||
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)
|
||||
cg.add(var.add_data_template(key, templ))
|
||||
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)
|
||||
cg.add(var.add_variable(key, templ))
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
|
||||
@@ -644,16 +673,17 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
"homeassistant.tag_scanned",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
|
||||
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("esphome.tag_scanned"))
|
||||
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("tag_id", templ))
|
||||
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
|
||||
return var
|
||||
|
||||
|
||||
@@ -685,6 +715,7 @@ API_RESPOND_ACTION_SCHEMA = cv.All(
|
||||
"api.respond",
|
||||
APIRespondAction,
|
||||
API_RESPOND_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def api_respond_to_code(
|
||||
config: ConfigType,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
esphome/components/api/api_buffer.cpp
Normal file
13
esphome/components/api/api_buffer.cpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#include "api_buffer.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
void APIBuffer::grow_(size_t n) {
|
||||
auto new_data = make_buffer(n);
|
||||
if (this->size_)
|
||||
std::memcpy(new_data.get(), this->data_.get(), this->size_);
|
||||
this->data_ = std::move(new_data);
|
||||
this->capacity_ = n;
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
73
esphome/components/api/api_buffer.h
Normal file
73
esphome/components/api/api_buffer.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
|
||||
/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
|
||||
inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
||||
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
|
||||
return std::make_unique<uint8_t[]>(n);
|
||||
#else
|
||||
return std::make_unique_for_overwrite<uint8_t[]>(n);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Byte buffer that skips zero-initialization on resize().
|
||||
///
|
||||
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
|
||||
/// shared protobuf write buffer, every byte is overwritten by the encoder,
|
||||
/// making the zero-fill pure waste. For the receive buffer, bytes are
|
||||
/// overwritten by socket reads.
|
||||
///
|
||||
/// Designed for bulk clear/resize/overwrite patterns. grow_() allocates
|
||||
/// exactly the requested size (no growth factor) since callers resize to
|
||||
/// known sizes rather than appending incrementally.
|
||||
///
|
||||
/// Safe because: callers always write exactly the number of bytes they
|
||||
/// resize for. In the protobuf write path, debug_check_bounds_ validates
|
||||
/// writes in debug builds.
|
||||
class APIBuffer {
|
||||
public:
|
||||
void clear() { this->size_ = 0; }
|
||||
inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
|
||||
if (n > this->capacity_)
|
||||
this->grow_(n);
|
||||
}
|
||||
inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
|
||||
this->reserve(n);
|
||||
this->size_ = n; // no zero-fill
|
||||
}
|
||||
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
|
||||
/// Single grow_ check regardless of argument order.
|
||||
inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
||||
this->reserve(std::max(reserve_size, new_size));
|
||||
this->size_ = new_size;
|
||||
}
|
||||
uint8_t *data() { return this->data_.get(); }
|
||||
const uint8_t *data() const { return this->data_.get(); }
|
||||
size_t size() const { return this->size_; }
|
||||
bool empty() const { return this->size_ == 0; }
|
||||
uint8_t &operator[](size_t i) { return this->data_[i]; }
|
||||
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
|
||||
/// Release all memory (equivalent to std::vector swap trick).
|
||||
void release() {
|
||||
this->data_.reset();
|
||||
this->size_ = 0;
|
||||
this->capacity_ = 0;
|
||||
}
|
||||
|
||||
protected:
|
||||
void grow_(size_t n);
|
||||
std::unique_ptr<uint8_t[]> data_;
|
||||
size_t size_{0};
|
||||
size_t capacity_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,26 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_frame_helper.h"
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
#include "api_frame_helper_plaintext.h"
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_server.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
#include "esphome/components/esp32/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
#include "esphome/components/rp2040/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
#endif
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
@@ -32,16 +47,46 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP
|
||||
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
|
||||
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
|
||||
|
||||
#ifdef USE_BENCHMARK
|
||||
class APIConnection;
|
||||
void bench_enable_immediate_send(APIConnection *conn);
|
||||
void bench_clear_batch(APIConnection *conn);
|
||||
void bench_process_batch(APIConnection *conn);
|
||||
#endif
|
||||
|
||||
class APIConnection final : public APIServerConnectionBase {
|
||||
public:
|
||||
friend class APIServer;
|
||||
friend class ListEntitiesIterator;
|
||||
#ifdef USE_BENCHMARK
|
||||
friend void bench_enable_immediate_send(APIConnection *conn);
|
||||
friend void bench_clear_batch(APIConnection *conn);
|
||||
friend void bench_process_batch(APIConnection *conn);
|
||||
#endif
|
||||
APIConnection(std::unique_ptr<socket::Socket> socket, APIServer *parent);
|
||||
virtual ~APIConnection();
|
||||
~APIConnection();
|
||||
|
||||
void start();
|
||||
void loop();
|
||||
|
||||
protected:
|
||||
// read_message_ is defined here (instead of in APIServerConnectionBase) so the
|
||||
// compiler can devirtualize and inline on_* handler calls within this final class.
|
||||
void read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data);
|
||||
|
||||
// Auth helpers defined here (not in ProtoService) so the compiler can
|
||||
// devirtualize is_connection_setup()/on_no_setup_connection() calls
|
||||
// within this final class.
|
||||
inline bool check_connection_setup_() {
|
||||
if (!this->is_connection_setup()) {
|
||||
this->on_no_setup_connection();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
inline bool check_authenticated_() { return this->check_connection_setup_(); }
|
||||
|
||||
public:
|
||||
bool send_list_info_done() {
|
||||
return this->schedule_message_(nullptr, ListEntitiesDoneResponse::MESSAGE_TYPE,
|
||||
ListEntitiesDoneResponse::ESTIMATED_SIZE);
|
||||
@@ -51,163 +96,173 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
#ifdef USE_COVER
|
||||
bool send_cover_state(cover::Cover *cover);
|
||||
void on_cover_command_request(const CoverCommandRequest &msg) override;
|
||||
void on_cover_command_request(const CoverCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
bool send_fan_state(fan::Fan *fan);
|
||||
void on_fan_command_request(const FanCommandRequest &msg) override;
|
||||
void on_fan_command_request(const FanCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
bool send_light_state(light::LightState *light);
|
||||
void on_light_command_request(const LightCommandRequest &msg) override;
|
||||
void on_light_command_request(const LightCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
bool send_sensor_state(sensor::Sensor *sensor);
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
bool send_switch_state(switch_::Switch *a_switch);
|
||||
void on_switch_command_request(const SwitchCommandRequest &msg) override;
|
||||
void on_switch_command_request(const SwitchCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
bool send_text_sensor_state(text_sensor::TextSensor *text_sensor);
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
void set_camera_state(std::shared_ptr<camera::CameraImage> image);
|
||||
void on_camera_image_request(const CameraImageRequest &msg) override;
|
||||
void on_camera_image_request(const CameraImageRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
bool send_climate_state(climate::Climate *climate);
|
||||
void on_climate_command_request(const ClimateCommandRequest &msg) override;
|
||||
void on_climate_command_request(const ClimateCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
bool send_number_state(number::Number *number);
|
||||
void on_number_command_request(const NumberCommandRequest &msg) override;
|
||||
void on_number_command_request(const NumberCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATE
|
||||
bool send_date_state(datetime::DateEntity *date);
|
||||
void on_date_command_request(const DateCommandRequest &msg) override;
|
||||
void on_date_command_request(const DateCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_DATETIME_TIME
|
||||
bool send_time_state(datetime::TimeEntity *time);
|
||||
void on_time_command_request(const TimeCommandRequest &msg) override;
|
||||
void on_time_command_request(const TimeCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATETIME
|
||||
bool send_datetime_state(datetime::DateTimeEntity *datetime);
|
||||
void on_date_time_command_request(const DateTimeCommandRequest &msg) override;
|
||||
void on_date_time_command_request(const DateTimeCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_TEXT
|
||||
bool send_text_state(text::Text *text);
|
||||
void on_text_command_request(const TextCommandRequest &msg) override;
|
||||
void on_text_command_request(const TextCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
bool send_select_state(select::Select *select);
|
||||
void on_select_command_request(const SelectCommandRequest &msg) override;
|
||||
void on_select_command_request(const SelectCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_BUTTON
|
||||
void on_button_command_request(const ButtonCommandRequest &msg) override;
|
||||
void on_button_command_request(const ButtonCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_LOCK
|
||||
bool send_lock_state(lock::Lock *a_lock);
|
||||
void on_lock_command_request(const LockCommandRequest &msg) override;
|
||||
void on_lock_command_request(const LockCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_VALVE
|
||||
bool send_valve_state(valve::Valve *valve);
|
||||
void on_valve_command_request(const ValveCommandRequest &msg) override;
|
||||
void on_valve_command_request(const ValveCommandRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
bool send_media_player_state(media_player::MediaPlayer *media_player);
|
||||
void on_media_player_command_request(const MediaPlayerCommandRequest &msg) override;
|
||||
void on_media_player_command_request(const MediaPlayerCommandRequest &msg);
|
||||
#endif
|
||||
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return;
|
||||
this->send_message(call, HomeassistantActionRequest::MESSAGE_TYPE);
|
||||
this->send_message(call);
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg) override;
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
#endif // USE_API_HOMEASSISTANT_SERVICES
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
|
||||
void on_unsubscribe_bluetooth_le_advertisements_request() override;
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
|
||||
void on_unsubscribe_bluetooth_le_advertisements_request();
|
||||
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg) override;
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) override;
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) override;
|
||||
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) override;
|
||||
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) override;
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) override;
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override;
|
||||
void on_subscribe_bluetooth_connections_free_request() override;
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override;
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
|
||||
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg);
|
||||
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg);
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
|
||||
void on_subscribe_bluetooth_connections_free_request();
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
|
||||
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void send_time_request() {
|
||||
GetTimeRequest req;
|
||||
this->send_message(req, GetTimeRequest::MESSAGE_TYPE);
|
||||
this->send_message(req);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) override;
|
||||
void on_voice_assistant_response(const VoiceAssistantResponse &msg) override;
|
||||
void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) override;
|
||||
void on_voice_assistant_audio(const VoiceAssistantAudio &msg) override;
|
||||
void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg) override;
|
||||
void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg) override;
|
||||
void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) override;
|
||||
void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override;
|
||||
void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg);
|
||||
void on_voice_assistant_response(const VoiceAssistantResponse &msg);
|
||||
void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg);
|
||||
void on_voice_assistant_audio(const VoiceAssistantAudio &msg);
|
||||
void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg);
|
||||
void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg);
|
||||
void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg);
|
||||
void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override;
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override;
|
||||
void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg);
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#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) override;
|
||||
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_WATER_HEATER
|
||||
bool send_water_heater_state(water_heater::WaterHeater *water_heater);
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override;
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override;
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
|
||||
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg);
|
||||
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_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_EVENT
|
||||
void send_event(event::Event *event);
|
||||
#endif
|
||||
|
||||
#ifdef USE_UPDATE
|
||||
bool send_update_state(update::UpdateEntity *update);
|
||||
void on_update_command_request(const UpdateCommandRequest &msg) override;
|
||||
void on_update_command_request(const UpdateCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
void on_disconnect_response() override;
|
||||
void on_ping_response() override {
|
||||
void on_disconnect_response();
|
||||
void on_ping_response() {
|
||||
// we initiated ping
|
||||
this->flags_.sent_ping = false;
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override;
|
||||
void on_home_assistant_state_response(const HomeAssistantStateResponse &msg);
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void on_get_time_response(const GetTimeResponse &value) override;
|
||||
void on_get_time_response(const GetTimeResponse &value);
|
||||
#endif
|
||||
void on_hello_request(const HelloRequest &msg) override;
|
||||
void on_disconnect_request() override;
|
||||
void on_ping_request() override;
|
||||
void on_device_info_request() override;
|
||||
void on_list_entities_request() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() override {
|
||||
void on_hello_request(const HelloRequest &msg);
|
||||
void on_disconnect_request();
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() {
|
||||
this->flags_.state_subscription = true;
|
||||
// Start initial state iterator only if no iterator is active
|
||||
// If list_entities is running, we'll start initial_state when it completes
|
||||
@@ -215,19 +270,29 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
|
||||
}
|
||||
}
|
||||
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override {
|
||||
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
|
||||
this->flags_.log_subscription = msg.level;
|
||||
if (msg.dump_config)
|
||||
App.schedule_dump_config();
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
esp32::crash_handler_log();
|
||||
esp32::crash_handler_clear();
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
rp2040::crash_handler_log();
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
esp8266::crash_handler_log();
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; }
|
||||
void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; }
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void on_subscribe_home_assistant_states_request() override;
|
||||
void on_subscribe_home_assistant_states_request();
|
||||
#endif
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
void on_execute_service_request(const ExecuteServiceRequest &msg) override;
|
||||
void on_execute_service_request(const ExecuteServiceRequest &msg);
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
void send_execute_service_response(uint32_t call_id, bool success, StringRef error_message);
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
@@ -237,16 +302,17 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override;
|
||||
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
|
||||
bool is_authenticated() override {
|
||||
bool is_authenticated() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
|
||||
}
|
||||
bool is_connection_setup() override {
|
||||
bool is_connection_setup() {
|
||||
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
|
||||
this->is_authenticated();
|
||||
}
|
||||
bool is_marked_for_removal() const { return this->flags_.remove; }
|
||||
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
|
||||
|
||||
// Get client API version for feature detection
|
||||
@@ -255,29 +321,47 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
|
||||
}
|
||||
|
||||
void on_fatal_error() override;
|
||||
void on_no_setup_connection() override;
|
||||
bool send_message_impl(const ProtoMessage &msg, uint8_t message_type) override;
|
||||
void on_fatal_error();
|
||||
void on_no_setup_connection();
|
||||
|
||||
void prepare_first_message_buffer(std::vector<uint8_t> &shared_buf, size_t header_padding, size_t total_size) {
|
||||
// Function pointer type for type-erased message encoding
|
||||
using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
template<typename T> bool send_message(const T &msg) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
|
||||
} else {
|
||||
return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg<T>, &msg);
|
||||
}
|
||||
}
|
||||
|
||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
|
||||
shared_buf.clear();
|
||||
// Reserve space for header padding + message + footer
|
||||
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
|
||||
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
|
||||
shared_buf.reserve(total_size);
|
||||
// Resize to add header padding so message encoding starts at the correct position
|
||||
shared_buf.resize(header_padding);
|
||||
// Reserve full size but only set initial size to header padding
|
||||
// so message encoding starts at the correct position
|
||||
shared_buf.reserve_and_resize(total_size, header_padding);
|
||||
}
|
||||
|
||||
// Convenience overload - computes frame overhead internally
|
||||
void prepare_first_message_buffer(std::vector<uint8_t> &shared_buf, size_t payload_size) {
|
||||
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
|
||||
const uint8_t header_padding = this->helper_->frame_header_padding();
|
||||
const uint8_t footer_size = this->helper_->frame_footer_size();
|
||||
this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
|
||||
}
|
||||
|
||||
bool try_to_clear_buffer(bool log_out_of_space);
|
||||
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override;
|
||||
bool try_to_clear_buffer(bool log_out_of_space) {
|
||||
if (this->flags_.remove)
|
||||
return false;
|
||||
if (this->helper_->can_write_without_blocking())
|
||||
return true;
|
||||
return this->try_to_clear_buffer_slow_(log_out_of_space);
|
||||
}
|
||||
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
|
||||
|
||||
const char *get_name() const { return this->helper_->get_client_name(); }
|
||||
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
|
||||
@@ -286,6 +370,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
}
|
||||
|
||||
protected:
|
||||
bool try_to_clear_buffer_slow_(bool log_out_of_space);
|
||||
|
||||
// Helper function to handle authentication completion
|
||||
void complete_authentication_();
|
||||
|
||||
@@ -312,50 +398,112 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void process_state_subscriptions_();
|
||||
#endif
|
||||
|
||||
// Non-template helper to encode any ProtoMessage
|
||||
static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
// Helper to fill entity state base and encode message
|
||||
static uint16_t fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, uint8_t message_type,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
msg.key = entity->get_object_id_hash();
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
return encode_message_to_buffer(msg, message_type, conn, remaining_size);
|
||||
// Size thunk — converts void* back to concrete type for direct calculate_size() call
|
||||
template<typename T> static uint32_t calc_size(const void *msg) {
|
||||
return static_cast<const T *>(msg)->calculate_size();
|
||||
}
|
||||
|
||||
// Helper to fill entity info base and encode message
|
||||
static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg, uint8_t message_type,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
// Set common fields that are shared by all entity types
|
||||
msg.key = entity->get_object_id_hash();
|
||||
// Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0)
|
||||
static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return buf.get_pos();
|
||||
}
|
||||
|
||||
// API 1.14+ clients compute object_id client-side from the entity name
|
||||
// For older clients, we must send object_id for backward compatibility
|
||||
// See: https://github.com/esphome/backlog/issues/76
|
||||
// TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then
|
||||
// Buffer must remain in scope until encode_message_to_buffer is called
|
||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||
if (!conn->client_supports_api_version(1, 14)) {
|
||||
msg.object_id = entity->get_object_id_to(object_id_buf);
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
|
||||
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
|
||||
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
if (entity->has_own_name()) {
|
||||
msg.name = entity->get_name();
|
||||
}
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
// Set common EntityBase properties
|
||||
#ifdef USE_ENTITY_ICON
|
||||
msg.icon = entity->get_icon_ref();
|
||||
#endif
|
||||
msg.disabled_by_default = entity->is_disabled_by_default();
|
||||
msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category());
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
return encode_message_to_buffer(msg, message_type, conn, remaining_size);
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
|
||||
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
|
||||
static uint16_t encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg,
|
||||
APIConnection *conn, uint32_t remaining_size);
|
||||
|
||||
// Thin template wrapper — uses noinline encode_to_buffer_slow since
|
||||
// encode_message_to_buffer callers are cold paths (zero-payload control messages).
|
||||
// Hot paths (state/info) go through fill_and_encode_entity_state/info instead.
|
||||
// batch_message_type_ is already set by dispatch_message_ before reaching here.
|
||||
template<typename T> static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size);
|
||||
} else {
|
||||
return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg<T>, &msg, conn, remaining_size);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-template core — fills state fields and encodes
|
||||
static uint16_t fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
// Thin template wrapper
|
||||
template<typename T>
|
||||
static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_state(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
|
||||
}
|
||||
|
||||
// Non-template core — fills info fields, allocates buffers, and encodes
|
||||
static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg,
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
// Thin template wrapper
|
||||
template<typename T>
|
||||
static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_info(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
|
||||
}
|
||||
|
||||
// Non-template core — fills device_class, then delegates to fill_and_encode_entity_info
|
||||
static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg,
|
||||
StringRef &device_class_field, CalculateSizeFn size_fn,
|
||||
MessageEncodeFn encode_fn, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
// Thin template wrapper
|
||||
template<typename T>
|
||||
static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg,
|
||||
StringRef &device_class_field, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &calc_size<T>,
|
||||
&proto_encode_msg<T>, conn, remaining_size);
|
||||
}
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -370,6 +518,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
|
||||
}
|
||||
|
||||
// Send keepalive ping or disconnect unresponsive client.
|
||||
// Cold path — extracted from loop() to reduce instruction cache pressure.
|
||||
void __attribute__((noinline)) check_keepalive_(uint32_t now);
|
||||
|
||||
// Process active iterator (list_entities/initial_state) during connection setup.
|
||||
// Extracted from loop() — only runs during initial handshake, NONE in steady state.
|
||||
void __attribute__((noinline)) process_active_iterator_();
|
||||
@@ -460,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_INFRARED
|
||||
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
@@ -485,7 +640,13 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// === Optimal member ordering for 32-bit systems ===
|
||||
|
||||
// Group 1: Pointers (4 bytes each on 32-bit)
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
std::unique_ptr<APIFrameHelper> helper_;
|
||||
#elif defined(USE_API_NOISE)
|
||||
std::unique_ptr<APINoiseFrameHelper> helper_;
|
||||
#elif defined(USE_API_PLAINTEXT)
|
||||
std::unique_ptr<APIPlaintextFrameHelper> helper_;
|
||||
#endif
|
||||
APIServer *parent_;
|
||||
|
||||
// Group 2: Iterator union (saves ~16 bytes vs separate iterators)
|
||||
@@ -504,6 +665,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Helper methods for iterator lifecycle management
|
||||
void destroy_active_iterator_();
|
||||
void begin_iterator_(ActiveIterator type);
|
||||
void finalize_iterator_sync_();
|
||||
#ifdef USE_CAMERA
|
||||
std::unique_ptr<camera::CameraImageReader> image_reader_;
|
||||
#endif
|
||||
@@ -538,9 +700,28 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
|
||||
// Add item to the batch (with deduplication)
|
||||
void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = AUX_DATA_UNUSED);
|
||||
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
||||
// Dedup: O(n) scan but optimized for RAM over performance
|
||||
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
||||
#ifdef USE_EVENT
|
||||
if (message_type != EventResponse::MESSAGE_TYPE)
|
||||
#endif
|
||||
{
|
||||
for (const auto &item : this->items) {
|
||||
if (item.entity == entity && item.message_type == message_type)
|
||||
return; // Already queued
|
||||
}
|
||||
}
|
||||
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
||||
}
|
||||
// Add item to the front of the batch (for high priority messages like ping)
|
||||
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
|
||||
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
||||
// Swap to front avoids expensive vector::insert which shifts all elements
|
||||
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
|
||||
if (this->items.size() > 1) {
|
||||
std::swap(this->items.front(), this->items.back());
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all items
|
||||
void clear() {
|
||||
@@ -593,6 +774,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
uint8_t batch_scheduled : 1;
|
||||
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 HAS_PROTO_MESSAGE_DUMP
|
||||
uint8_t log_only_mode : 1;
|
||||
#endif
|
||||
@@ -601,11 +783,16 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// 2-byte types immediately after flags_ (no padding between them)
|
||||
uint16_t client_api_version_major_{0};
|
||||
uint16_t client_api_version_minor_{0};
|
||||
// 1-byte type to fill padding
|
||||
// 1-byte types to fill remaining space before next 4-byte boundary
|
||||
ActiveIterator active_iterator_{ActiveIterator::NONE};
|
||||
// Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary
|
||||
uint8_t batch_message_type_{0}; // Current message type during batch encoding
|
||||
// Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
|
||||
|
||||
uint32_t get_batch_delay_ms_() const;
|
||||
// Actual header size used by encode_to_buffer for the current message.
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
uint8_t batch_header_size_{0};
|
||||
|
||||
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
// Message will use 8 more bytes than the minimum size, and typical
|
||||
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
|
||||
// If its IPv6 the header is 40 bytes, and if its IPv4
|
||||
@@ -621,8 +808,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
|
||||
bool schedule_batch_();
|
||||
void process_batch_();
|
||||
void process_batch_multi_(std::vector<uint8_t> &shared_buf, size_t num_items, uint8_t header_padding,
|
||||
uint8_t footer_size) __attribute__((noinline));
|
||||
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
|
||||
__attribute__((noinline));
|
||||
void clear_batch_() {
|
||||
this->deferred_batch_.clear();
|
||||
this->flags_.batch_scheduled = false;
|
||||
@@ -672,10 +859,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
}
|
||||
|
||||
// Helper function to schedule a high priority message at the front of the batch
|
||||
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
||||
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
|
||||
return this->schedule_batch_();
|
||||
}
|
||||
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
|
||||
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
|
||||
|
||||
// Helper function to log client messages with name and peername
|
||||
void log_client_(int level, const LogString *message);
|
||||
|
||||
@@ -100,149 +100,81 @@ const LogString *api_error_to_logstr(APIError err) {
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
|
||||
// Default implementation for loop - handles sending buffered data
|
||||
APIError APIFrameHelper::loop() {
|
||||
if (this->tx_buf_count_ > 0) {
|
||||
APIError err = try_send_tx_buf_();
|
||||
if (err != APIError::OK && err != APIError::WOULD_BLOCK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination
|
||||
}
|
||||
|
||||
// Common socket write error handling
|
||||
APIError APIFrameHelper::handle_socket_write_error_() {
|
||||
if (errno == EWOULDBLOCK || errno == EAGAIN) {
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
HELPER_LOG("Socket write failed with errno %d", errno);
|
||||
this->state_ = State::FAILED;
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
|
||||
// Helper method to buffer data from IOVs
|
||||
void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len,
|
||||
uint16_t offset) {
|
||||
// Check if queue is full
|
||||
if (this->tx_buf_count_ >= API_MAX_SEND_QUEUE) {
|
||||
HELPER_LOG("Send queue full (%u buffers), dropping connection", this->tx_buf_count_);
|
||||
this->state_ = State::FAILED;
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t buffer_size = total_write_len - offset;
|
||||
auto &buffer = this->tx_buf_[this->tx_buf_tail_];
|
||||
buffer = std::make_unique<SendBuffer>(SendBuffer{
|
||||
.data = std::make_unique<uint8_t[]>(buffer_size),
|
||||
.size = buffer_size,
|
||||
.offset = 0,
|
||||
});
|
||||
|
||||
uint16_t to_skip = offset;
|
||||
uint16_t write_pos = 0;
|
||||
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
if (to_skip >= iov[i].iov_len) {
|
||||
// Skip this entire segment
|
||||
to_skip -= static_cast<uint16_t>(iov[i].iov_len);
|
||||
} else {
|
||||
// Include this segment (partially or fully)
|
||||
const uint8_t *src = reinterpret_cast<uint8_t *>(iov[i].iov_base) + to_skip;
|
||||
uint16_t len = static_cast<uint16_t>(iov[i].iov_len) - to_skip;
|
||||
std::memcpy(buffer->data.get() + write_pos, src, len);
|
||||
write_pos += len;
|
||||
to_skip = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Update circular buffer tracking
|
||||
this->tx_buf_tail_ = (this->tx_buf_tail_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->tx_buf_count_++;
|
||||
}
|
||||
|
||||
// This method writes data to socket or buffers it
|
||||
APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) {
|
||||
// Returns APIError::OK if successful (or would block, but data has been buffered)
|
||||
// Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED
|
||||
|
||||
if (iovcnt == 0)
|
||||
return APIError::OK; // Nothing to do, success
|
||||
|
||||
#ifdef HELPER_LOG_PACKETS
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
LOG_PACKET_SENDING(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
|
||||
}
|
||||
void APIFrameHelper::log_packet_sending_(const void *data, uint16_t len) {
|
||||
LOG_PACKET_SENDING(reinterpret_cast<const uint8_t *>(data), len);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Try to send any existing buffered data first if there is any
|
||||
if (this->tx_buf_count_ > 0) {
|
||||
APIError send_result = try_send_tx_buf_();
|
||||
// If real error occurred (not just WOULD_BLOCK), return it
|
||||
if (send_result != APIError::OK && send_result != APIError::WOULD_BLOCK) {
|
||||
return send_result;
|
||||
}
|
||||
|
||||
// If there is still data in the buffer, we can't send, buffer
|
||||
// the new data and return
|
||||
if (this->tx_buf_count_ > 0) {
|
||||
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0);
|
||||
return APIError::OK; // Success, data buffered
|
||||
APIError APIFrameHelper::drain_overflow_and_handle_errors_() {
|
||||
if (this->overflow_buf_.try_drain(this->socket_.get()) == -1) {
|
||||
int err = errno;
|
||||
if (err != EWOULDBLOCK && err != EAGAIN) {
|
||||
this->state_ = State::FAILED;
|
||||
HELPER_LOG("Socket write failed with errno %d", err);
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to send directly if no buffered data
|
||||
// Optimize for single iovec case (common for plaintext API)
|
||||
ssize_t sent =
|
||||
(iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt);
|
||||
|
||||
if (sent == -1) {
|
||||
APIError err = this->handle_socket_write_error_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
// Socket would block, buffer the data
|
||||
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0);
|
||||
return APIError::OK; // Success, data buffered
|
||||
}
|
||||
return err; // Socket write failed
|
||||
} else if (static_cast<uint16_t>(sent) < total_write_len) {
|
||||
// Partially sent, buffer the remaining data
|
||||
this->buffer_data_from_iov_(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent));
|
||||
}
|
||||
|
||||
return APIError::OK; // Success, all data sent or buffered
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
// Common implementation for trying to send buffered data
|
||||
// IMPORTANT: Caller MUST ensure tx_buf_count_ > 0 before calling this method
|
||||
APIError APIFrameHelper::try_send_tx_buf_() {
|
||||
// Try to send from tx_buf - we assume it's not empty as it's the caller's responsibility to check
|
||||
while (this->tx_buf_count_ > 0) {
|
||||
// Get the first buffer in the queue
|
||||
SendBuffer *front_buffer = this->tx_buf_[this->tx_buf_head_].get();
|
||||
// Single-buffer write path: wraps in iovec and delegates.
|
||||
APIError APIFrameHelper::write_raw_buf_(const void *data, uint16_t len, ssize_t sent) {
|
||||
struct iovec iov = {const_cast<void *>(data), len};
|
||||
APIError err = this->write_raw_iov_(&iov, 1, len, sent);
|
||||
#ifdef HELPER_LOG_PACKETS
|
||||
// Log after write/enqueue so re-entrant log sends can't corrupt data before it's sent
|
||||
if (err == APIError::OK)
|
||||
LOG_PACKET_SENDING(reinterpret_cast<const uint8_t *>(data), len);
|
||||
#endif
|
||||
return err;
|
||||
}
|
||||
|
||||
// Try to send the remaining data in this buffer
|
||||
ssize_t sent = this->socket_->write(front_buffer->current_data(), front_buffer->remaining());
|
||||
|
||||
if (sent == -1) {
|
||||
return this->handle_socket_write_error_();
|
||||
} else if (sent == 0) {
|
||||
// Nothing sent but not an error
|
||||
return APIError::WOULD_BLOCK;
|
||||
} else if (static_cast<uint16_t>(sent) < front_buffer->remaining()) {
|
||||
// Partially sent, update offset
|
||||
// Cast to ensure no overflow issues with uint16_t
|
||||
front_buffer->offset += static_cast<uint16_t>(sent);
|
||||
return APIError::WOULD_BLOCK; // Stop processing more buffers if we couldn't send a complete buffer
|
||||
} else {
|
||||
// Buffer completely sent, remove it from the queue
|
||||
this->tx_buf_[this->tx_buf_head_].reset();
|
||||
this->tx_buf_head_ = (this->tx_buf_head_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->tx_buf_count_--;
|
||||
// Continue loop to try sending the next buffer
|
||||
// Handles partial writes, errors, and overflow buffering.
|
||||
// Called when the inline fast path couldn't complete the write,
|
||||
// or directly from cold paths (handshake, error handling).
|
||||
APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, ssize_t sent) {
|
||||
if (sent <= 0) {
|
||||
if (sent == WRITE_NOT_ATTEMPTED) {
|
||||
// Cold path: no write attempted yet, drain overflow and try
|
||||
if (!this->overflow_buf_.empty()) {
|
||||
APIError err = this->drain_overflow_and_handle_errors_();
|
||||
if (err != APIError::OK)
|
||||
return err;
|
||||
}
|
||||
if (this->overflow_buf_.empty()) {
|
||||
sent = this->write_iov_to_socket_(iov, iovcnt);
|
||||
if (sent == static_cast<ssize_t>(total_write_len))
|
||||
return APIError::OK;
|
||||
// Partial write or -1: fall through to error check / enqueue below
|
||||
} else {
|
||||
// Overflow backlog remains after drain; skip socket write, enqueue everything
|
||||
sent = 0;
|
||||
}
|
||||
}
|
||||
// WRITE_FAILED (-1): fast path or retry write returned -1, check errno
|
||||
if (sent == WRITE_FAILED) {
|
||||
int err = errno;
|
||||
if (err != EWOULDBLOCK && err != EAGAIN) {
|
||||
this->state_ = State::FAILED;
|
||||
HELPER_LOG("Socket write failed with errno %d", err);
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
sent = 0; // Treat WOULD_BLOCK as zero bytes sent
|
||||
}
|
||||
}
|
||||
|
||||
return APIError::OK; // All buffers sent successfully
|
||||
// Full write completed (possible when called directly, not via write_raw_fast_buf_)
|
||||
if (sent == static_cast<ssize_t>(total_write_len))
|
||||
return APIError::OK;
|
||||
|
||||
// Queue unsent data into overflow buffer
|
||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
||||
HELPER_LOG("Overflow buffer full, dropping connection");
|
||||
this->state_ = State::FAILED;
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
const char *APIFrameHelper::get_peername_to(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
|
||||
@@ -278,11 +210,12 @@ APIError APIFrameHelper::init_common_() {
|
||||
|
||||
APIError APIFrameHelper::handle_socket_read_result_(ssize_t received) {
|
||||
if (received == -1) {
|
||||
if (errno == EWOULDBLOCK || errno == EAGAIN) {
|
||||
const int err = errno;
|
||||
if (err == EWOULDBLOCK || err == EAGAIN) {
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Socket read failed with errno %d", errno);
|
||||
HELPER_LOG("Socket read failed with errno %d", err);
|
||||
return APIError::SOCKET_READ_FAILED;
|
||||
} else if (received == 0) {
|
||||
state_ = State::FAILED;
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_buffer.h"
|
||||
#include "esphome/components/api/api_overflow_buffer.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "proto.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -29,12 +31,14 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 8192; // 8 KiB for ESP8266
|
||||
static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and other platforms
|
||||
#endif
|
||||
|
||||
// Extra byte reserved in rx_buf_ beyond the message size so protobuf
|
||||
// StringRef fields can be null-terminated in-place after decode.
|
||||
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
|
||||
|
||||
// Maximum number of messages to batch in a single write operation
|
||||
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
|
||||
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
|
||||
|
||||
class ProtoWriteBuffer;
|
||||
|
||||
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
|
||||
static constexpr size_t CLIENT_INFO_NAME_MAX_LEN = 32;
|
||||
|
||||
@@ -45,12 +49,17 @@ struct ReadPacketBuffer {
|
||||
};
|
||||
|
||||
// Packed message info structure to minimize memory usage
|
||||
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
|
||||
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
|
||||
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
|
||||
struct MessageInfo {
|
||||
uint16_t offset; // Offset in buffer where message starts
|
||||
uint16_t payload_size; // Size of the message payload
|
||||
uint8_t message_type; // Message type (0-255)
|
||||
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
|
||||
|
||||
MessageInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {}
|
||||
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
||||
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
|
||||
};
|
||||
|
||||
enum class APIError : uint16_t {
|
||||
@@ -101,9 +110,9 @@ class APIFrameHelper {
|
||||
}
|
||||
virtual ~APIFrameHelper() = default;
|
||||
virtual APIError init() = 0;
|
||||
virtual APIError loop();
|
||||
virtual APIError loop() = 0;
|
||||
virtual APIError read_packet(ReadPacketBuffer *buffer) = 0;
|
||||
bool can_write_without_blocking() { return this->state_ == State::DATA && this->tx_buf_count_ == 0; }
|
||||
bool can_write_without_blocking() { return this->state_ == State::DATA && this->overflow_buf_.empty(); }
|
||||
int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); }
|
||||
APIError close() {
|
||||
if (state_ == State::CLOSED)
|
||||
@@ -130,43 +139,66 @@ class APIFrameHelper {
|
||||
//
|
||||
// For log messages: Use Nagle to coalesce multiple small log packets into
|
||||
// fewer larger packets, reducing WiFi overhead. However, we limit batching
|
||||
// to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained
|
||||
// devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into
|
||||
// shared pbufs, but holding data too long waiting for Nagle's timer causes
|
||||
// buffer exhaustion and dropped messages.
|
||||
// to avoid excessive LWIP buffer pressure on memory-constrained devices.
|
||||
// LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but
|
||||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||||
// and dropped messages.
|
||||
//
|
||||
// Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all)
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||||
//
|
||||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||||
// Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all)
|
||||
//
|
||||
void set_nodelay_for_message(bool is_log_message) {
|
||||
if (!is_log_message) {
|
||||
if (this->nodelay_state_ != NODELAY_ON) {
|
||||
if (this->nodelay_counter_) {
|
||||
this->set_nodelay_raw_(true);
|
||||
this->nodelay_state_ = NODELAY_ON;
|
||||
this->nodelay_counter_ = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd)
|
||||
if (this->nodelay_state_ == NODELAY_ON) {
|
||||
// Log message: enable Nagle on first, flush after LOG_NAGLE_COUNT
|
||||
if (!this->nodelay_counter_)
|
||||
this->set_nodelay_raw_(false);
|
||||
this->nodelay_state_ = 1;
|
||||
} else if (this->nodelay_state_ >= LOG_NAGLE_COUNT) {
|
||||
if (++this->nodelay_counter_ > LOG_NAGLE_COUNT) {
|
||||
this->set_nodelay_raw_(true);
|
||||
this->nodelay_state_ = NODELAY_ON;
|
||||
} else {
|
||||
this->nodelay_state_++;
|
||||
this->nodelay_counter_ = 0;
|
||||
}
|
||||
}
|
||||
// Write a single protobuf message - the hot path (87-100% of all writes).
|
||||
// Caller must ensure state is DATA before calling.
|
||||
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
|
||||
// Write multiple protobuf messages in a single operation
|
||||
// messages contains (message_type, offset, length) for each message in the buffer
|
||||
// The buffer contains all messages with appropriate padding before each
|
||||
// Write multiple protobuf messages in a single batched operation.
|
||||
// Caller must ensure state is DATA and messages is not empty.
|
||||
// messages contains (message_type, offset, length) for each message in the buffer.
|
||||
// The buffer contains all messages with appropriate padding before each.
|
||||
virtual APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) = 0;
|
||||
// Get the frame header padding required by this protocol
|
||||
// Get the maximum frame header padding required by this protocol (worst case)
|
||||
uint8_t frame_header_padding() const { return frame_header_padding_; }
|
||||
// Get the actual frame header size for a specific message.
|
||||
// For noise: always returns frame_header_padding_ (fixed 7-byte header).
|
||||
// For plaintext: computes actual size from varint lengths (3-6 bytes).
|
||||
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
|
||||
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
|
||||
// added, this should become a virtual method.
|
||||
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
return this->frame_footer_size_
|
||||
? this->frame_header_padding_
|
||||
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||||
#elif defined(USE_API_NOISE)
|
||||
return this->frame_header_padding_;
|
||||
#else // USE_API_PLAINTEXT only
|
||||
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||||
#endif
|
||||
}
|
||||
// Get the frame footer size required by this protocol
|
||||
uint8_t frame_footer_size() const { return frame_footer_size_; }
|
||||
// Check if socket has data ready to read
|
||||
// Check if socket has buffered data ready to read.
|
||||
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
|
||||
// or track that they stopped early and retry without this check.
|
||||
// See Socket::ready() for details.
|
||||
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
|
||||
// Release excess memory from internal buffers after initial sync
|
||||
void release_buffers() {
|
||||
@@ -174,37 +206,51 @@ class APIFrameHelper {
|
||||
// rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame
|
||||
// and clearing would lose partially received data.
|
||||
if (this->rx_buf_len_ == 0) {
|
||||
// Use swap trick since shrink_to_fit() is non-binding and may be ignored
|
||||
std::vector<uint8_t>().swap(this->rx_buf_);
|
||||
this->rx_buf_.release();
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
// Buffer containing data to be sent
|
||||
struct SendBuffer {
|
||||
std::unique_ptr<uint8_t[]> data;
|
||||
uint16_t size{0}; // Total size of the buffer
|
||||
uint16_t offset{0}; // Current offset within the buffer
|
||||
// Drain backlogged overflow data to the socket and handle errors.
|
||||
// Called when overflow_buf_.empty() is false. Out-of-line to keep the
|
||||
// fast path (empty check) inline at call sites.
|
||||
// Returns OK for transient errors (WOULD_BLOCK), SOCKET_WRITE_FAILED for hard errors.
|
||||
APIError drain_overflow_and_handle_errors_();
|
||||
|
||||
// Using uint16_t reduces memory usage since ESPHome API messages are limited to UINT16_MAX (65535) bytes
|
||||
uint16_t remaining() const { return size - offset; }
|
||||
const uint8_t *current_data() const { return data.get() + offset; }
|
||||
};
|
||||
// Sentinel values for the sent parameter in write_raw_ methods
|
||||
static constexpr ssize_t WRITE_FAILED = -1; // Fast path: write()/writev() returned -1
|
||||
static constexpr ssize_t WRITE_NOT_ATTEMPTED = -2; // Cold path: no write attempted yet
|
||||
|
||||
// Common implementation for writing raw data to socket
|
||||
APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len);
|
||||
// Dispatch to write() or writev() based on iovec count
|
||||
inline ssize_t ESPHOME_ALWAYS_INLINE write_iov_to_socket_(const struct iovec *iov, int iovcnt) {
|
||||
return (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt);
|
||||
}
|
||||
|
||||
// Try to send data from the tx buffer
|
||||
APIError try_send_tx_buf_();
|
||||
|
||||
// Helper method to buffer data from IOVs
|
||||
void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t offset);
|
||||
|
||||
// Common socket write error handling
|
||||
APIError handle_socket_write_error_();
|
||||
template<typename StateEnum>
|
||||
APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector<uint8_t> &tx_buf,
|
||||
const std::string &info, StateEnum &state, StateEnum failed_state);
|
||||
// Inlined write methods — used by hot paths (write_protobuf_packet, write_protobuf_messages)
|
||||
// These inline the fast path (overflow empty + full write) and tail-call the out-of-line
|
||||
// slow path only on failure/partial write.
|
||||
inline APIError ESPHOME_ALWAYS_INLINE write_raw_fast_buf_(const void *data, uint16_t len) {
|
||||
if (this->overflow_buf_.empty()) [[likely]] {
|
||||
ssize_t sent = this->socket_->write(data, len);
|
||||
if (sent == static_cast<ssize_t>(len)) [[likely]] {
|
||||
#ifdef HELPER_LOG_PACKETS
|
||||
this->log_packet_sending_(data, len);
|
||||
#endif
|
||||
return APIError::OK;
|
||||
}
|
||||
// sent is -1 (WRITE_FAILED) or partial write count
|
||||
return this->write_raw_buf_(data, len, sent);
|
||||
}
|
||||
return this->write_raw_buf_(data, len, WRITE_NOT_ATTEMPTED);
|
||||
}
|
||||
// Out-of-line write paths: handle partial writes, errors, overflow buffering
|
||||
// sent: WRITE_NOT_ATTEMPTED (cold path), WRITE_FAILED (fast path write returned -1), or bytes sent (partial write)
|
||||
APIError write_raw_buf_(const void *data, uint16_t len, ssize_t sent = WRITE_NOT_ATTEMPTED);
|
||||
APIError write_raw_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len,
|
||||
ssize_t sent = WRITE_NOT_ATTEMPTED);
|
||||
#ifdef HELPER_LOG_PACKETS
|
||||
void log_packet_sending_(const void *data, uint16_t len);
|
||||
#endif
|
||||
|
||||
// Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit)
|
||||
std::unique_ptr<socket::Socket> socket_;
|
||||
@@ -228,9 +274,20 @@ class APIFrameHelper {
|
||||
EXPLICIT_REJECT = 8, // Noise only
|
||||
};
|
||||
|
||||
// Containers (size varies, but typically 12+ bytes on 32-bit)
|
||||
std::array<std::unique_ptr<SendBuffer>, API_MAX_SEND_QUEUE> tx_buf_;
|
||||
std::vector<uint8_t> rx_buf_;
|
||||
// Fast inline state check for read_packet/write_protobuf_messages hot path.
|
||||
// Returns OK only in DATA state; maps CLOSED/FAILED to BAD_STATE and any
|
||||
// other intermediate state to WOULD_BLOCK.
|
||||
inline APIError ESPHOME_ALWAYS_INLINE check_data_state_() const {
|
||||
if (this->state_ == State::DATA)
|
||||
return APIError::OK;
|
||||
if (this->state_ == State::CLOSED || this->state_ == State::FAILED)
|
||||
return APIError::BAD_STATE;
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
|
||||
// Backlog for unsent data when TCP send buffer is full (rarely used in production)
|
||||
APIOverflowBuffer overflow_buf_;
|
||||
APIBuffer rx_buf_;
|
||||
|
||||
// Client name buffer - stores name from Hello message or initial peername
|
||||
char client_name_[CLIENT_INFO_NAME_MAX_LEN]{};
|
||||
@@ -240,15 +297,17 @@ class APIFrameHelper {
|
||||
State state_{State::INITIALIZE};
|
||||
uint8_t frame_header_padding_{0};
|
||||
uint8_t frame_footer_size_{0};
|
||||
uint8_t tx_buf_head_{0};
|
||||
uint8_t tx_buf_tail_{0};
|
||||
uint8_t tx_buf_count_{0};
|
||||
// Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled
|
||||
// (immediate send). Values 1-2 count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset.
|
||||
static constexpr int8_t NODELAY_ON = -1;
|
||||
static constexpr int8_t LOG_NAGLE_COUNT = 2;
|
||||
int8_t nodelay_state_{NODELAY_ON};
|
||||
// Nagle batching counter for log messages. 0 means NODELAY is enabled (immediate send).
|
||||
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
|
||||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||||
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 3;
|
||||
#endif
|
||||
uint8_t nodelay_counter_{0};
|
||||
|
||||
// Internal helper to set TCP_NODELAY socket option
|
||||
void set_nodelay_raw_(bool enable) {
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace esphome::api {
|
||||
|
||||
static const char *const TAG = "api.noise";
|
||||
#ifdef USE_ESP8266
|
||||
static const char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
|
||||
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
|
||||
#else
|
||||
static const char *const PROLOGUE_INIT = "NoiseAPIInit";
|
||||
#endif
|
||||
@@ -47,15 +47,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
|
||||
format_hex_pretty_to(hex_buf_, (buffer).data(), \
|
||||
(buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \
|
||||
} while (0)
|
||||
#define LOG_PACKET_SENDING(data, len) \
|
||||
do { \
|
||||
char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \
|
||||
ESP_LOGVV(TAG, "Sending raw: %s", \
|
||||
format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \
|
||||
} while (0)
|
||||
#else
|
||||
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
|
||||
#define LOG_PACKET_SENDING(data, len) ((void) 0)
|
||||
#endif
|
||||
|
||||
/// Convert a noise error code to a readable error
|
||||
@@ -153,8 +146,10 @@ APIError APINoiseFrameHelper::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use base class implementation for buffer sending
|
||||
return APIFrameHelper::loop();
|
||||
if (!this->overflow_buf_.empty()) [[unlikely]] {
|
||||
return this->drain_overflow_and_handle_errors_();
|
||||
}
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
/** Read a packet into the rx_buf_.
|
||||
@@ -194,17 +189,20 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
||||
uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2];
|
||||
|
||||
// Check against size limits to prevent OOM: MAX_HANDSHAKE_SIZE for handshake, MAX_MESSAGE_SIZE for data
|
||||
uint16_t limit = (state_ == State::DATA) ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
|
||||
bool is_data = (state_ == State::DATA);
|
||||
uint16_t limit = is_data ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
|
||||
if (msg_size > limit) {
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, limit);
|
||||
return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
|
||||
return is_data ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
|
||||
}
|
||||
|
||||
// Reserve space for body
|
||||
if (this->rx_buf_.size() != msg_size) {
|
||||
this->rx_buf_.resize(msg_size);
|
||||
}
|
||||
// Reserve space for body (+ null terminator in DATA state so protobuf
|
||||
// StringRef fields can be safely null-terminated in-place after decode.
|
||||
// During handshake, rx_buf_.size() is used in prologue construction, so
|
||||
// the buffer must be exactly msg_size to avoid prologue mismatch.)
|
||||
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
|
||||
this->rx_buf_.resize(alloc_size);
|
||||
|
||||
if (rx_buf_len_ < msg_size) {
|
||||
// more data to read
|
||||
@@ -239,129 +237,144 @@ APIError APINoiseFrameHelper::try_read_frame_() {
|
||||
* If an error occurred, returns that error. Only returns OK if the transport is ready for data
|
||||
* traffic.
|
||||
*/
|
||||
// Split into per-state methods so the compiler doesn't allocate stack space
|
||||
// for all branches simultaneously. On RP2040 the core0 stack lives in a 4KB
|
||||
// scratch RAM bank; the Noise crypto path (curve25519) needs ~2KB+ of stack,
|
||||
// so every byte saved in the caller matters.
|
||||
APIError APINoiseFrameHelper::state_action_() {
|
||||
int err;
|
||||
APIError aerr;
|
||||
if (state_ == State::INITIALIZE) {
|
||||
HELPER_LOG("Bad state for method: %d", (int) state_);
|
||||
return APIError::BAD_STATE;
|
||||
switch (this->state_) {
|
||||
case State::INITIALIZE:
|
||||
HELPER_LOG("Bad state for method: %d", (int) this->state_);
|
||||
return APIError::BAD_STATE;
|
||||
case State::CLIENT_HELLO:
|
||||
return this->state_action_client_hello_();
|
||||
case State::SERVER_HELLO:
|
||||
return this->state_action_server_hello_();
|
||||
case State::HANDSHAKE:
|
||||
return this->state_action_handshake_();
|
||||
case State::CLOSED:
|
||||
case State::FAILED:
|
||||
return APIError::BAD_STATE;
|
||||
default:
|
||||
return APIError::OK;
|
||||
}
|
||||
if (state_ == State::CLIENT_HELLO) {
|
||||
// waiting for client hello
|
||||
aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK) {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
// ignore contents, may be used in future for flags
|
||||
// Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
this->prologue_.resize(old_size + 2 + this->rx_buf_.size());
|
||||
this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8);
|
||||
this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size();
|
||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size());
|
||||
|
||||
state_ = State::SERVER_HELLO;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
// waiting for client hello
|
||||
APIError aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK) {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
if (state_ == State::SERVER_HELLO) {
|
||||
// send server hello
|
||||
const std::string &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
|
||||
// Calculate positions and sizes
|
||||
size_t name_len = name.size() + 1; // including null terminator
|
||||
size_t name_offset = 1;
|
||||
size_t mac_offset = name_offset + name_len;
|
||||
size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE;
|
||||
|
||||
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
|
||||
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
|
||||
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
|
||||
uint8_t msg[max_msg_size];
|
||||
|
||||
// chosen proto
|
||||
msg[0] = 0x01;
|
||||
|
||||
// node name, terminated by null byte
|
||||
std::memcpy(msg + name_offset, name.c_str(), name_len);
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
aerr = write_frame_(msg, total_size);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
// ignore contents, may be used in future for flags
|
||||
// Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
size_t rx_size = this->rx_buf_.size();
|
||||
this->prologue_.resize(old_size + 2 + rx_size);
|
||||
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
|
||||
this->prologue_[old_size + 1] = (uint8_t) rx_size;
|
||||
if (rx_size > 0) {
|
||||
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
|
||||
}
|
||||
if (state_ == State::HANDSHAKE) {
|
||||
int action = noise_handshakestate_get_action(handshake_);
|
||||
if (action == NOISE_ACTION_READ_MESSAGE) {
|
||||
// waiting for handshake msg
|
||||
aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK) {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
|
||||
if (this->rx_buf_.empty()) {
|
||||
send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
|
||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||
} else if (this->rx_buf_[0] != 0x00) {
|
||||
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
|
||||
send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
|
||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||
}
|
||||
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
|
||||
err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr);
|
||||
if (err != 0) {
|
||||
// Special handling for MAC failure
|
||||
send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
|
||||
: LOG_STR("Handshake error"));
|
||||
return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
|
||||
APIError::HANDSHAKESTATE_READ_FAILED);
|
||||
}
|
||||
|
||||
aerr = check_handshake_finished_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
|
||||
uint8_t buffer[65];
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
|
||||
|
||||
err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr);
|
||||
APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
|
||||
APIError::HANDSHAKESTATE_WRITE_FAILED);
|
||||
if (aerr_write != APIError::OK)
|
||||
return aerr_write;
|
||||
buffer[0] = 0x00; // success
|
||||
|
||||
aerr = write_frame_(buffer, mbuf.size + 1);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
aerr = check_handshake_finished_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
} else {
|
||||
// bad state for action
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad action for handshake: %d", action);
|
||||
return APIError::HANDSHAKESTATE_BAD_STATE;
|
||||
}
|
||||
}
|
||||
if (state_ == State::CLOSED || state_ == State::FAILED) {
|
||||
return APIError::BAD_STATE;
|
||||
}
|
||||
state_ = State::SERVER_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// send server hello
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac);
|
||||
|
||||
// Calculate positions and sizes
|
||||
size_t name_len = name.size() + 1; // including null terminator
|
||||
size_t name_offset = 1;
|
||||
size_t mac_offset = name_offset + name_len;
|
||||
size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE;
|
||||
|
||||
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
|
||||
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
|
||||
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
|
||||
uint8_t msg[max_msg_size];
|
||||
|
||||
// chosen proto
|
||||
msg[0] = 0x01;
|
||||
|
||||
// node name, terminated by null byte
|
||||
std::memcpy(msg + name_offset, name.c_str(), name_len);
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_() {
|
||||
int action = noise_handshakestate_get_action(this->handshake_);
|
||||
if (action == NOISE_ACTION_READ_MESSAGE) {
|
||||
return this->state_action_handshake_read_();
|
||||
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
|
||||
return this->state_action_handshake_write_();
|
||||
}
|
||||
// bad state for action
|
||||
this->state_ = State::FAILED;
|
||||
HELPER_LOG("Bad action for handshake: %d", action);
|
||||
return APIError::HANDSHAKESTATE_BAD_STATE;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_read_() {
|
||||
APIError aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK) {
|
||||
return this->handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
|
||||
if (this->rx_buf_.empty()) {
|
||||
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
|
||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||
} else if (this->rx_buf_[0] != 0x00) {
|
||||
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
|
||||
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
|
||||
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
|
||||
}
|
||||
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
|
||||
int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
|
||||
if (err != 0) {
|
||||
// Special handling for MAC failure
|
||||
this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
|
||||
: LOG_STR("Handshake error"));
|
||||
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
|
||||
APIError::HANDSHAKESTATE_READ_FAILED);
|
||||
}
|
||||
|
||||
return this->check_handshake_finished_();
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_write_() {
|
||||
uint8_t buffer[65];
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
|
||||
|
||||
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
|
||||
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
|
||||
APIError::HANDSHAKESTATE_WRITE_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
buffer[0] = 0x00; // success
|
||||
|
||||
aerr = this->write_frame_(buffer, mbuf.size + 1);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
return this->check_handshake_finished_();
|
||||
}
|
||||
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
|
||||
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
|
||||
uint8_t data[32];
|
||||
@@ -370,6 +383,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
|
||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
||||
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
|
||||
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
|
||||
reason_len = std::min(reason_len, sizeof(data) - 1);
|
||||
if (reason_len > 0) {
|
||||
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
|
||||
}
|
||||
@@ -377,6 +391,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
|
||||
// Normal memory access
|
||||
const char *reason_str = LOG_STR_ARG(reason);
|
||||
size_t reason_len = strlen(reason_str);
|
||||
reason_len = std::min(reason_len, sizeof(data) - 1);
|
||||
if (reason_len > 0) {
|
||||
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
|
||||
std::memcpy(data + 1, reason_str, reason_len);
|
||||
@@ -392,14 +407,9 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
|
||||
state_ = orig_state;
|
||||
}
|
||||
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
APIError aerr = this->state_action_();
|
||||
if (aerr != APIError::OK) {
|
||||
APIError aerr = this->check_data_state_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
}
|
||||
|
||||
if (this->state_ != State::DATA) {
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
|
||||
aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK)
|
||||
@@ -407,7 +417,18 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), this->rx_buf_.size(), this->rx_buf_.size());
|
||||
// read_packet() must only be called in DATA state; the extra
|
||||
// RX_BUF_NULL_TERMINATOR byte is only allocated in DATA state
|
||||
// (see try_read_frame_), so calling this during handshake would
|
||||
// underflow the size calculation below.
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
// rx_buf_ has RX_BUF_NULL_TERMINATOR extra byte for null termination
|
||||
// (only added in DATA state — see try_read_frame_), so subtract it
|
||||
// to get the actual encrypted data size for decryption.
|
||||
size_t encrypted_size = this->rx_buf_.size() - RX_BUF_NULL_TERMINATOR;
|
||||
noise_buffer_set_inout(mbuf, this->rx_buf_.data(), encrypted_size, encrypted_size);
|
||||
int err = noise_cipherstate_decrypt(this->recv_cipher_, &mbuf);
|
||||
APIError decrypt_err =
|
||||
handle_noise_error_(err, LOG_STR("noise_cipherstate_decrypt"), APIError::CIPHERSTATE_DECRYPT_FAILED);
|
||||
@@ -436,78 +457,83 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
buffer->type = type;
|
||||
return APIError::OK;
|
||||
}
|
||||
// Encrypt a single noise message in place and return the encrypted frame length.
|
||||
// Returns APIError::OK on success.
|
||||
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
||||
uint16_t &encrypted_len_out) {
|
||||
// Write noise header
|
||||
buf_start[0] = 0x01; // indicator
|
||||
// buf_start[1], buf_start[2] to be set after encryption
|
||||
|
||||
// Write message header (to be encrypted)
|
||||
constexpr uint8_t msg_offset = 3;
|
||||
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
|
||||
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
|
||||
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
|
||||
buf_start[msg_offset + 3] = static_cast<uint8_t>(payload_size); // data_len low byte
|
||||
// payload data is already in the buffer starting at offset + 7
|
||||
|
||||
// Encrypt the message in place
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + payload_size, 4 + payload_size + this->frame_footer_size_);
|
||||
|
||||
int err = noise_cipherstate_encrypt(this->send_cipher_, &mbuf);
|
||||
APIError aerr =
|
||||
this->handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// Fill in the encrypted size
|
||||
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
|
||||
buf_start[2] = static_cast<uint8_t>(mbuf.size);
|
||||
|
||||
encrypted_len_out = static_cast<uint16_t>(3 + mbuf.size); // indicator + size + encrypted data
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
||||
// Resize to include MAC space (required for Noise encryption)
|
||||
buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_);
|
||||
MessageInfo msg{type, 0,
|
||||
static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)};
|
||||
return write_protobuf_messages(buffer, std::span<const MessageInfo>(&msg, 1));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
|
||||
// Resize buffer to include footer space for Noise MAC
|
||||
if (this->frame_footer_size_)
|
||||
buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
|
||||
|
||||
uint16_t payload_size =
|
||||
static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
|
||||
uint8_t *buf_start = buffer.get_buffer()->data();
|
||||
uint16_t encrypted_len;
|
||||
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
return this->write_raw_fast_buf_(buf_start, encrypted_len);
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) {
|
||||
APIError aerr = state_action_();
|
||||
if (aerr != APIError::OK) {
|
||||
return aerr;
|
||||
}
|
||||
|
||||
if (state_ != State::DATA) {
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
|
||||
if (messages.empty()) {
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
assert(!messages.empty());
|
||||
#endif
|
||||
|
||||
// Noise messages are already contiguous in the buffer:
|
||||
// HEADER_PADDING (7) exactly matches the fixed header size, and
|
||||
// footer space (16) is consumed by the encryption MAC.
|
||||
uint8_t *buffer_data = buffer.get_buffer()->data();
|
||||
|
||||
// Stack-allocated iovec array - no heap allocation
|
||||
StaticVector<struct iovec, MAX_MESSAGES_PER_BATCH> iovs;
|
||||
uint8_t *write_start = buffer_data + messages[0].offset;
|
||||
uint16_t total_write_len = 0;
|
||||
|
||||
// We need to encrypt each message in place
|
||||
for (const auto &msg : messages) {
|
||||
// The buffer already has padding at offset
|
||||
uint8_t *buf_start = buffer_data + msg.offset;
|
||||
|
||||
// Write noise header
|
||||
buf_start[0] = 0x01; // indicator
|
||||
// buf_start[1], buf_start[2] to be set after encryption
|
||||
|
||||
// Write message header (to be encrypted)
|
||||
const uint8_t msg_offset = 3;
|
||||
buf_start[msg_offset] = static_cast<uint8_t>(msg.message_type >> 8); // type high byte
|
||||
buf_start[msg_offset + 1] = static_cast<uint8_t>(msg.message_type); // type low byte
|
||||
buf_start[msg_offset + 2] = static_cast<uint8_t>(msg.payload_size >> 8); // data_len high byte
|
||||
buf_start[msg_offset + 3] = static_cast<uint8_t>(msg.payload_size); // data_len low byte
|
||||
// payload data is already in the buffer starting at offset + 7
|
||||
|
||||
// Make sure we have space for MAC
|
||||
// The buffer should already have been sized appropriately
|
||||
|
||||
// Encrypt the message in place
|
||||
NoiseBuffer mbuf;
|
||||
noise_buffer_init(mbuf);
|
||||
noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size,
|
||||
4 + msg.payload_size + frame_footer_size_);
|
||||
|
||||
int err = noise_cipherstate_encrypt(send_cipher_, &mbuf);
|
||||
APIError aerr =
|
||||
handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED);
|
||||
uint16_t encrypted_len;
|
||||
APIError aerr = this->encrypt_noise_message_(buf_start, msg.payload_size, msg.message_type, encrypted_len);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// Fill in the encrypted size
|
||||
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
|
||||
buf_start[2] = static_cast<uint8_t>(mbuf.size);
|
||||
|
||||
// Add iovec for this encrypted message
|
||||
size_t msg_len = static_cast<size_t>(3 + mbuf.size); // indicator + size + encrypted data
|
||||
iovs.push_back({buf_start, msg_len});
|
||||
total_write_len += msg_len;
|
||||
total_write_len += encrypted_len;
|
||||
}
|
||||
|
||||
// Send all encrypted messages in one writev call
|
||||
return this->write_raw_(iovs.data(), iovs.size(), total_write_len);
|
||||
return this->write_raw_fast_buf_(write_start, total_write_len);
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
@@ -516,16 +542,16 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
|
||||
header[1] = (uint8_t) (len >> 8);
|
||||
header[2] = (uint8_t) len;
|
||||
|
||||
if (len == 0) {
|
||||
return this->write_raw_buf_(header, 3);
|
||||
}
|
||||
struct iovec iov[2];
|
||||
iov[0].iov_base = header;
|
||||
iov[0].iov_len = 3;
|
||||
if (len == 0) {
|
||||
return this->write_raw_(iov, 1, 3); // Just header
|
||||
}
|
||||
iov[1].iov_base = const_cast<uint8_t *>(data);
|
||||
iov[1].iov_len = len;
|
||||
|
||||
return this->write_raw_(iov, 2, 3 + len); // Header + data
|
||||
return this->write_raw_iov_(iov, 2, 3 + len);
|
||||
}
|
||||
|
||||
/** Initiate the data structures for the handshake.
|
||||
@@ -563,8 +589,7 @@ APIError APINoiseFrameHelper::init_handshake_() {
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
// set_prologue copies it into handshakestate, so we can get rid of it now
|
||||
// Use swap idiom to actually release memory (= {} only clears size, not capacity)
|
||||
std::vector<uint8_t>().swap(prologue_);
|
||||
prologue_.release();
|
||||
|
||||
err = noise_handshakestate_start(handshake_);
|
||||
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
@@ -574,7 +599,9 @@ APIError APINoiseFrameHelper::init_handshake_() {
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::check_handshake_finished_() {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(state_ == State::HANDSHAKE);
|
||||
#endif
|
||||
|
||||
int action = noise_handshakestate_get_action(handshake_);
|
||||
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
|
||||
@@ -590,7 +617,7 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
|
||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
|
||||
|
||||
HELPER_LOG("Handshake complete!");
|
||||
noise_handshakestate_free(handshake_);
|
||||
|
||||
@@ -9,14 +9,16 @@ namespace esphome::api {
|
||||
|
||||
class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
public:
|
||||
// Noise header structure:
|
||||
// Pos 0: indicator (0x01)
|
||||
// Pos 1-2: encrypted payload size (16-bit big-endian)
|
||||
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
|
||||
// Pos 7+: actual payload data
|
||||
static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
|
||||
|
||||
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, APINoiseContext &ctx)
|
||||
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
|
||||
// Noise header structure:
|
||||
// Pos 0: indicator (0x01)
|
||||
// Pos 1-2: encrypted payload size (16-bit big-endian)
|
||||
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
|
||||
// Pos 7+: actual payload data
|
||||
frame_header_padding_ = 7;
|
||||
frame_header_padding_ = HEADER_PADDING;
|
||||
}
|
||||
~APINoiseFrameHelper() override;
|
||||
APIError init() override;
|
||||
@@ -27,8 +29,15 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
|
||||
protected:
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
APIError state_action_handshake_();
|
||||
APIError state_action_handshake_read_();
|
||||
APIError state_action_handshake_write_();
|
||||
APIError try_read_frame_();
|
||||
APIError write_frame_(const uint8_t *data, uint16_t len);
|
||||
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
||||
uint16_t &encrypted_len_out);
|
||||
APIError init_handshake_();
|
||||
APIError check_handshake_finished_();
|
||||
void send_explicit_handshake_reject_(const LogString *reason);
|
||||
@@ -43,8 +52,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
// Reference to noise context (4 bytes on 32-bit)
|
||||
APINoiseContext &ctx_;
|
||||
|
||||
// Vector (12 bytes on 32-bit)
|
||||
std::vector<uint8_t> prologue_;
|
||||
// Buffer for noise handshake prologue (released after handshake)
|
||||
APIBuffer prologue_;
|
||||
|
||||
// NoiseProtocolId (size depends on implementation)
|
||||
NoiseProtocolId nid_;
|
||||
|
||||
@@ -39,15 +39,8 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
|
||||
format_hex_pretty_to(hex_buf_, (buffer).data(), \
|
||||
(buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \
|
||||
} while (0)
|
||||
#define LOG_PACKET_SENDING(data, len) \
|
||||
do { \
|
||||
char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \
|
||||
ESP_LOGVV(TAG, "Sending raw: %s", \
|
||||
format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \
|
||||
} while (0)
|
||||
#else
|
||||
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
|
||||
#define LOG_PACKET_SENDING(data, len) ((void) 0)
|
||||
#endif
|
||||
|
||||
/// Initialize the frame helper, returns OK if successful.
|
||||
@@ -64,8 +57,10 @@ APIError APIPlaintextFrameHelper::loop() {
|
||||
if (state_ != State::DATA) {
|
||||
return APIError::BAD_STATE;
|
||||
}
|
||||
// Use base class implementation for buffer sending
|
||||
return APIFrameHelper::loop();
|
||||
if (!this->overflow_buf_.empty()) [[unlikely]] {
|
||||
return this->drain_overflow_and_handle_errors_();
|
||||
}
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
/** Read a packet into the rx_buf_.
|
||||
@@ -128,45 +123,44 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
|
||||
// Skip indicator byte at position 0
|
||||
uint8_t varint_pos = 1;
|
||||
uint32_t consumed = 0;
|
||||
|
||||
auto msg_size_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed);
|
||||
// rx_header_buf_pos_ >= 3 and varint_pos == 1, so len >= 2
|
||||
auto msg_size_varint = ProtoVarInt::parse_non_empty(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos);
|
||||
if (!msg_size_varint.has_value()) {
|
||||
// not enough data there yet
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) {
|
||||
if (msg_size_varint.value > MAX_MESSAGE_SIZE) {
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(),
|
||||
MAX_MESSAGE_SIZE);
|
||||
HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u",
|
||||
static_cast<uint32_t>(msg_size_varint.value), MAX_MESSAGE_SIZE);
|
||||
return APIError::BAD_DATA_PACKET;
|
||||
}
|
||||
rx_header_parsed_len_ = msg_size_varint->as_uint16();
|
||||
rx_header_parsed_len_ = static_cast<uint16_t>(msg_size_varint.value);
|
||||
|
||||
// Move to next varint position
|
||||
varint_pos += consumed;
|
||||
varint_pos += msg_size_varint.consumed;
|
||||
|
||||
auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed);
|
||||
auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos);
|
||||
if (!msg_type_varint.has_value()) {
|
||||
// not enough data there yet
|
||||
continue;
|
||||
}
|
||||
if (msg_type_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) {
|
||||
if (msg_type_varint.value > std::numeric_limits<uint16_t>::max()) {
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", msg_type_varint->as_uint32(),
|
||||
std::numeric_limits<uint16_t>::max());
|
||||
HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u",
|
||||
static_cast<uint32_t>(msg_type_varint.value), std::numeric_limits<uint16_t>::max());
|
||||
return APIError::BAD_DATA_PACKET;
|
||||
}
|
||||
rx_header_parsed_type_ = msg_type_varint->as_uint16();
|
||||
rx_header_parsed_type_ = static_cast<uint16_t>(msg_type_varint.value);
|
||||
rx_header_parsed_ = true;
|
||||
}
|
||||
// header reading done
|
||||
|
||||
// Reserve space for body
|
||||
if (this->rx_buf_.size() != this->rx_header_parsed_len_) {
|
||||
this->rx_buf_.resize(this->rx_header_parsed_len_);
|
||||
}
|
||||
// Reserve space for body (+ null terminator so protobuf StringRef fields
|
||||
// can be safely null-terminated in-place after decode)
|
||||
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
|
||||
|
||||
if (rx_buf_len_ < rx_header_parsed_len_) {
|
||||
// more data to read
|
||||
@@ -194,17 +188,16 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
}
|
||||
|
||||
APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
if (this->state_ != State::DATA) {
|
||||
return APIError::WOULD_BLOCK;
|
||||
}
|
||||
APIError aerr = this->check_data_state_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
APIError aerr = this->try_read_frame_();
|
||||
aerr = this->try_read_frame_();
|
||||
if (aerr != APIError::OK) {
|
||||
if (aerr == APIError::BAD_INDICATOR) {
|
||||
// Make sure to tell the remote that we don't
|
||||
// understand the indicator byte so it knows
|
||||
// we do not support it.
|
||||
struct iovec iov[1];
|
||||
// The \x00 first byte is the marker for plaintext.
|
||||
//
|
||||
// The remote will know how to handle the indicator byte,
|
||||
@@ -219,14 +212,12 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
"Bad indicator byte";
|
||||
char msg[INDICATOR_MSG_SIZE];
|
||||
memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE);
|
||||
iov[0].iov_base = (void *) msg;
|
||||
this->write_raw_buf_(msg, INDICATOR_MSG_SIZE);
|
||||
#else
|
||||
static const char MSG[] = "\x00"
|
||||
"Bad indicator byte";
|
||||
iov[0].iov_base = (void *) MSG;
|
||||
this->write_raw_buf_(MSG, INDICATOR_MSG_SIZE);
|
||||
#endif
|
||||
iov[0].iov_len = INDICATOR_MSG_SIZE;
|
||||
this->write_raw_(iov, 1, INDICATOR_MSG_SIZE);
|
||||
}
|
||||
return aerr;
|
||||
}
|
||||
@@ -236,76 +227,101 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
buffer->type = this->rx_header_parsed_type_;
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
// Encode a 16-bit varint (1-3 bytes) using pre-computed length.
|
||||
ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_t varint_len, uint8_t *p) {
|
||||
if (varint_len >= 2) {
|
||||
*p++ = static_cast<uint8_t>(value | 0x80);
|
||||
value >>= 7;
|
||||
if (varint_len == 3) {
|
||||
*p++ = static_cast<uint8_t>(value | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
}
|
||||
*p = static_cast<uint8_t>(value);
|
||||
}
|
||||
|
||||
// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
|
||||
ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
|
||||
if (varint_len == 2) {
|
||||
*p++ = static_cast<uint8_t>(value | 0x80);
|
||||
*p = static_cast<uint8_t>(value >> 7);
|
||||
} else {
|
||||
*p = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Write plaintext header into pre-allocated padding before payload.
|
||||
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
|
||||
// actual header size for contiguous batch messages).
|
||||
// Returns the total header length (indicator + varints).
|
||||
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
|
||||
uint8_t message_type, uint8_t padding_size) {
|
||||
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
|
||||
uint8_t type_varint_len = ProtoSize::varint8(message_type);
|
||||
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
|
||||
|
||||
// The header is right-justified within the padding so it sits immediately before payload.
|
||||
//
|
||||
// Single/first message (padding_size = HEADER_PADDING = 6):
|
||||
// Example (small, header=3): [0-2] unused | [3] 0x00 | [4] size | [5] type | [6...] payload
|
||||
// Example (medium, header=4): [0-1] unused | [2] 0x00 | [3-4] size | [5] type | [6...] payload
|
||||
// Example (large, header=6): [0] 0x00 | [1-3] size | [4-5] type | [6...] payload
|
||||
//
|
||||
// Batch messages 2+ (padding_size = actual header size, no unused bytes):
|
||||
// Example (small, header=3): [0] 0x00 | [1] size | [2] type | [3...] payload
|
||||
// Example (medium, header=4): [0] 0x00 | [1-2] size | [3] type | [4...] payload
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(padding_size >= total_header_len);
|
||||
#endif
|
||||
uint32_t header_offset = padding_size - total_header_len;
|
||||
|
||||
// Write the plaintext header
|
||||
buf_start[header_offset] = 0x00; // indicator
|
||||
|
||||
// Encode varints directly into buffer using pre-computed lengths
|
||||
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
|
||||
encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
|
||||
|
||||
return total_header_len;
|
||||
}
|
||||
|
||||
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
||||
MessageInfo msg{type, 0, static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_)};
|
||||
return write_protobuf_messages(buffer, std::span<const MessageInfo>(&msg, 1));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
|
||||
uint16_t payload_size = static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING);
|
||||
uint8_t *buffer_data = buffer.get_buffer()->data();
|
||||
uint8_t header_len = write_plaintext_header(buffer_data, payload_size, type, HEADER_PADDING);
|
||||
return this->write_raw_fast_buf_(buffer_data + HEADER_PADDING - header_len,
|
||||
static_cast<uint16_t>(header_len + payload_size));
|
||||
}
|
||||
|
||||
APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer,
|
||||
std::span<const MessageInfo> messages) {
|
||||
if (state_ != State::DATA) {
|
||||
return APIError::BAD_STATE;
|
||||
}
|
||||
|
||||
if (messages.empty()) {
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
assert(!messages.empty());
|
||||
#endif
|
||||
uint8_t *buffer_data = buffer.get_buffer()->data();
|
||||
|
||||
// Stack-allocated iovec array - no heap allocation
|
||||
StaticVector<struct iovec, MAX_MESSAGES_PER_BATCH> iovs;
|
||||
uint16_t total_write_len = 0;
|
||||
// First message has max padding (header_size = HEADER_PADDING), may have unused leading bytes.
|
||||
// Subsequent messages were encoded with exact header sizes (header_size = actual header len).
|
||||
// write_plaintext_header right-justifies the header within header_size bytes of padding.
|
||||
const auto &first = messages[0];
|
||||
uint8_t *first_start = buffer_data + first.offset;
|
||||
uint8_t header_len = write_plaintext_header(first_start, first.payload_size, first.message_type, HEADER_PADDING);
|
||||
uint8_t *write_start = first_start + HEADER_PADDING - header_len;
|
||||
uint16_t total_len = header_len + first.payload_size;
|
||||
|
||||
for (const auto &msg : messages) {
|
||||
// Calculate varint sizes for header layout
|
||||
uint8_t size_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(msg.payload_size));
|
||||
uint8_t type_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(msg.message_type));
|
||||
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
|
||||
|
||||
// Calculate where to start writing the header
|
||||
// The header starts at the latest possible position to minimize unused padding
|
||||
//
|
||||
// Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3
|
||||
// [0-2] - Unused padding
|
||||
// [3] - 0x00 indicator byte
|
||||
// [4] - Payload size varint (1 byte, for sizes 0-127)
|
||||
// [5] - Message type varint (1 byte, for types 0-127)
|
||||
// [6...] - Actual payload data
|
||||
//
|
||||
// Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2
|
||||
// [0-1] - Unused padding
|
||||
// [2] - 0x00 indicator byte
|
||||
// [3-4] - Payload size varint (2 bytes, for sizes 128-16383)
|
||||
// [5] - Message type varint (1 byte, for types 0-127)
|
||||
// [6...] - Actual payload data
|
||||
//
|
||||
// Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0
|
||||
// [0] - 0x00 indicator byte
|
||||
// [1-3] - Payload size varint (3 bytes, for sizes 16384-2097151)
|
||||
// [4-5] - Message type varint (2 bytes, for types 128-32767)
|
||||
// [6...] - Actual payload data
|
||||
//
|
||||
// The message starts at offset + frame_header_padding_
|
||||
// So we write the header starting at offset + frame_header_padding_ - total_header_len
|
||||
uint8_t *buf_start = buffer_data + msg.offset;
|
||||
uint32_t header_offset = frame_header_padding_ - total_header_len;
|
||||
|
||||
// Write the plaintext header
|
||||
buf_start[header_offset] = 0x00; // indicator
|
||||
|
||||
// Encode varints directly into buffer
|
||||
encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1);
|
||||
encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len);
|
||||
|
||||
// Add iovec for this message (header + payload)
|
||||
size_t msg_len = static_cast<size_t>(total_header_len + msg.payload_size);
|
||||
iovs.push_back({buf_start + header_offset, msg_len});
|
||||
total_write_len += msg_len;
|
||||
for (size_t i = 1; i < messages.size(); i++) {
|
||||
const auto &msg = messages[i];
|
||||
header_len = write_plaintext_header(buffer_data + msg.offset, msg.payload_size, msg.message_type, msg.header_size);
|
||||
total_len += header_len + msg.payload_size;
|
||||
}
|
||||
|
||||
// Send all messages in one writev call
|
||||
return write_raw_(iovs.data(), iovs.size(), total_write_len);
|
||||
return this->write_raw_fast_buf_(write_start, total_len);
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -7,13 +7,15 @@ namespace esphome::api {
|
||||
|
||||
class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
public:
|
||||
// Plaintext header structure (worst case):
|
||||
// Pos 0: indicator (0x00)
|
||||
// Pos 1-3: payload size varint (up to 3 bytes)
|
||||
// Pos 4-5: message type varint (up to 2 bytes)
|
||||
// Pos 6+: actual payload data
|
||||
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
|
||||
|
||||
explicit APIPlaintextFrameHelper(std::unique_ptr<socket::Socket> socket) : APIFrameHelper(std::move(socket)) {
|
||||
// Plaintext header structure (worst case):
|
||||
// Pos 0: indicator (0x00)
|
||||
// Pos 1-3: payload size varint (up to 3 bytes)
|
||||
// Pos 4-5: message type varint (up to 2 bytes)
|
||||
// Pos 6+: actual payload data
|
||||
frame_header_padding_ = 6;
|
||||
frame_header_padding_ = HEADER_PADDING;
|
||||
}
|
||||
~APIPlaintextFrameHelper() override = default;
|
||||
APIError init() override;
|
||||
|
||||
@@ -22,6 +22,8 @@ extend google.protobuf.MessageOptions {
|
||||
optional bool log = 1039 [default=true];
|
||||
optional bool no_delay = 1040 [default=false];
|
||||
optional string base_class = 1041;
|
||||
optional bool inline_encode = 1042 [default=false];
|
||||
optional bool speed_optimized = 1043 [default=false];
|
||||
}
|
||||
|
||||
extend google.protobuf.FieldOptions {
|
||||
@@ -90,4 +92,22 @@ extend google.protobuf.FieldOptions {
|
||||
// - uint16_t <field>_length_{0};
|
||||
// - uint16_t <field>_count_{0};
|
||||
optional bool packed_buffer = 50015 [default=false];
|
||||
|
||||
// force: Always encode this field, even when its value equals the proto3 default.
|
||||
// Skips the zero/empty check in calculate_size() and encode(), using the _force
|
||||
// variant of the calc_ method. Use on fields that are almost always non-default
|
||||
// to eliminate dead branches on hot paths.
|
||||
optional bool force = 50016 [default=false];
|
||||
|
||||
// max_value: Maximum value a field can have.
|
||||
// When max_value < 128, the code generator emits constant-size calculations
|
||||
// and direct byte writes instead of varint branching, since the encoded varint
|
||||
// is guaranteed to be 1 byte.
|
||||
optional uint32 max_value = 50017;
|
||||
|
||||
// max_data_length: Maximum length of a string or bytes field.
|
||||
// When max_data_length < 128, the code generator emits constant-size
|
||||
// length varint calculations and direct byte writes, since the length
|
||||
// varint is guaranteed to be 1 byte.
|
||||
optional uint32 max_data_length = 50018;
|
||||
}
|
||||
|
||||
73
esphome/components/api/api_overflow_buffer.cpp
Normal file
73
esphome/components/api/api_overflow_buffer.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#include "api_overflow_buffer.h"
|
||||
#ifdef USE_API
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
APIOverflowBuffer::~APIOverflowBuffer() {
|
||||
for (auto *entry : this->queue_) {
|
||||
if (entry != nullptr)
|
||||
Entry::destroy(entry);
|
||||
}
|
||||
}
|
||||
|
||||
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
|
||||
while (this->count_ > 0) {
|
||||
Entry *front = this->queue_[this->head_];
|
||||
|
||||
ssize_t sent = socket->write(front->current_data(), front->remaining());
|
||||
|
||||
if (sent <= 0) {
|
||||
// -1 = error (caller checks errno for EWOULDBLOCK vs hard error)
|
||||
// 0 = nothing sent (treat as no progress)
|
||||
return sent;
|
||||
}
|
||||
|
||||
if (static_cast<uint16_t>(sent) < front->remaining()) {
|
||||
// Partially sent, update offset and stop
|
||||
front->offset += static_cast<uint16_t>(sent);
|
||||
return sent;
|
||||
}
|
||||
|
||||
// Entry fully sent — free it and advance
|
||||
Entry::destroy(front);
|
||||
this->queue_[this->head_] = nullptr;
|
||||
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_--;
|
||||
}
|
||||
|
||||
return 0; // All drained
|
||||
}
|
||||
|
||||
bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) {
|
||||
if (this->count_ >= API_MAX_SEND_QUEUE)
|
||||
return false;
|
||||
|
||||
uint16_t buffer_size = total_len - skip;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
|
||||
this->queue_[this->tail_] = entry;
|
||||
|
||||
uint16_t to_skip = skip;
|
||||
uint16_t write_pos = 0;
|
||||
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
if (to_skip >= iov[i].iov_len) {
|
||||
to_skip -= static_cast<uint16_t>(iov[i].iov_len);
|
||||
} else {
|
||||
const uint8_t *src = reinterpret_cast<uint8_t *>(iov[i].iov_base) + to_skip;
|
||||
uint16_t len = static_cast<uint16_t>(iov[i].iov_len) - to_skip;
|
||||
std::memcpy(entry->data + write_pos, src, len);
|
||||
write_pos += len;
|
||||
to_skip = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_++;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API
|
||||
76
esphome/components/api/api_overflow_buffer.h
Normal file
76
esphome/components/api/api_overflow_buffer.h
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
|
||||
#include "esphome/components/socket/headers.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
/// Circular queue of heap-allocated byte buffers used as a TCP send backlog.
|
||||
///
|
||||
/// Under normal operation this buffer is **never used** — data goes straight
|
||||
/// from the frame helper to the socket. It only fills when the LWIP TCP
|
||||
/// send buffer is full (slow client, congested network, heavy logging).
|
||||
/// The queue drains automatically on subsequent write/loop calls once the
|
||||
/// socket becomes writable again.
|
||||
///
|
||||
/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python
|
||||
/// config). If the queue fills completely the connection is marked failed.
|
||||
class APIOverflowBuffer {
|
||||
public:
|
||||
/// A single heap-allocated send-backlog entry.
|
||||
/// Lifetime is manually managed — see destroy().
|
||||
struct Entry {
|
||||
uint8_t *data;
|
||||
uint16_t size; // Total size of the buffer
|
||||
uint16_t offset; // Current send offset within the buffer
|
||||
|
||||
uint16_t remaining() const { return this->size - this->offset; }
|
||||
const uint8_t *current_data() const { return this->data + this->offset; }
|
||||
|
||||
/// Free this entry and its data buffer.
|
||||
static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) {
|
||||
delete[] entry->data;
|
||||
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
};
|
||||
|
||||
~APIOverflowBuffer();
|
||||
|
||||
/// True when no backlogged data is waiting.
|
||||
bool empty() const { return this->count_ == 0; }
|
||||
|
||||
/// True when the queue has no room for another entry.
|
||||
bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; }
|
||||
|
||||
/// Number of entries currently queued.
|
||||
uint8_t count() const { return this->count_; }
|
||||
|
||||
/// Try to drain queued data to the socket.
|
||||
/// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress,
|
||||
/// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors).
|
||||
/// Callers only need to act on -1; 0 and positive values both mean "no error".
|
||||
/// Frees entries as they are fully sent.
|
||||
ssize_t try_drain(socket::Socket *socket);
|
||||
|
||||
/// Enqueue unsent IOV data into the backlog.
|
||||
/// Copies iov data starting at byte offset `skip` into a new entry.
|
||||
/// Returns false if the queue is full (caller should fail the connection).
|
||||
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
|
||||
|
||||
protected:
|
||||
std::array<Entry *, API_MAX_SEND_QUEUE> queue_{};
|
||||
uint8_t head_{0};
|
||||
uint8_t tail_{0};
|
||||
uint8_t count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
#endif // USE_API
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
12
esphome/components/api/api_pb2_defines.h
Normal file
12
esphome/components/api/api_pb2_defines.h
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file was automatically generated with a tool.
|
||||
// See script/api_protobuf/api_protobuf.py
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace esphome::api {} // namespace esphome::api
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user