mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 12:06:01 +00:00
Compare commits
890
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
923be0736b | ||
|
|
4a815946e1 | ||
|
|
c754727189 | ||
|
|
1e1b591a70 | ||
|
|
cef852f77e | ||
|
|
cb85c8d5a4 | ||
|
|
c290a508f6 | ||
|
|
17defaf361 | ||
|
|
27f05f01cf | ||
|
|
0aa6c6cc4d | ||
|
|
2031be0c23 | ||
|
|
708b7bfb53 | ||
|
|
7782bc11c6 | ||
|
|
a9a66baeba | ||
|
|
16d0fa1165 | ||
|
|
25d5cd3e14 | ||
|
|
9598449b6b | ||
|
|
a8c7279b3b | ||
|
|
c3c69c730c | ||
|
|
a8c8827a5b | ||
|
|
18b7002604 | ||
|
|
51105a25db | ||
|
|
fb132b4421 | ||
|
|
20b38ac8b0 | ||
|
|
cdd892a526 | ||
|
|
b99e7f5ae2 | ||
|
|
e55a8aeabe | ||
|
|
a8094ed548 | ||
|
|
2104096f02 | ||
|
|
e53870085b | ||
|
|
b62305793d | ||
|
|
a1b29b0d0b | ||
|
|
e458a38f89 | ||
|
|
39177402dd | ||
|
|
3361d031de | ||
|
|
5df1c7f1d3 | ||
|
|
9508165797 | ||
|
|
5878406918 | ||
|
|
fedd46e648 | ||
|
|
d28b17c619 | ||
|
|
f17ef133f3 | ||
|
|
34e7453677 | ||
|
|
8a89f3075f | ||
|
|
5328813814 | ||
|
|
1cbe3a49b2 | ||
|
|
5c79c92c06 | ||
|
|
7b4894da03 | ||
|
|
4eb85a24c2 | ||
|
|
1e7c48e2cf | ||
|
|
6ef13cc8de | ||
|
|
e37a540fb7 | ||
|
|
9baff76520 | ||
|
|
612d58ec37 | ||
|
|
cb981c2930 | ||
|
|
8b8de0c9c6 | ||
|
|
9e4c52989c | ||
|
|
7ff56c62f9 | ||
|
|
370e8fffed | ||
|
|
c272c4c1a6 | ||
|
|
150f75d8f6 | ||
|
|
cf5ec2d277 | ||
|
|
145373ed13 | ||
|
|
8749c3d18d | ||
|
|
688e833413 | ||
|
|
19b434f99c | ||
|
|
0b78800e3d | ||
|
|
6a86ef0ee0 | ||
|
|
4eba9ee41a | ||
|
|
b579751bdf | ||
|
|
bc8d0840eb | ||
|
|
3f615f87cf | ||
|
|
c6d423159c | ||
|
|
791590c8a3 | ||
|
|
036e5cda7e | ||
|
|
44860ff512 | ||
|
|
90927ba788 | ||
|
|
169c055011 | ||
|
|
8623b6836d | ||
|
|
b3d9aa2d84 | ||
|
|
acd3c4f156 | ||
|
|
37b78ad46a | ||
|
|
a15fae6a1e | ||
|
|
c866add320 | ||
|
|
2a05372a51 | ||
|
|
19866c60ee | ||
|
|
3f0ec0b157 | ||
|
|
7c5105b16e | ||
|
|
b97c877992 | ||
|
|
ca96365143 | ||
|
|
3b6c6e3dad | ||
|
|
ad508d79af | ||
|
|
32a0a5c55c | ||
|
|
b103df1bb1 | ||
|
|
c2a153a946 | ||
|
|
49fc4be861 | ||
|
|
b8408e14b9 | ||
|
|
b11a34af5c | ||
|
|
5ed20805ce | ||
|
|
7674477dd0 | ||
|
|
2b11fbe164 | ||
|
|
5f6a910e2d | ||
|
|
9974ad97fa | ||
|
|
c8ebe461d1 | ||
|
|
de1361ac0e | ||
|
|
d877fb021c | ||
|
|
3c56b9e66e | ||
|
|
1ceaf699c2 | ||
|
|
d75f85b089 | ||
|
|
34c1a009b6 | ||
|
|
c9e91be35f | ||
|
|
31afd94a30 | ||
|
|
6f7b9a1482 | ||
|
|
6c23ad661d | ||
|
|
41171e61d1 | ||
|
|
bdf8dec028 | ||
|
|
a872626fba | ||
|
|
00bdbf8ed1 | ||
|
|
25fb3983cb | ||
|
|
4510f10491 | ||
|
|
0ecc7045fa | ||
|
|
5cc1f001fa | ||
|
|
76caadc594 | ||
|
|
7d34efecf2 | ||
|
|
c4096d44d8 | ||
|
|
68f3a6b9a5 | ||
|
|
662bf7d7f0 | ||
|
|
0d71ab8efb | ||
|
|
4ce4768ebd | ||
|
|
fb13327922 | ||
|
|
4c62420f1b | ||
|
|
ca3f31643f | ||
|
|
d770004e0e | ||
|
|
b28efcd545 | ||
|
|
6b6d27f905 | ||
|
|
603c3539a3 | ||
|
|
f248a85b51 | ||
|
|
5a3d7e3292 | ||
|
|
c6d329db64 | ||
|
|
bdb203d742 | ||
|
|
f509516d60 | ||
|
|
4c856949c2 | ||
|
|
c90a5f4cff | ||
|
|
964fc1ef3f | ||
|
|
98f138f6b2 | ||
|
|
9179fe26ce | ||
|
|
e6c49e2bd9 | ||
|
|
d84293931b | ||
|
|
2c32ac2221 | ||
|
|
dde6906f98 | ||
|
|
4efd308345 | ||
|
|
f741c274d5 | ||
|
|
cf31c08a5c | ||
|
|
e7574a574b | ||
|
|
33484108a9 | ||
|
|
e697a40fda | ||
|
|
d1f065671e | ||
|
|
02da5c6484 | ||
|
|
b2440cb655 | ||
|
|
160d8b8f0c | ||
|
|
8899713ef9 | ||
|
|
f3cdefce21 | ||
|
|
b115813fbe | ||
|
|
ab45ab316a | ||
|
|
5b3a6c05bf | ||
|
|
cd53681787 | ||
|
|
f0651e5c9b | ||
|
|
f24b731f95 | ||
|
|
01ad424d12 | ||
|
|
435d522683 | ||
|
|
a63c3bc0c7 | ||
|
|
b83ce91528 | ||
|
|
a30238aab6 | ||
|
|
47156c9a5b | ||
|
|
78240c9a46 | ||
|
|
5a9f06e584 | ||
|
|
ecb007da70 | ||
|
|
ad1a4fca36 | ||
|
|
832a738588 | ||
|
|
fedb3ac5c1 | ||
|
|
cb4e55e444 | ||
|
|
3ef5a8e6a4 | ||
|
|
0e915e9b8b | ||
|
|
dba3b287dd | ||
|
|
ce019f508d | ||
|
|
5c2286cc4a | ||
|
|
efc0a94112 | ||
|
|
c60062c418 | ||
|
|
763a1d9371 | ||
|
|
4db1666024 | ||
|
|
0dc69aab1e | ||
|
|
a282cb095e | ||
|
|
259e7182a3 | ||
|
|
74bdf275d2 | ||
|
|
14499223fd | ||
|
|
6aab523dd9 | ||
|
|
1f31e51446 | ||
|
|
c062d0c717 | ||
|
|
dcabaedff1 | ||
|
|
d119ad6c60 | ||
|
|
74fc2e367a | ||
|
|
ea10f94376 | ||
|
|
ef1d77885d | ||
|
|
dccf55eadc | ||
|
|
65704e881f | ||
|
|
a30e82459f | ||
|
|
5a300e92f1 | ||
|
|
8e9fb0f93c | ||
|
|
11ea819bc7 | ||
|
|
abc9098bd8 | ||
|
|
00cffa09a2 | ||
|
|
aa944456e0 | ||
|
|
409d74a48d | ||
|
|
7957808f00 | ||
|
|
52bfc0efb1 | ||
|
|
46f90d0c54 | ||
|
|
5177972c04 | ||
|
|
ece90ee97b | ||
|
|
4cfa4893ef | ||
|
|
edd4a86d14 | ||
|
|
44dcd82d78 | ||
|
|
12da2140cf | ||
|
|
c006e9804a | ||
|
|
6343c11873 | ||
|
|
2ab09e1a77 | ||
|
|
a3ea77c2f1 | ||
|
|
7fe4399b94 | ||
|
|
545f762568 | ||
|
|
e83439eaae | ||
|
|
b6a9761dae | ||
|
|
fbe4b39a16 | ||
|
|
ecca240eef | ||
|
|
347a6155f8 | ||
|
|
a8e721abeb | ||
|
|
2d62ea78d2 | ||
|
|
8ef0f38f4e | ||
|
|
3c47ab42d6 | ||
|
|
aafeca5859 | ||
|
|
132f494195 | ||
|
|
5e9de7c94b | ||
|
|
b7d0b676fc | ||
|
|
55b45fc6fb | ||
|
|
6f8dbb6fbc | ||
|
|
ca97c86d65 | ||
|
|
828eac90f3 | ||
|
|
d9359a70c1 | ||
|
|
29404a782c | ||
|
|
e75a7a61fa | ||
|
|
185f12266a | ||
|
|
c455991962 | ||
|
|
f735dcadc0 | ||
|
|
78a65eabdc | ||
|
|
b3fda9973e | ||
|
|
4a85c98285 | ||
|
|
2c92a2498e | ||
|
|
74e22b5ad7 | ||
|
|
e9e77d02a0 | ||
|
|
7418fcce8d | ||
|
|
b768e2a1ce | ||
|
|
6084314cc9 | ||
|
|
2df953f3d7 | ||
|
|
10e592fa3a | ||
|
|
a99a8f364e | ||
|
|
200a1644a5 | ||
|
|
9daae377fc | ||
|
|
0b1065feee | ||
|
|
47da743d11 | ||
|
|
470226ca03 | ||
|
|
7b7107556f | ||
|
|
f90b776071 | ||
|
|
e26237e57d | ||
|
|
d2c3f749ab | ||
|
|
b7cc271219 | ||
|
|
17eed7055b | ||
|
|
b7121940c8 | ||
|
|
07fa16e2e7 | ||
|
|
8aa7db15e5 | ||
|
|
8b637b339b | ||
|
|
4f866c563b | ||
|
|
7a999f9a48 | ||
|
|
0a88c81d95 | ||
|
|
804e8fb856 | ||
|
|
43939a9199 | ||
|
|
f414a07bcd | ||
|
|
5c9d050ebe | ||
|
|
285a508e09 | ||
|
|
92f55f721f | ||
|
|
476d540065 | ||
|
|
ae730d6357 | ||
|
|
9823205ef3 | ||
|
|
d1391c2b10 | ||
|
|
8b888f31e0 | ||
|
|
6a247dfe91 | ||
|
|
482869fbbe | ||
|
|
4dea147386 | ||
|
|
1fd6337254 | ||
|
|
4ce6d59484 | ||
|
|
014cc19902 | ||
|
|
b9041566ea | ||
|
|
3a403c40d5 | ||
|
|
096e71bd67 | ||
|
|
fffa902a1a | ||
|
|
463e3833da | ||
|
|
4416aacebb | ||
|
|
15a626bcf3 | ||
|
|
a347a2e879 | ||
|
|
7be4566b41 | ||
|
|
96e26c6a5f | ||
|
|
7362c01c67 | ||
|
|
e45b4e4938 | ||
|
|
e32329cc11 | ||
|
|
199e368fe2 | ||
|
|
55726120db | ||
|
|
346ba7e831 | ||
|
|
9518006724 | ||
|
|
3d5f6f692f | ||
|
|
4712c15c75 | ||
|
|
9bc72529a6 | ||
|
|
6d20943a9c | ||
|
|
a3af82867b | ||
|
|
27483a4101 | ||
|
|
6d20ebc66b | ||
|
|
031a038b49 | ||
|
|
f6c7434b2a | ||
|
|
3d9fecb562 | ||
|
|
ebe93e2c68 | ||
|
|
1f4fcead38 | ||
|
|
1f000ba668 | ||
|
|
e0d28d7f5c | ||
|
|
47a58dd799 | ||
|
|
37bea1c153 | ||
|
|
d1a7b8df8b | ||
|
|
c01f24553c | ||
|
|
07e8b303b9 | ||
|
|
ebb0923362 | ||
|
|
e1c279718f | ||
|
|
cf764740cf | ||
|
|
58d549ed4c | ||
|
|
6b22d8068d | ||
|
|
443d8f1f28 | ||
|
|
1ec21a2245 | ||
|
|
bb7d4c3630 | ||
|
|
f42fe9af29 | ||
|
|
0bc2d71370 | ||
|
|
594c12b3d9 | ||
|
|
bca72e9b6d | ||
|
|
ce09504c92 | ||
|
|
dda4566b9e | ||
|
|
46a5665a66 | ||
|
|
9161f74bb1 | ||
|
|
3f01f9895f | ||
|
|
801a1817b5 | ||
|
|
32c76ae828 | ||
|
|
c664f5fc95 | ||
|
|
2bc4681fd6 | ||
|
|
646501b0ef | ||
|
|
de3e657d8b | ||
|
|
1add726892 | ||
|
|
5a000cf5e4 | ||
|
|
6ed676fe32 | ||
|
|
7cceddb8a3 | ||
|
|
039b897e7b | ||
|
|
b178f74e5d | ||
|
|
e5224e22ae | ||
|
|
be66e8b99c | ||
|
|
4b91c8aff4 | ||
|
|
9d7997f55a | ||
|
|
4db47de556 | ||
|
|
c8de632764 | ||
|
|
b794b7b1d1 | ||
|
|
add18d4e35 | ||
|
|
02c1810c3a | ||
|
|
4f3153375a | ||
|
|
1c3a67b5e8 | ||
|
|
236ff33a09 | ||
|
|
7c07fb48c5 | ||
|
|
d72bab79d7 | ||
|
|
617e2ec1e0 | ||
|
|
1afac0312d | ||
|
|
b05465145f | ||
|
|
990fc402fd | ||
|
|
dd51624fbb | ||
|
|
45a056e337 | ||
|
|
37782f7206 | ||
|
|
945c2458b3 | ||
|
|
7420d23867 | ||
|
|
191686c5b3 | ||
|
|
137351fa8d | ||
|
|
db5173697a | ||
|
|
c7940382a9 | ||
|
|
87045ab9c0 | ||
|
|
f1c4086778 | ||
|
|
f337d0acff | ||
|
|
9cc05b30d4 | ||
|
|
e0b68c4d6d | ||
|
|
9c3407cfdd | ||
|
|
d3e27054f6 | ||
|
|
bd58b5c8b3 | ||
|
|
c1a326f32e | ||
|
|
a14ea0e8fa | ||
|
|
48d6368ff9 | ||
|
|
83cff59fdd | ||
|
|
89489b1f0d | ||
|
|
7569a7b5ce | ||
|
|
8e624b4117 | ||
|
|
787a909aa4 | ||
|
|
905485b673 | ||
|
|
99677390e0 | ||
|
|
f2121130f9 | ||
|
|
1a01c34ec4 | ||
|
|
3c46cc9c35 | ||
|
|
e192ec8fee | ||
|
|
ec6b4263a2 | ||
|
|
8a1aa5753d | ||
|
|
3e4661fe1e | ||
|
|
22153be4cd | ||
|
|
3f490fe1ed | ||
|
|
58a42fe5c2 | ||
|
|
25c0c2c97b | ||
|
|
622942482c | ||
|
|
cffd775450 | ||
|
|
8d87ba34d9 | ||
|
|
08c6585915 | ||
|
|
3ef74d17af | ||
|
|
5d04c1dc18 | ||
|
|
a3d599ac69 | ||
|
|
a4e05cd1c8 | ||
|
|
1758653330 | ||
|
|
a2feff8f68 | ||
|
|
37eae9b466 | ||
|
|
9605b34c69 | ||
|
|
bab62b345b | ||
|
|
3349046c5d | ||
|
|
3f5b8139f3 | ||
|
|
be5e28ea9e | ||
|
|
9f28638ee7 | ||
|
|
201f843e95 | ||
|
|
37a59a07bc | ||
|
|
9556c2bc4c | ||
|
|
3540012529 | ||
|
|
e0b112c584 | ||
|
|
7f0d6a8696 | ||
|
|
4a7de87bff | ||
|
|
b9d1d2f06b | ||
|
|
74c30c62ef | ||
|
|
94fbfa05de | ||
|
|
55bd63732d | ||
|
|
3ae651af7b | ||
|
|
55e8bc3b14 | ||
|
|
9938a2487d | ||
|
|
aee41d64c2 | ||
|
|
f3a5a9fbd5 | ||
|
|
8ee3c8d41d | ||
|
|
eefd2a00c7 | ||
|
|
e8852c5950 | ||
|
|
069f40f653 | ||
|
|
04dd6b3a55 | ||
|
|
2ff55e3058 | ||
|
|
e5cdda9ee5 | ||
|
|
ecec3a19c7 | ||
|
|
9823ad6abc | ||
|
|
51b0240fb8 | ||
|
|
f697cf2011 | ||
|
|
8728aaa616 | ||
|
|
eab9a47aa2 | ||
|
|
c2ba2fbfc4 | ||
|
|
3de01d2a2e | ||
|
|
791d6659c0 | ||
|
|
007c677da1 | ||
|
|
2a0f2d59f0 | ||
|
|
c8d2c3691a | ||
|
|
82a63658f9 | ||
|
|
596827c51c | ||
|
|
8a16ead8ce | ||
|
|
334afdefb5 | ||
|
|
e7a2980b9c | ||
|
|
b5a78c6c46 | ||
|
|
8650d175f4 | ||
|
|
2999e7b925 | ||
|
|
207ae2e4cb | ||
|
|
9e78a768a2 | ||
|
|
e4d08a73b0 | ||
|
|
4cf7ad9c63 | ||
|
|
293d0b90d9 | ||
|
|
8f1e439792 | ||
|
|
ad733272e5 | ||
|
|
b7c6245388 | ||
|
|
2184ec2928 | ||
|
|
bae7f19323 | ||
|
|
56ec21d950 | ||
|
|
2798ef4de2 | ||
|
|
e90b4abe9c | ||
|
|
f3d1fc0d64 | ||
|
|
c8c929d487 | ||
|
|
d9567b2974 | ||
|
|
02fa18b74f | ||
|
|
3656375516 | ||
|
|
b0a9bfd381 | ||
|
|
25cb440005 | ||
|
|
0f59ef36a9 | ||
|
|
3de8c7f95c | ||
|
|
989dbd7550 | ||
|
|
ab12e5490f | ||
|
|
e9f428983e | ||
|
|
862b13c8dd | ||
|
|
2bb01853e0 | ||
|
|
c7d6b4aaa4 | ||
|
|
8c74e3d5ef | ||
|
|
04384e0f5b | ||
|
|
7218aa4803 | ||
|
|
9cb46aa584 | ||
|
|
747c5c3e40 | ||
|
|
68acc055bf | ||
|
|
2730c10c2c | ||
|
|
252bb3333e | ||
|
|
98f4854ecd | ||
|
|
413a4c5885 | ||
|
|
087b80eeb5 | ||
|
|
745dee0734 | ||
|
|
b986530efc | ||
|
|
3aaea907ba | ||
|
|
1d184b43eb | ||
|
|
8d494a84c5 | ||
|
|
b0aec6dc2f | ||
|
|
4a7d270494 | ||
|
|
ae26483be6 | ||
|
|
5d7bd179b1 | ||
|
|
23a3454551 | ||
|
|
f5ee72753d | ||
|
|
ee13996ee6 | ||
|
|
c24e61439b | ||
|
|
2e1c517821 | ||
|
|
950cfc4da3 | ||
|
|
715b14aeba | ||
|
|
d7b5ad77da | ||
|
|
77bfda6f1f | ||
|
|
b326cefea7 | ||
|
|
c0e70d9beb | ||
|
|
c5e165d062 | ||
|
|
5f483b11b6 | ||
|
|
b802740997 | ||
|
|
1bbe8b415f | ||
|
|
8b0e23d55b | ||
|
|
2f2634bf6b | ||
|
|
9c93d8925f | ||
|
|
634515ecc5 | ||
|
|
e58ba59288 | ||
|
|
bf95be5550 | ||
|
|
96f0ea10f4 | ||
|
|
97558ce64a | ||
|
|
4301e5a2a2 | ||
|
|
09ecfa58de | ||
|
|
27f6ced1a9 | ||
|
|
9802c7f32f | ||
|
|
bf36a62f62 | ||
|
|
4c47051da9 | ||
|
|
56682534f8 | ||
|
|
53b1b3a253 | ||
|
|
4ccf4210be | ||
|
|
3221ed2bad | ||
|
|
abc13a7c0a | ||
|
|
3353e71f9a | ||
|
|
73689f8d8b | ||
|
|
ba86685361 | ||
|
|
a16c2e2b04 | ||
|
|
eb0fac9cbf | ||
|
|
3b6f45894b | ||
|
|
f8cabc9d1f | ||
|
|
a7740091ec | ||
|
|
0fcc2a6ff1 | ||
|
|
0d192e8334 | ||
|
|
2dfcde477f | ||
|
|
f971e404c7 | ||
|
|
524b278811 | ||
|
|
7f80276cf4 | ||
|
|
26256d3d2e | ||
|
|
1bd94bb805 | ||
|
|
28aedc2b14 | ||
|
|
b700193a0a | ||
|
|
4fd2ddee55 | ||
|
|
a2d1f73bca | ||
|
|
f2472563f4 | ||
|
|
90d6d8f5ca | ||
|
|
d4b3d37165 | ||
|
|
563b6ade0f | ||
|
|
17f3c9d780 | ||
|
|
98622bd029 | ||
|
|
253ecdd145 | ||
|
|
1cc83172ab | ||
|
|
b521b5e1ca | ||
|
|
202b31f711 | ||
|
|
fc682dc75b | ||
|
|
4627f07a7b | ||
|
|
9bfce75bc5 | ||
|
|
b93b21eab1 | ||
|
|
e31a43af17 | ||
|
|
0496627d2b | ||
|
|
d548454dbe | ||
|
|
64f0e38a51 | ||
|
|
3964ef61f8 | ||
|
|
2d5d34d33f | ||
|
|
6bcbdd79c3 | ||
|
|
3d093c0ae8 | ||
|
|
450232964b | ||
|
|
112d8b26ee | ||
|
|
27dcf64b45 | ||
|
|
97d33a5679 | ||
|
|
0a99b007a9 | ||
|
|
e2ca80ec41 | ||
|
|
920ff9c25f | ||
|
|
56a90d2b25 | ||
|
|
b4166a883e | ||
|
|
b8703a8a1b | ||
|
|
57bb4e4e77 | ||
|
|
de93815c6b | ||
|
|
d4372ed008 | ||
|
|
a199ac41ee | ||
|
|
c0f494450d | ||
|
|
6b6903e568 | ||
|
|
d0f68802b9 | ||
|
|
7862520450 | ||
|
|
cbdddc8020 | ||
|
|
7d1317ad53 | ||
|
|
ad18bbc644 | ||
|
|
1bae91abaa | ||
|
|
1f5df20cb2 | ||
|
|
d47acf5d0b | ||
|
|
79a305d69a | ||
|
|
d344fd74d1 | ||
|
|
68640f8074 | ||
|
|
342f3d6994 | ||
|
|
a904426431 | ||
|
|
b00f32db8a | ||
|
|
914cab301f | ||
|
|
8bd9f213e5 | ||
|
|
5abd100b53 | ||
|
|
adb86a052c | ||
|
|
c2ddc17063 | ||
|
|
37b6578259 | ||
|
|
c6a37847e6 | ||
|
|
61a4722c93 | ||
|
|
dbf7167622 | ||
|
|
4ceae9cf85 | ||
|
|
dafc86d960 | ||
|
|
56b1c59eb2 | ||
|
|
1b3891866c | ||
|
|
11c3c06b12 | ||
|
|
735c64d138 | ||
|
|
4f67932e0d | ||
|
|
25d5985775 | ||
|
|
a1c2983394 | ||
|
|
4424af8c5b | ||
|
|
e8dadf2852 | ||
|
|
2b6bf1f0fe | ||
|
|
644f279972 | ||
|
|
43deec3063 | ||
|
|
a50369fc0e | ||
|
|
5d89e432da | ||
|
|
0b34c97742 | ||
|
|
66db4fdd61 | ||
|
|
9931647a8c | ||
|
|
77546b6111 | ||
|
|
050b4c3c94 | ||
|
|
3be32846e4 | ||
|
|
f8b83cc8a2 | ||
|
|
4f058585bb | ||
|
|
4d9c514a1a | ||
|
|
6b10e8bd15 | ||
|
|
67654fd1e1 | ||
|
|
2604169080 | ||
|
|
74fbee7d8d | ||
|
|
d6ad1560fa | ||
|
|
fcc2c26367 | ||
|
|
e36d7fe82b | ||
|
|
d38a458de9 | ||
|
|
5821915aad | ||
|
|
fae2f7a11d | ||
|
|
1df2759db6 | ||
|
|
ce9b221be4 | ||
|
|
e50fae3f46 | ||
|
|
15711f8112 | ||
|
|
7ad662781d | ||
|
|
2d1530adf7 | ||
|
|
40b4cd8e9b | ||
|
|
e11a295680 | ||
|
|
f9cd789f7c | ||
|
|
80c17fb2cd | ||
|
|
fb21320826 | ||
|
|
3899429cfa | ||
|
|
44f08d15db | ||
|
|
963a3379a6 | ||
|
|
4951f4fc2e | ||
|
|
a87ad66746 | ||
|
|
60662830dc | ||
|
|
33ccaf8a85 | ||
|
|
d64e1ebba3 | ||
|
|
6733e930eb | ||
|
|
c23a107a87 | ||
|
|
4224d90567 | ||
|
|
fb1b1db87f | ||
|
|
be78b8011d | ||
|
|
a99d03e172 | ||
|
|
c7d16dde15 | ||
|
|
be7748ccb6 | ||
|
|
41902d7538 | ||
|
|
ca1f89e500 | ||
|
|
306d200b02 | ||
|
|
98ee7e0f82 | ||
|
|
ad3e2f83b8 | ||
|
|
33dacb06ff | ||
|
|
363a91c185 | ||
|
|
4209f242e5 | ||
|
|
9af0302bb9 | ||
|
|
4c571c3ec7 | ||
|
|
a9df82fa0a | ||
|
|
6ad804fbc5 | ||
|
|
227f1d3842 | ||
|
|
72f904dcfb | ||
|
|
324e222bd2 | ||
|
|
7b1d70c2c0 | ||
|
|
b80fa4ae19 | ||
|
|
a4611907a9 | ||
|
|
17bd1b2a42 | ||
|
|
985a08e247 | ||
|
|
95786459c1 | ||
|
|
4f3db4c15a | ||
|
|
56512abb7e | ||
|
|
71349a6feb | ||
|
|
489e3d17ca | ||
|
|
d93772fed6 | ||
|
|
50f02aa523 | ||
|
|
5a87ad8fc0 | ||
|
|
e40579ad93 | ||
|
|
37fe59dc37 | ||
|
|
3829d368ff | ||
|
|
013c5d7217 | ||
|
|
e38ae51de2 | ||
|
|
333ad42e29 | ||
|
|
bb8ffac269 | ||
|
|
10303b3fa7 | ||
|
|
93eec5c961 | ||
|
|
16930c4e2c | ||
|
|
c2476911a3 | ||
|
|
98d99fb9fd | ||
|
|
ce6c122449 | ||
|
|
f1f8b102e2 | ||
|
|
e1ab5a85bb | ||
|
|
0959141f88 | ||
|
|
01616c4f30 | ||
|
|
7c89e81449 | ||
|
|
6420de53f0 | ||
|
|
fa8c7e60da | ||
|
|
5eeb780538 | ||
|
|
19511f5787 | ||
|
|
56028d0932 | ||
|
|
ca08803425 | ||
|
|
e209bbb5ae | ||
|
|
580b3a1a69 | ||
|
|
c395ea00cc | ||
|
|
89654fea66 | ||
|
|
700c0b0460 | ||
|
|
c0aa121c3d | ||
|
|
345bd11a2c | ||
|
|
071ef5016d | ||
|
|
fdc2974c0d | ||
|
|
899ed02ef2 | ||
|
|
a930baab7c | ||
|
|
43526a815e | ||
|
|
8f321bf346 | ||
|
|
91cbcab25e | ||
|
|
67e0058e72 | ||
|
|
b395a87cad | ||
|
|
8a7d2d0ca0 | ||
|
|
ed066cf0fe | ||
|
|
741fbbff5a | ||
|
|
cf17749307 | ||
|
|
98f2a0b4a4 | ||
|
|
9dd7dcb06d | ||
|
|
559614077d | ||
|
|
b5e18eb101 | ||
|
|
e0644ab20a | ||
|
|
98e9dd7954 | ||
|
|
fbb1a76e2a | ||
|
|
7fbb647c65 | ||
|
|
a2c749c277 | ||
|
|
0833e91fb5 | ||
|
|
eaa79b3696 | ||
|
|
58385bebff | ||
|
|
eb7575f59e | ||
|
|
6648ccc278 | ||
|
|
9ac4039c01 | ||
|
|
5e2d428e69 | ||
|
|
d12300679e | ||
|
|
e224a781ff | ||
|
|
a210c27d14 | ||
|
|
f0800336b8 | ||
|
|
33fa9f5069 | ||
|
|
427a504308 | ||
|
|
dd2fbdd43a | ||
|
|
d7d1e5c2f6 | ||
|
|
b832e135cb | ||
|
|
66615a24a7 | ||
|
|
52ace9c448 | ||
|
|
e1cedaba87 | ||
|
|
f7bf4e9727 | ||
|
|
e46bdf9e18 | ||
|
|
a08070a836 | ||
|
|
18f93c0e41 | ||
|
|
deba7a1f0c | ||
|
|
4817904637 | ||
|
|
98c6fa294a | ||
|
|
312b0c53c2 | ||
|
|
5dfce9b90c | ||
|
|
69085a7a42 | ||
|
|
2edb089f71 | ||
|
|
078b1aa1d6 | ||
|
|
1f8fea5379 | ||
|
|
9c211abc54 | ||
|
|
4afb619672 | ||
|
|
4d3b4659d7 | ||
|
|
37be04f58e | ||
|
|
1e4bf48ea3 | ||
|
|
52c6a50b6d | ||
|
|
008c85c7d5 | ||
|
|
e84880e4ec | ||
|
|
a30f9f7c65 | ||
|
|
e931919fb4 | ||
|
|
bbc5669efa | ||
|
|
718b04c15a | ||
|
|
24a8634a4b | ||
|
|
fc6664d737 | ||
|
|
388e411469 | ||
|
|
a8ebdcb8f2 | ||
|
|
f5f1c48f51 | ||
|
|
7c55de311f | ||
|
|
307faa6c38 | ||
|
|
5f2adcf9b3 | ||
|
|
7738464f0b | ||
|
|
5a86e26f68 | ||
|
|
786b47d8c2 | ||
|
|
83092ea05c | ||
|
|
629afd38f6 | ||
|
|
3ffc3a9610 | ||
|
|
9de7bd7461 | ||
|
|
5df922e0df | ||
|
|
b571d2a5ab | ||
|
|
231a2897c0 | ||
|
|
2223b14794 | ||
|
|
3d340f4d90 | ||
|
|
2f99466f3a | ||
|
|
f05e15522c | ||
|
|
f37dad683b | ||
|
|
a0fb14bf54 | ||
|
|
d4443be0c1 | ||
|
|
0dfb573e18 | ||
|
|
572fc033cd | ||
|
|
4062f0a323 | ||
|
|
cd40fb1c68 | ||
|
|
7afe7750cd | ||
|
|
e08bdf8cca | ||
|
|
b3172ecae8 | ||
|
|
2b4d9c0af9 | ||
|
|
3a10d2c187 | ||
|
|
05e2c6b133 | ||
|
|
2797349c75 | ||
|
|
95a01ac2ab | ||
|
|
ea01c909b7 | ||
|
|
d3655597ea | ||
|
|
cd8d76fa34 | ||
|
|
500d4aa9e9 | ||
|
|
271c1b409a | ||
|
|
9a0e8606cd | ||
|
|
d5681ac6a0 | ||
|
|
057143838d | ||
|
|
4268d91da3 | ||
|
|
6f39030b41 | ||
|
|
3e1a9e8a3a | ||
|
|
bb1318dce1 | ||
|
|
26fcc068b6 | ||
|
|
2a96fa44ff | ||
|
|
5fa93513f2 | ||
|
|
4a1f54ce4b | ||
|
|
76655cf951 | ||
|
|
dfb988c563 | ||
|
|
0443b4849b | ||
|
|
977376d55c | ||
|
|
1f93345af8 | ||
|
|
96dd2382c1 | ||
|
|
3064fb0d48 | ||
|
|
4c9dab9c98 | ||
|
|
0b0e706349 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
|
||||
"statusMessage": "Setting up dev environment (script/setup)...",
|
||||
"timeout": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ contact_links:
|
||||
url: https://github.com/esphome/esphome-webserver/issues/new/choose
|
||||
about: Report an issue with the ESPHome web server.
|
||||
- name: Report an issue with the ESPHome Builder / Dashboard
|
||||
url: https://github.com/esphome/dashboard/issues/new/choose
|
||||
url: https://github.com/esphome/device-builder/issues/new/choose
|
||||
about: Report an issue with the ESPHome Builder / Dashboard.
|
||||
- name: Report an issue with the ESPHome API client
|
||||
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
@@ -20,6 +21,10 @@
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
|
||||
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
|
||||
|
||||
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
- [ ] ESP32
|
||||
|
||||
@@ -3,8 +3,10 @@ description: >
|
||||
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
|
||||
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
|
||||
natively (clang-tidy for IDF/Arduino and the component test batches) shares
|
||||
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", so all toolchains are present regardless of the chip).
|
||||
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
|
||||
skips per-variant narrowing whenever CI is set, so all toolchains are
|
||||
present regardless of the chip a job builds.
|
||||
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
|
||||
Python venv already restored.
|
||||
inputs:
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
|
||||
@@ -58,5 +58,5 @@ runs:
|
||||
python -m venv venv
|
||||
source ./venv/Scripts/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
'merging-to-release',
|
||||
'merging-to-beta',
|
||||
'chained-pr',
|
||||
'stacked-pr',
|
||||
'core',
|
||||
'small-pr',
|
||||
'medium-pr',
|
||||
@@ -22,11 +23,13 @@ module.exports = {
|
||||
'has-tests',
|
||||
'needs-tests',
|
||||
'needs-docs',
|
||||
'needs-developer-docs',
|
||||
'needs-codeowners',
|
||||
'too-big',
|
||||
'labeller-recheck',
|
||||
'bugfix',
|
||||
'new-feature',
|
||||
'new-feature-developer',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
@@ -40,5 +43,17 @@ module.exports = {
|
||||
// Keep matching the old esphome-docs name during the transition period
|
||||
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
|
||||
/esphome\/esphome-docs#\d+/
|
||||
],
|
||||
|
||||
DEVELOPER_DOCS_PR_PATTERNS: [
|
||||
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
|
||||
/esphome\/developers\.esphome\.io#\d+/
|
||||
],
|
||||
|
||||
// Files whose developer-facing changes are documented via Python docstrings
|
||||
// only - developers.esphome.io has no reference page for them yet, so PRs
|
||||
// touching nothing but these files (and tests/) skip needs-developer-docs.
|
||||
DEV_DOCS_EXEMPT_FILES: [
|
||||
'esphome/config_validation.py'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DOCS_PR_PATTERNS } = require('./constants');
|
||||
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
|
||||
const {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
@@ -33,16 +33,54 @@ async function fetchPrFileContent(github, context, path) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a pull request is part of a GitHub stack.
|
||||
//
|
||||
// GitHub's stacked pull request feature adds a `stack` object to the pull
|
||||
// request resource. It is present on every pull request in the stack -
|
||||
// including the bottom one, whose base is already `dev` - and is absent
|
||||
// entirely on standalone pull requests.
|
||||
//
|
||||
// The `pull_request_target` webhook payload is not guaranteed to carry this
|
||||
// field, so fall back to asking the API when it is missing. Guessing wrong
|
||||
// here is costly: a stacked pull request mistaken for a manually chained one
|
||||
// gets a label that blocks merging.
|
||||
async function isStackedPr(github, context) {
|
||||
const pr = context.payload.pull_request;
|
||||
if (pr.stack != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
return data.stack != null;
|
||||
} catch (error) {
|
||||
// Treat an API failure as "not stacked" so a chained pull request still
|
||||
// gets its blocking label rather than silently slipping through.
|
||||
console.log('Failed to check stack membership:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy: Merge branch detection
|
||||
async function detectMergeBranch(context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
} else if (baseRef === 'beta') {
|
||||
labels.add('merging-to-beta');
|
||||
} else if (baseRef !== 'dev') {
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -245,6 +283,7 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
const checkboxPatterns = [
|
||||
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
@@ -355,12 +394,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
const labels = new Set();
|
||||
|
||||
// Check for missing tests
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
|
||||
labels.add('needs-tests');
|
||||
}
|
||||
|
||||
// Check for missing docs.
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
|
||||
// deliberately excluded here: its docs live on developers.esphome.io and are
|
||||
// checked separately below. `new-component` / `new-platform`
|
||||
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
|
||||
// i.e. the new component/platform is actually loadable from YAML.
|
||||
const docsEligible =
|
||||
@@ -376,6 +417,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing developer docs. `new-feature-developer` requires a
|
||||
// developers.esphome.io PR link, unless every changed file outside tests/ is
|
||||
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
|
||||
if (allLabels.has('new-feature-developer')) {
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
const nonTestFiles = prFiles
|
||||
.map(file => file.filename)
|
||||
.filter(file => !file.startsWith('tests/'));
|
||||
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
|
||||
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
|
||||
|
||||
if (!onlyExemptFiles && !hasDevDocsLink) {
|
||||
labels.add('needs-developer-docs');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing CODEOWNERS
|
||||
if (allLabels.has('new-component')) {
|
||||
const codeownersModified = prFiles.some(file =>
|
||||
|
||||
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
|
||||
|
||||
// Early exit for release and beta branches only
|
||||
if (baseRef === 'release' || baseRef === 'beta') {
|
||||
const branchLabels = await detectMergeBranch(context);
|
||||
const branchLabels = await detectMergeBranch(github, context);
|
||||
const finalLabels = Array.from(branchLabels);
|
||||
|
||||
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
|
||||
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
|
||||
deprecatedResult,
|
||||
maintainerAccess
|
||||
] = await Promise.all([
|
||||
detectMergeBranch(context),
|
||||
detectMergeBranch(github, context),
|
||||
detectComponentPlatforms(changedFiles, apiData),
|
||||
detectNewComponents(github, context, prFiles),
|
||||
detectNewPlatforms(github, context, prFiles, apiData),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
|
||||
const {
|
||||
detectMergeBranch,
|
||||
detectNewPlatforms,
|
||||
detectNewComponents,
|
||||
detectPRSize,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectRequirements,
|
||||
} = require('../detectors');
|
||||
const { MANAGED_LABELS } = require('../constants');
|
||||
|
||||
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
|
||||
// to check for CONFIG_SCHEMA in newly added files.
|
||||
@@ -29,6 +37,122 @@ const API_DATA = {
|
||||
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
|
||||
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectMergeBranch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
};
|
||||
}
|
||||
|
||||
// A GitHub API mock exposing only rest.pulls.get, with a call counter so
|
||||
// tests can assert whether the API fallback was actually invoked.
|
||||
function makeStackGithub({ stack = null, error = null } = {}) {
|
||||
const state = { calls: 0 };
|
||||
const github = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => {
|
||||
state.calls++;
|
||||
if (error) throw error;
|
||||
return { data: { stack } };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return { github, state };
|
||||
}
|
||||
|
||||
const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
|
||||
|
||||
describe('detectMergeBranch', () => {
|
||||
it('base ref release adds merging-to-release only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('release', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('beta', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('dev', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('not stacked, base ref not dev adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
it('not stacked, base ref dev adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectNewPlatforms
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -146,6 +270,125 @@ describe('detectNewComponents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRTemplateCheckboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
|
||||
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
|
||||
function makeBodyContext(body) {
|
||||
return { payload: { pull_request: { body } } };
|
||||
}
|
||||
|
||||
describe('detectPRTemplateCheckboxes', () => {
|
||||
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature-developer'));
|
||||
assert.ok(!labels.has('new-feature'));
|
||||
});
|
||||
|
||||
it('unticked developer-facing feature checkbox adds no label', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('ticked new feature checkbox does not add new-feature-developer', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature'));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectRequirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectRequirements', () => {
|
||||
// PR body without any docs-PR link.
|
||||
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
|
||||
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
|
||||
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
|
||||
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
|
||||
|
||||
// File sets: a normal source change vs. one confined to the exempt core validators.
|
||||
const SOURCE_FILES = [
|
||||
{ filename: 'esphome/components/foo/foo.py' },
|
||||
{ filename: 'tests/components/foo/common.yaml' },
|
||||
];
|
||||
const VALIDATOR_FILES = [
|
||||
{ filename: 'esphome/config_validation.py' },
|
||||
{ filename: 'tests/unit_tests/test_config_validation.py' },
|
||||
];
|
||||
|
||||
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-tests'));
|
||||
assert.ok(!labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer with has-tests does not add needs-tests', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-tests'));
|
||||
});
|
||||
|
||||
it('new-feature without a docs link still adds needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('validator changes mixed with other source files are not exempt', async () => {
|
||||
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGED_LABELS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MANAGED_LABELS', () => {
|
||||
it('includes new-feature-developer so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('includes needs-developer-docs so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
|
||||
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
|
||||
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
|
||||
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
|
||||
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
|
||||
|
||||
- name: Auto Label PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
@@ -21,15 +21,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
@@ -41,10 +41,32 @@ jobs:
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
# PR-only workflow, so nothing on dev could seed a shared apt cache
|
||||
# entry; the cached apt action would save one copy per PR. Plain apt
|
||||
# with every call bounded: the apt.conf.d timeouts make a dead
|
||||
# mirror fail over in seconds, and timeout runs under sudo so it can
|
||||
# kill apt-get itself. Install without update first: image lists are
|
||||
# fresh, and the index refresh is what a congested mirror makes slow.
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt-cache show protobuf-compiler
|
||||
sudo apt install -y protobuf-compiler
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
# Common path: the image's package lists are fresh enough.
|
||||
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
|
||||
apt-get install -y protobuf-compiler; then
|
||||
protoc --version
|
||||
exit 0
|
||||
fi
|
||||
# Rescue path: refresh the lists once with a generous bound; the
|
||||
# apt config already fails a stalled mirror over quickly.
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
|
||||
dpkg --configure -a || true
|
||||
sudo timeout -k 15 300 apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
|
||||
apt-get install -y protobuf-compiler
|
||||
protoc --version
|
||||
- name: Install python dependencies
|
||||
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
|
||||
@@ -61,21 +61,23 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Determine tag and whether to push
|
||||
id: tag
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
# Sanitize the branch name into a valid docker tag: replace invalid
|
||||
# characters, ensure the first character is valid (tags must start
|
||||
# with [A-Za-z0-9_]), and cap the length at 128 characters.
|
||||
branch="${{ github.head_ref || github.ref_name }}"
|
||||
branch="$HEAD_REF"
|
||||
tag="${branch//[^a-zA-Z0-9_.-]/-}"
|
||||
case "$tag" in
|
||||
[a-zA-Z0-9_]*) ;;
|
||||
@@ -96,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -117,16 +119,22 @@ jobs:
|
||||
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
|
||||
- name: Export image for compile-test
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
|
||||
# zstd over gzip: docker save is on the critical path for every
|
||||
# compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
|
||||
# docker load auto-detects the format; its time is layer extraction,
|
||||
# not decompression, so it is unchanged. shell: bash adds pipefail so
|
||||
# a failed docker save cannot upload a truncated artifact.
|
||||
shell: bash
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
|
||||
|
||||
- name: Upload compile-test image artifact
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# The tar is already gzipped, so upload it as-is. archive: false skips
|
||||
# the redundant zip and makes the file name the artifact name (the
|
||||
# `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.gz
|
||||
# The tar is already compressed, so upload it as-is. archive: false
|
||||
# skips the redundant zip and makes the file name the artifact name
|
||||
# (the `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.zst
|
||||
retention-days: 1
|
||||
archive: false
|
||||
|
||||
@@ -145,16 +153,16 @@ jobs:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -180,8 +188,6 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Cap concurrency so this smoke test doesn't hog all the shared runners.
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
@@ -202,16 +208,17 @@ jobs:
|
||||
- nrf52
|
||||
- host
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: compile-test-image.tar.gz
|
||||
name: compile-test-image.tar.zst
|
||||
- name: Load image
|
||||
run: docker load --input compile-test-image.tar.gz
|
||||
run: docker load --input compile-test-image.tar.zst
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
-e ESPHOME_LDGEN_STRICT=1 \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: .github/scripts/auto-label-pr
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Check out code from base repository
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Always check out from the base repository (esphome/esphome), never from forks
|
||||
# Use the PR's target branch to ensure we run trusted code from the main repo
|
||||
|
||||
+421
-317
@@ -28,13 +28,13 @@ jobs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Generate cache-key
|
||||
id: cache-key
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -65,201 +65,24 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
pylint:
|
||||
name: Check pylint
|
||||
seed-apt-cache:
|
||||
name: Seed apt package cache
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.python-linters == 'true'
|
||||
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
||||
# pushes seed the one shared entry PR jobs restore. The key is derived
|
||||
# only from the package list and version; keep both identical in every
|
||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
||||
if: github.event_name == 'push'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Run pylint
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pylint -f parseable --persistent=n esphome
|
||||
- name: Suggested changes
|
||||
run: script/ci-suggest-changes
|
||||
if: always()
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
|
||||
- name: Run script/ci-custom
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
script/ci-custom.py
|
||||
script/build_codeowners.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2-boards.py --check
|
||||
script/ci_check_duplicate_test_ids.py
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.import-time == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Check import time against budget and write waterfall HAR
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
script/check_import_time.py --check --har importtime.har
|
||||
- name: Upload waterfall HAR
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: import-time-waterfall
|
||||
path: importtime.har
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out esphome (this PR)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: esphome/device-builder
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
# Mirrors the install shape device-builder's own CI uses
|
||||
# (esphome/device-builder#192): uv replaces pip for the
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
# overlay the PR's esphome so the downstream tests run
|
||||
# against this PR's Python code. ``--system`` installs into
|
||||
# the runner's Python instead of a venv.
|
||||
run: |
|
||||
uv pip install --system -e './device-builder[esphome,test]'
|
||||
uv pip install --system -e ./esphome
|
||||
- name: Run device-builder pytest
|
||||
# ``-n auto`` runs under pytest-xdist (matches device-builder's
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code. ``tests/e2e/slow``
|
||||
# is excluded: those are real multi-minute toolchain compiles
|
||||
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
|
||||
# runs in its own dedicated jobs, not this smoke check.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macOS-latest
|
||||
- windows-latest
|
||||
exclude:
|
||||
# Minimize CI resource usage
|
||||
# by only running the Python version
|
||||
# version used for docker images on Windows and macOS
|
||||
- python-version: "3.13"
|
||||
os: windows-latest
|
||||
- python-version: "3.13"
|
||||
os: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
. ./venv/Scripts/activate.ps1
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
@@ -289,9 +112,15 @@ jobs:
|
||||
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
|
||||
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
|
||||
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
||||
# "true" when this run is a pull request into one of the release
|
||||
# branches. Those pull requests are batches of changes already tested on
|
||||
# their original dev pull requests, so several jobs below trade coverage
|
||||
# for turnaround time on them. Matched exactly, not by prefix, so an
|
||||
# ordinary branch named e.g. "release-notes" is not caught by it.
|
||||
release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Fetch enough history to find the merge base
|
||||
fetch-depth: 2
|
||||
@@ -350,9 +179,174 @@ jobs:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
|
||||
- name: Run script/ci-custom
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
script/ci-custom.py
|
||||
script/build_codeowners.py --check
|
||||
script/build_alias_registry.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2-boards.py --check
|
||||
script/ci_check_duplicate_test_ids.py
|
||||
script/ci_check_test_fixture_list_form.py
|
||||
|
||||
pylint:
|
||||
name: Check pylint
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.python-linters == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Run pylint
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pylint -f parseable --persistent=n esphome
|
||||
- name: Suggested changes
|
||||
run: script/ci-suggest-changes
|
||||
if: always()
|
||||
|
||||
lint-format:
|
||||
name: Check lint and formatting
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
# the cache on dev. Every run would miss and then write a per-pull
|
||||
# request copy, which is what the old seed-cache job existed to
|
||||
# avoid. Building the hooks from scratch takes seconds, so skip it.
|
||||
cache: false
|
||||
env:
|
||||
PREK_SKIP: pylint,ci-custom
|
||||
# Pushes any fixes the hooks made back to the pull request. This step
|
||||
# must keep its default name: the GitHub App that performs the push
|
||||
# locates the workflow run by that name.
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
with:
|
||||
msg: apply automatic formatting fixes
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macOS-latest
|
||||
- windows-latest
|
||||
exclude:
|
||||
# Minimize CI resource usage
|
||||
# by only running the Python version
|
||||
# version used for docker images on Windows and macOS
|
||||
- python-version: "3.13"
|
||||
os: windows-latest
|
||||
- python-version: "3.13"
|
||||
os: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
. ./venv/Scripts/activate.ps1
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
- name: Save Python virtual environment cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
|
||||
codecov-empty-upload:
|
||||
name: Report no coverage to Codecov
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- determine-jobs
|
||||
# ``pytest`` is the only job that uploads coverage, and it is skipped when
|
||||
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
|
||||
# ``script/determine-jobs.py``). With no upload Codecov never reports a
|
||||
# result, so the required ``codecov/patch`` status stays pending forever and
|
||||
# the pull request can never be merged. Tell Codecov up front that this
|
||||
# commit has nothing to cover so it publishes a passing status instead.
|
||||
#
|
||||
# ``force`` skips Codecov's own check that every changed file is ignorable;
|
||||
# ``determine-jobs`` has already decided none of these files can affect
|
||||
# coverage, and Codecov would otherwise fail the status for paths it does
|
||||
# not recognise as non-testable (``docker/**``, ``.yamllint``).
|
||||
if: needs.determine-jobs.outputs.core-ci == 'false'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Report empty upload to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
run_command: empty-upload
|
||||
force: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
integration-tests:
|
||||
name: Run integration tests (${{ matrix.bucket.name }})
|
||||
runs-on: ubuntu-latest
|
||||
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
@@ -363,10 +357,20 @@ jobs:
|
||||
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install apt packages (cached)
|
||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||
# path. Packages and version must match seed-apt-cache exactly;
|
||||
# libsdl2-dev is unused here and carried only for cache-key parity.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore Python virtual environment
|
||||
@@ -378,7 +382,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -408,6 +412,137 @@ jobs:
|
||||
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
|
||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
|
||||
- name: Print ccache statistics
|
||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.import-time == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Check import time against budget and write waterfall HAR
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
script/check_import_time.py --check --har importtime.har
|
||||
- name: Upload waterfall HAR
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: import-time-waterfall
|
||||
path: importtime.har
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
benchmarks:
|
||||
name: Run CodSpeed benchmarks
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: >-
|
||||
github.repository == 'esphome/esphome' && (
|
||||
(github.event_name == 'push' && github.ref_name == 'dev') ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.determine-jobs.outputs.release-pr == 'false' &&
|
||||
needs.determine-jobs.outputs.benchmarks == 'true'
|
||||
)
|
||||
)
|
||||
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
|
||||
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
|
||||
#
|
||||
# Pull requests into beta and release are skipped as well. CodSpeed compares a
|
||||
# pull request against the newest commit of its base branch that has a benchmark
|
||||
# run of its own, and only dev is benchmarked. A release pull request therefore
|
||||
# falls back to dev's latest run, so every speed-up merged into dev since the
|
||||
# release branched is reported as a regression in the release. The changes there
|
||||
# have already been benchmarked on their original dev pull requests.
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Build benchmarks
|
||||
id: build
|
||||
run: |
|
||||
# pipefail: without it a failed build is masked by the grep/cut
|
||||
# pipeline below, leaving BINARY empty and silently dropping every
|
||||
# C++ benchmark from the run while the job still reports success.
|
||||
set -o pipefail
|
||||
. venv/bin/activate
|
||||
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
|
||||
export BENCHMARK_LIB_CONFIG
|
||||
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
|
||||
# non-fatal so a missing marker reaches the check below instead of
|
||||
# tripping errexit at this assignment
|
||||
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
|
||||
if [ -z "$BINARY" ]; then
|
||||
echo "::error::Benchmark build did not report a binary path"
|
||||
exit 1
|
||||
fi
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bound apt fetches and pre-install libc6-dbg
|
||||
# The CodSpeed runner installs valgrind + libc6-dbg via its own
|
||||
# unbounded apt-get update; per-invocation apt options cannot reach
|
||||
# it. The apt.conf.d timeouts below bound every later apt call in
|
||||
# this job, the runner's included. Pre-installing libc6-dbg lets the
|
||||
# runner skip apt once its valgrind cache is restored (it checks
|
||||
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
|
||||
# would not count). Install without update first: image lists are
|
||||
# fresh, and the index refresh is what a congested mirror makes
|
||||
# slow. Best effort; the job timeout is the last backstop.
|
||||
timeout-minutes: 15
|
||||
continue-on-error: true
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
if dpkg -s libc6-dbg >/dev/null 2>&1; then
|
||||
echo "libc6-dbg already installed"
|
||||
exit 0
|
||||
fi
|
||||
# Common path: the image's package lists are fresh enough.
|
||||
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
|
||||
apt-get install -y libc6-dbg; then
|
||||
exit 0
|
||||
fi
|
||||
# Rescue path: refresh the lists once with a generous bound; the
|
||||
# apt config already fails a stalled mirror over quickly.
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
|
||||
dpkg --configure -a || true
|
||||
sudo timeout -k 15 300 apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
${{ steps.build.outputs.binary }}
|
||||
pytest tests/benchmarks/python/ --codspeed --no-cov
|
||||
mode: simulation
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
@@ -418,7 +553,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -436,43 +571,6 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
${{ steps.build.outputs.binary }}
|
||||
pytest tests/benchmarks/python/ --codspeed --no-cov
|
||||
mode: simulation
|
||||
|
||||
clang-tidy-single:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -488,7 +586,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -516,30 +613,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
# Key on the exact Python version as well: LibreTiny creates a venv under
|
||||
# ~/.platformio/penv whose interpreter is a symlink into the runner's
|
||||
# hosted toolcache, so a cache saved on an older runner image breaks once
|
||||
# a new image ships a newer patch release and drops the old interpreter.
|
||||
- name: Cache platformio
|
||||
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache platformio
|
||||
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
|
||||
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Cache ESP-IDF install
|
||||
if: matrix.cache_idf
|
||||
@@ -614,7 +716,7 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -679,7 +781,6 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -694,7 +795,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -759,7 +860,6 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -779,7 +879,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
@@ -846,7 +946,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
|
||||
matrix:
|
||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||
steps:
|
||||
@@ -858,15 +957,20 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Install apt packages
|
||||
# Not cached: this job is pull-request-only, so a cache save could
|
||||
# never be shared and would only consume quota.
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
|
||||
- name: Install apt packages (cached)
|
||||
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
||||
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
||||
# the cold path. Packages and version must match seed-apt-cache
|
||||
# exactly. The action has no --no-install-recommends; same package
|
||||
# set this job used before #17463.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -928,7 +1032,7 @@ jobs:
|
||||
# - This catches pin conflicts and other issues in directly changed code
|
||||
# - Grouped tests use --testing-mode to allow config merging (disables some checks)
|
||||
# - Dependencies are safe to group since they weren't modified in this PR
|
||||
if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
|
||||
if [[ "${{ needs.determine-jobs.outputs.release-pr }}" == "true" ]]; then
|
||||
directly_changed_csv=""
|
||||
echo "Testing components: $components_csv"
|
||||
echo "Target branch: ${{ github.base_ref }} - grouping all components"
|
||||
@@ -1011,7 +1115,7 @@ jobs:
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -1037,69 +1141,62 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
pre-commit-seed-cache:
|
||||
name: Seed pre-commit cache
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
# Saves a dev-scoped pre-commit cache that pull request runs can
|
||||
# restore, since pre-commit.ci lite itself never runs on dev pushes.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache pre-commit environments
|
||||
id: cache-pre-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the restore key in pre-commit-ci-lite
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Install pre-commit hook environments
|
||||
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit install-hooks
|
||||
|
||||
pre-commit-ci-lite:
|
||||
name: pre-commit.ci lite
|
||||
runs-on: ubuntu-latest
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
- name: Check out esphome (this PR)
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
# Inlined from esphome/pre-commit-action with a restore-only cache
|
||||
# step: the pre-commit-seed-cache job owns saving this cache, so
|
||||
# pull request runs never write per-PR copies.
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the key pre-commit-seed-cache saves
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Run pre-commit
|
||||
env:
|
||||
SKIP: pylint,ci-custom
|
||||
repository: esphome/device-builder
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
# Mirrors the install shape device-builder's own CI uses
|
||||
# (esphome/device-builder#192): uv replaces pip for the
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
# overlay the PR's esphome so the downstream tests run
|
||||
# against this PR's Python code. ``--system`` installs into
|
||||
# the runner's Python instead of a venv.
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
uv pip install --system -e './device-builder[esphome,test]'
|
||||
uv pip install --system -e ./esphome
|
||||
- name: Run device-builder pytest
|
||||
# ``-n auto`` runs under pytest-xdist (matches device-builder's
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code. ``tests/e2e/slow``
|
||||
# is excluded: those are real multi-minute toolchain compiles
|
||||
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
|
||||
# runs in its own dedicated jobs, not this smoke check.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
|
||||
|
||||
memory-impact-target-branch:
|
||||
name: Build target branch for memory impact
|
||||
@@ -1115,7 +1212,7 @@ jobs:
|
||||
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
|
||||
@@ -1297,7 +1394,7 @@ jobs:
|
||||
flash_usage: ${{ steps.extract.outputs.flash_usage }}
|
||||
steps:
|
||||
- name: Check out PR branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1366,7 +1463,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -1400,21 +1497,28 @@ jobs:
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-24.04
|
||||
# Listed in the same order the jobs are defined above. One job is
|
||||
# deliberately left out: "benchmarks" reports through CodSpeed rather than
|
||||
# this check.
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
- lint-format
|
||||
- pytest
|
||||
- codecov-empty-upload
|
||||
- integration-tests
|
||||
- import-time
|
||||
- cpp-unit-tests
|
||||
- clang-tidy-single
|
||||
- clang-tidy-nosplit
|
||||
- clang-tidy-split
|
||||
- clang-tidy-esp32-variants
|
||||
- determine-jobs
|
||||
- device-builder
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- pre-commit-ci-lite
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
- memory-impact-comment
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Nightly Dev Release
|
||||
|
||||
# Works out the dated dev tag and starts the release workflow with it, so that
|
||||
# the release run is named after the tag it builds. A workflow run name is
|
||||
# fixed when the run starts and cannot read a file or the current date.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: Start release build
|
||||
if: github.repository == 'esphome/esphome'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
actions: write # gh workflow run starts release.yml
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Start the release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not read __version__ from esphome/const.py"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${VERSION}$(date --utc '+%Y%m%d')"
|
||||
echo "Starting release build for ${TAG}"
|
||||
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
|
||||
@@ -1,12 +1,23 @@
|
||||
---
|
||||
name: Publish Release
|
||||
|
||||
# Releases (production and beta) are named after the version they publish.
|
||||
# Dev builds are named after the dated dev tag, which is passed in by the
|
||||
# nightly workflow because a run name cannot compute it itself.
|
||||
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: >-
|
||||
Tag to build. Only supported on dev, where the nightly workflow
|
||||
uses it. Leave empty to build the version from esphome/const.py
|
||||
with today's date appended.
|
||||
required: false
|
||||
default: ""
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
|
||||
@@ -20,9 +31,11 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
# yamllint disable rule:line-length
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" = "release" ]]; then
|
||||
@@ -34,12 +47,23 @@ jobs:
|
||||
ENVIRONMENT="production"
|
||||
fi
|
||||
else
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
# The nightly workflow passes the finished tag so that the run name
|
||||
# matches what is built. Without it, work it out here.
|
||||
TAG="${INPUT_TAG}"
|
||||
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
|
||||
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$TAG" ]]; then
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
fi
|
||||
fi
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
BRANCH_BUILD="true"
|
||||
ENVIRONMENT=""
|
||||
else
|
||||
@@ -60,9 +84,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -70,7 +94,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -92,22 +116,22 @@ jobs:
|
||||
os: "ubuntu-24.04-arm"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -168,7 +192,7 @@ jobs:
|
||||
- ghcr
|
||||
- dockerhub
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -178,17 +202,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+35
-50
@@ -6,61 +6,46 @@ on:
|
||||
- cron: "30 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write # actions/stale labels, comments on, and closes stale issues
|
||||
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
|
||||
# needs no permissions at all.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository_owner == 'esphome'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 400
|
||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||
# GitHub App token so the labels, comments and closures come from
|
||||
# esphome[bot] instead of github-actions[bot].
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
# Live only on dev: a workflow_dispatch from any other branch is a dry run
|
||||
dry-run: ${{ github.ref != 'refs/heads/dev' }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
stale-label: stale
|
||||
exempt-label: not-stale
|
||||
ignored-users: esphbot,codecov-commenter
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
# The 90 day stale policy for PRs
|
||||
# - PRs
|
||||
# - No PRs marked as "not-stale"
|
||||
# - No Issues (see below)
|
||||
days-before-pr-stale: 90
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "not-stale"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
Thank you for your contribution!
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Thank you for your contribution!
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
# The 90 day stale policy for Issues
|
||||
# - Issues
|
||||
# - No Issues marked as "not-stale"
|
||||
# - No PRs (see above)
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "not-stale"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -28,26 +28,26 @@ jobs:
|
||||
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Checkout Home Assistant
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: home-assistant/core
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
@@ -58,19 +58,19 @@ jobs:
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Apply pre-commit auto-fixes
|
||||
- name: Apply prek auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# files. prek exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# SKIP:
|
||||
# PREK_SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
@@ -79,25 +79,25 @@ jobs:
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files || true
|
||||
|
||||
- name: Verify pre-commit clean
|
||||
- name: Verify prek clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# real issue and fails the workflow loudly. Same PREK_SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphomebot <esphome@openhomefoundation.org>
|
||||
author: esphomebot <esphome@openhomefoundation.org>
|
||||
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
branch: sync/device-classes
|
||||
delete-branch: true
|
||||
title: "Synchronise Device Classes from Home Assistant"
|
||||
|
||||
@@ -133,6 +133,8 @@ CTestTestfile.cmake
|
||||
.gcc-flags.json
|
||||
|
||||
config/
|
||||
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
/.temp-clang-tidy.cpp
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.15
|
||||
rev: v0.16.3
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
|
||||
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
|
||||
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
|
||||
these common names (for example the Realtek SDKs used by LibreTiny define
|
||||
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
|
||||
before the compiler sees it, breaking the build and clang-tidy on those platforms.
|
||||
|
||||
* **Python Idioms:**
|
||||
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
|
||||
@@ -191,11 +197,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -229,7 +238,12 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
@@ -238,7 +252,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
@@ -246,7 +263,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
@@ -263,10 +283,13 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -316,11 +339,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
})
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
@@ -368,7 +394,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
@@ -381,6 +410,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -388,7 +418,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Configuration:**
|
||||
* `pyproject.toml`: Defines the Python project metadata and dependencies.
|
||||
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
|
||||
* `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
|
||||
* `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
|
||||
* **CI/CD Pipeline:** Defined in `.github/workflows`.
|
||||
* **Static Analysis & Development:**
|
||||
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
|
||||
@@ -396,7 +426,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
## 6. Development & Testing Workflow
|
||||
|
||||
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
@@ -469,9 +499,9 @@ This document provides essential context for AI models interacting with this pro
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
4. **Lint:** Run `pre-commit` to ensure code is compliant.
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
@@ -617,6 +647,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
@@ -636,20 +667,24 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
@@ -704,10 +739,22 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
|
||||
* **Deprecation Pattern (Python):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({ ... }),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
## 9. English Language
|
||||
@@ -716,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code
|
||||
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
|
||||
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
|
||||
audience, including non-native English speakers.
|
||||
|
||||
## 10. Code Comments
|
||||
|
||||
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
|
||||
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
|
||||
|
||||
Function and method comment blocks may include more detail as required to make
|
||||
calling contracts clear and document parameter usage, but should still be kept concise.
|
||||
|
||||
Avoid redundancy and repetition; comments should never simply restate what the code already says.
|
||||
|
||||
+15
@@ -69,12 +69,16 @@ esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
esphome/components/bk72xx/* @kuba2k2
|
||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
esphome/components/bme280_base/* @esphome/core
|
||||
@@ -234,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
|
||||
esphome/components/hm3301/* @freekode
|
||||
esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
@@ -285,6 +290,7 @@ esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
esphome/components/libretiny/* @kuba2k2
|
||||
esphome/components/libretiny_pwm/* @kuba2k2
|
||||
@@ -292,6 +298,8 @@ esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
@@ -346,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer
|
||||
esphome/components/mlx90614/* @jesserockz
|
||||
esphome/components/mmc5603/* @benhoff
|
||||
esphome/components/mmc5983/* @agoode
|
||||
esphome/components/modbus_client/* @exciton
|
||||
esphome/components/modbus_controller/* @martgras
|
||||
esphome/components/modbus_controller/binary_sensor/* @martgras
|
||||
esphome/components/modbus_controller/number/* @martgras
|
||||
@@ -372,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
|
||||
esphome/components/nextion/text_sensor/* @senexcrenshaw
|
||||
esphome/components/nfc/* @jesserockz @kbx81
|
||||
esphome/components/noblex/* @AGalfra
|
||||
esphome/components/noise/* @esphome/core
|
||||
esphome/components/npi19/* @bakerkj
|
||||
esphome/components/nrf52/* @tomaszduda23
|
||||
esphome/components/number/* @esphome/core
|
||||
@@ -434,6 +444,7 @@ esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rp2_ble_tracker/* @bdraco
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
@@ -456,6 +467,7 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
@@ -464,6 +476,7 @@ esphome/components/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
esphome/components/serial_proxy/* @kbx81
|
||||
esphome/components/sfa30/* @ghsensdev
|
||||
esphome/components/sfa40/* @NoQuarrel
|
||||
esphome/components/sgp40/* @SenexCrenshaw
|
||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||
esphome/components/sha256/* @esphome/core
|
||||
@@ -564,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim
|
||||
esphome/components/tuya/sensor/* @jesserockz
|
||||
esphome/components/tuya/switch/* @jesserockz
|
||||
esphome/components/tuya/text_sensor/* @dentra
|
||||
esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
@@ -624,6 +638,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
|
||||
@@ -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.8.0-dev
|
||||
PROJECT_NUMBER = 2026.9.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
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+310
-139
@@ -2,39 +2,33 @@
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -48,10 +42,12 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
SECRETS_FILES,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -59,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
from esphome.util import (
|
||||
@@ -74,6 +71,9 @@ from esphome.util import (
|
||||
safe_print,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import threading
|
||||
|
||||
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
|
||||
# module's top level. Every `esphome` invocation — including fast paths
|
||||
# like `esphome version` — pays the cost of what's imported here before
|
||||
@@ -276,8 +276,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
|
||||
if purpose == Purpose.LOGGING and not has_api():
|
||||
return (
|
||||
"Cannot view logs over the network: no 'api:' component is "
|
||||
"configured. Network log streaming requires the native API; add "
|
||||
"an 'api:' component, enable MQTT logging, or view logs over USB."
|
||||
"configured. Add an 'api:' component, enable MQTT logging, add a "
|
||||
"'web_server:' component, or view logs over USB."
|
||||
)
|
||||
if purpose == Purpose.UPLOADING and not has_ota():
|
||||
return (
|
||||
@@ -317,9 +317,12 @@ def choose_upload_log_host(
|
||||
]
|
||||
resolved.append(choose_prompt(options, purpose=purpose))
|
||||
elif device == "OTA":
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and has_api())
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
):
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
@@ -331,7 +334,11 @@ def choose_upload_log_host(
|
||||
if has_mqtt_logging():
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
if (
|
||||
network_logging
|
||||
and has_non_ip_address()
|
||||
and has_resolvable_address()
|
||||
):
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
@@ -393,7 +400,7 @@ def choose_upload_log_host(
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_api() or has_web_server_logging():
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
@@ -486,10 +493,23 @@ def has_web_server_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_logging() -> bool:
|
||||
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
|
||||
|
||||
The ``web_server`` component exposes a ``/events`` Server-Sent Events
|
||||
stream that carries ``event: log`` frames. This requires version 2+ (the
|
||||
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
|
||||
"""
|
||||
web_conf = CORE.config.get(CONF_WEB_SERVER)
|
||||
if web_conf is None:
|
||||
return False
|
||||
if web_conf.get(CONF_VERSION, 2) == 1:
|
||||
return False
|
||||
return web_conf.get(CONF_LOG, True)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
from esphome.components.mqtt import CONF_DISCOVER_IP
|
||||
|
||||
if CONF_MQTT not in CORE.config:
|
||||
return False
|
||||
# Default Enabled
|
||||
@@ -550,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
return mqtt.get_esphome_device_ip(
|
||||
config, username, password, client_id, stop_event=stop_event
|
||||
)
|
||||
|
||||
|
||||
def _add_network_device(device: str, network_devices: list[str]) -> None:
|
||||
"""Append a device to the list, expanding it through ``CORE.address_cache``.
|
||||
|
||||
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. Duplicates are dropped.
|
||||
"""
|
||||
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:
|
||||
network_devices.append(device)
|
||||
|
||||
|
||||
def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
|
||||
"""Split the device list into direct addresses and an MQTT-lookup flag.
|
||||
|
||||
Direct addresses are expanded through ``CORE.address_cache`` and deduped
|
||||
the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
|
||||
are not resolved, only reported via the returned bool so the caller can
|
||||
defer the broker lookup.
|
||||
"""
|
||||
network_devices: list[str] = []
|
||||
has_mqtt_lookup = False
|
||||
for device in devices:
|
||||
if get_port_type(device) in _MQTT_PORT_TYPES:
|
||||
has_mqtt_lookup = True
|
||||
else:
|
||||
_add_network_device(device, network_devices)
|
||||
return network_devices, has_mqtt_lookup
|
||||
|
||||
|
||||
def _resolve_network_devices(
|
||||
@@ -587,41 +644,47 @@ def _resolve_network_devices(
|
||||
if port_type in _MQTT_PORT_TYPES:
|
||||
# Only resolve MQTT once, even if multiple MQTT entries
|
||||
if not mqtt_resolved:
|
||||
try:
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
# 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_ips = _mqtt_get_ip_or_warn(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(
|
||||
addr for addr in mqtt_ips if addr not in network_devices
|
||||
)
|
||||
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)
|
||||
_add_network_device(device, network_devices)
|
||||
|
||||
return network_devices
|
||||
|
||||
|
||||
def _mqtt_get_ip_or_warn(
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
) -> list[str]:
|
||||
"""Look up the device IP via MQTT, returning [] with a warning on failure.
|
||||
|
||||
This owns the failure policy for MQTT IP discovery on paths that have
|
||||
other addresses to fall back on: a broker problem must not abort the
|
||||
operation. Also used as the deferred resolver handed to ``run_logs``,
|
||||
where it runs in a worker thread.
|
||||
"""
|
||||
try:
|
||||
return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
from aioesphomeapi import LogParser
|
||||
import serial
|
||||
|
||||
@@ -634,18 +697,9 @@ 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 = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
backtrace_state = False
|
||||
# Decoder resolution, crash isolation, and disable-after-failure
|
||||
# all live in LogLineProcessor, shared with the API log path.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = baud_rate
|
||||
ser.port = port
|
||||
@@ -685,11 +739,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
"utf8", "backslashreplace"
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
|
||||
if process_stacktrace is not None:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
processor.process_line(line)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -704,15 +754,19 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
|
||||
def _wrap_to_code(name, comp, yaml_util):
|
||||
import esphome.codegen as cg
|
||||
|
||||
coro = coroutine(comp.to_code)
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
async def wrapped(conf):
|
||||
cg.add(cg.LineComment(f"{name}:"))
|
||||
if comp.config_schema is not None:
|
||||
conf_str = yaml_util.dump(conf)
|
||||
# sort_keys: voluptuous fills defaults in set order, so an
|
||||
# unsorted dump would churn main.cpp and relink every run
|
||||
conf_str = yaml_util.dump(conf, sort_keys=True)
|
||||
conf_str = conf_str.replace("//", "")
|
||||
# remove tailing \ to avoid multi-line comment warning
|
||||
# remove trailing \ to avoid multi-line comment warning
|
||||
conf_str = conf_str.replace("\\\n", "\n")
|
||||
cg.add(cg.LineComment(indent(conf_str)))
|
||||
await coro(conf)
|
||||
@@ -739,6 +793,7 @@ def write_cpp(config: ConfigType) -> int:
|
||||
|
||||
def generate_cpp_contents(config: ConfigType) -> None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import iter_component_configs
|
||||
|
||||
_LOGGER.info("Generating C++ source...")
|
||||
|
||||
@@ -776,6 +831,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -795,7 +857,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
toolchain.get_idedata()
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -924,9 +999,10 @@ def upload_using_esptool(
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
|
||||
mcu = get_esp32_variant().lower()
|
||||
# Same lookup as esp32.get_esp32_variant(), read directly so the
|
||||
# serial upload path does not import the esp32 package; both the
|
||||
# validator and the warm-cache apply_to_core populate this key.
|
||||
mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
|
||||
|
||||
line_callbacks: list[Callable[[str], str | None]] = []
|
||||
if (
|
||||
@@ -980,6 +1056,8 @@ def upload_using_esptool(
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
import shutil
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
@@ -1017,6 +1095,8 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
the mass storage copy approach that causes "disk not ejected properly"
|
||||
warnings on macOS.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
@@ -1123,6 +1203,8 @@ def check_permissions(port: str):
|
||||
"the USB cable can be used for data and is not a power-only cable."
|
||||
)
|
||||
if not (os.access(port, os.R_OK | os.W_OK)):
|
||||
import getpass
|
||||
|
||||
raise EsphomeError(
|
||||
"You do not have read or write permission on the selected serial port. "
|
||||
"To resolve this issue, you can add your user to the dialout group "
|
||||
@@ -1135,12 +1217,11 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.upload_program(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
return 0, host
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
@@ -1291,25 +1372,23 @@ def _upload_via_native_api(
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
|
||||
from esphome import web_server_logs
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
port, username, password = get_web_server_connection(config)
|
||||
return web_server_logs.run_logs(network_devices, port, username, password)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
@@ -1400,12 +1479,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
platform_show_logs = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "show_logs"
|
||||
)
|
||||
if platform_show_logs is not None and platform_show_logs(config, args, devices):
|
||||
return 0
|
||||
|
||||
if "logger" not in config:
|
||||
raise EsphomeError("Logger is not configured!")
|
||||
@@ -1419,17 +1497,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
return run_miniterm(config, port, args)
|
||||
|
||||
# Check if we should use API for logging
|
||||
# Resolve MQTT magic strings to actual IP addresses
|
||||
if has_api() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
from esphome.components.api.client import run_logs
|
||||
if has_api():
|
||||
network_devices, has_mqtt_lookup = _split_network_devices(devices)
|
||||
mqtt_resolver = None
|
||||
if has_mqtt_lookup:
|
||||
if network_devices:
|
||||
# Addresses are already known, so don't block startup on the
|
||||
# MQTT broker lookup; hand it to run_logs as a deferred
|
||||
# resolver that runs in the background and feeds discovered
|
||||
# addresses into the running log client, keeping MQTT as a
|
||||
# fallback for when the known addresses are stale (e.g. DHCP
|
||||
# reassigned the IP).
|
||||
mqtt_resolver = functools.partial(
|
||||
_mqtt_get_ip_or_warn,
|
||||
config,
|
||||
args.username,
|
||||
args.password,
|
||||
args.client_id,
|
||||
)
|
||||
else:
|
||||
# The MQTT lookup is the only way to find the device; resolve
|
||||
# it up front since the client needs an address to start with.
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
if network_devices:
|
||||
from esphome.api_client import run_logs
|
||||
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
)
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
from esphome import mqtt
|
||||
@@ -1438,6 +1536,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
config, args.topic, args.username, args.password, args.client_id
|
||||
)
|
||||
|
||||
# Fall back to the web_server HTTP SSE log stream for devices that have
|
||||
# web_server: but no api: (the logging counterpart to web_server OTA).
|
||||
if has_web_server_logging() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
return _show_logs_via_web_server(config, network_devices)
|
||||
|
||||
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
|
||||
|
||||
|
||||
@@ -1457,6 +1562,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
|
||||
|
||||
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import strip_default_ids
|
||||
|
||||
if getattr(args, "no_defaults", False):
|
||||
user_config = getattr(config, "user_config", None)
|
||||
@@ -1510,10 +1616,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
@@ -1698,7 +1812,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
|
||||
from esphome.bundle import ConfigBundleCreator
|
||||
|
||||
creator = ConfigBundleCreator(config)
|
||||
|
||||
@@ -1933,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
@@ -1946,7 +2060,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
@@ -1993,7 +2107,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
> 1
|
||||
):
|
||||
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
@@ -2011,7 +2127,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2021,7 +2137,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2029,7 +2145,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
@@ -2037,7 +2153,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
@@ -2046,7 +2162,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
@@ -2072,7 +2188,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
@@ -2483,7 +2599,12 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
# argcomplete only does anything when the shell-completion machinery
|
||||
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
|
||||
if "_ARGCOMPLETE" in os.environ:
|
||||
import argcomplete
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
|
||||
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
@@ -2494,6 +2615,49 @@ def parse_args(argv):
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
"""Warn when the checkout the user is standing in is not the one being run.
|
||||
|
||||
An editable install records one absolute path, so a venv shared between git
|
||||
worktrees (or reused after a checkout is copied or renamed) keeps importing
|
||||
the tree it was installed from. Every command then silently runs, and
|
||||
compiles, sources the user is not looking at. Only fires inside a checkout,
|
||||
so ordinary installs never see it.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
return # working directory is gone; a diagnostic must not break startup
|
||||
for candidate in (cwd, *cwd.parents):
|
||||
if (candidate / "esphome" / "__main__.py").is_file():
|
||||
standing_in = candidate.resolve()
|
||||
break
|
||||
else:
|
||||
return # not inside a checkout; nothing to compare against
|
||||
|
||||
running = Path(__file__).resolve().parent.parent
|
||||
# Both sides are resolved, so on a case-sensitive filesystem this matches
|
||||
# plain equality. samefile() compares device and inode, which additionally
|
||||
# covers a case-insensitive filesystem (macOS) reaching one directory by
|
||||
# differently cased paths. Falls back to equality if either path is gone.
|
||||
try:
|
||||
same = standing_in.samefile(running)
|
||||
except OSError:
|
||||
same = standing_in == running
|
||||
if same:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Running ESPHome from a different checkout than the one you are in:\n"
|
||||
" running from: %s\n"
|
||||
" you are in: %s\n"
|
||||
"The installed esphome resolves to the first, so its sources are used.\n"
|
||||
"Run 'python -m esphome' from the second to use that one instead.",
|
||||
running,
|
||||
standing_in,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2512,6 +2676,7 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2543,10 +2708,11 @@ def run_esphome(argv):
|
||||
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
|
||||
# and rewrite conf_path to the extracted YAML config. The suffix check
|
||||
# stays inline so the ordinary run never imports esphome.bundle.
|
||||
if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
|
||||
from esphome.bundle import 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
|
||||
@@ -2568,10 +2734,14 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_eligible = (
|
||||
cache_write_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
if cache_eligible:
|
||||
# An explicit --toolchain must re-run the per-platform validators, so
|
||||
# gate only the cache read; the refresh below saves the result unless
|
||||
# the sidecar records a different toolchain.
|
||||
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||
if cache_read_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2581,31 +2751,32 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
if config is None:
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today.
|
||||
# The cache fast path skips validation, and legacy sidecars lack the
|
||||
# toolchain field. Must run before the cache refresh below.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_write_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.util import safe_print
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop.
|
||||
|
||||
If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
|
||||
has no asyncio support on Windows) concurrently with the connection
|
||||
attempts to ``addresses``, and any addresses it discovers are fed into
|
||||
the running client. It owns its own failure handling (returning [] when
|
||||
discovery fails) and must honor the ``threading.Event`` it is passed so
|
||||
teardown is not delayed by the lookup's wait window; the initial broker
|
||||
connect itself is only bounded by the socket timeout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Decoder resolution policy lives in LogLineProcessor.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
|
||||
mqtt_task: asyncio.Task[None] | None = None
|
||||
mqtt_stop_event = threading.Event()
|
||||
|
||||
def _cancel_mqtt_discovery() -> None:
|
||||
"""Stop the broker lookup once a connection has been established.
|
||||
|
||||
Its answer is only useful while still disconnected: after that it
|
||||
either duplicates the connected address or arrives too late to
|
||||
matter, so don't keep an idle broker session open for it.
|
||||
"""
|
||||
mqtt_stop_event.set()
|
||||
if mqtt_task is not None and not mqtt_task.done():
|
||||
mqtt_task.cancel()
|
||||
|
||||
async def _resolve_mqtt_addresses() -> None:
|
||||
"""Discover the device address via the MQTT broker in the background."""
|
||||
try:
|
||||
mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
|
||||
if not mqtt_ips:
|
||||
_LOGGER.debug(
|
||||
"MQTT discovery %s",
|
||||
"aborted" if mqtt_stop_event.is_set() else "found no addresses",
|
||||
)
|
||||
return
|
||||
if cli.add_addresses(mqtt_ips):
|
||||
_LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# A background task failure would otherwise stay invisible for
|
||||
# the whole session and only re-raise at teardown
|
||||
_LOGGER.exception("MQTT address discovery failed")
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
|
||||
)
|
||||
try:
|
||||
# Don't start (or keep) the broker lookup if a connection already
|
||||
# succeeded; the stop event doubles as the not-needed-anymore latch
|
||||
# and get_esphome_device_ip returns immediately when it is set.
|
||||
if mqtt_resolver is not None and not mqtt_stop_event.is_set():
|
||||
mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
try:
|
||||
if mqtt_task is not None:
|
||||
# Unblock the worker thread first so it can't hold up
|
||||
# loop.shutdown_default_executor() for the full lookup timeout.
|
||||
mqtt_stop_event.set()
|
||||
# Give the worker a moment to exit through its own error
|
||||
# handling; cancelling first would race out a late failure.
|
||||
done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
|
||||
if not done:
|
||||
mqtt_task.cancel()
|
||||
# return_exceptions keeps a CancelledError from the cancel()
|
||||
# above from re-raising here and jumping over the stop() below.
|
||||
# The task handles Exception itself, so only a BaseException
|
||||
# escape (e.g. SystemExit from the worker) can land here.
|
||||
(result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
|
||||
if isinstance(result, BaseException) and not isinstance(
|
||||
result, asyncio.CancelledError
|
||||
):
|
||||
_LOGGER.error("MQTT address discovery failed", exc_info=result)
|
||||
finally:
|
||||
# Must run even if a second cancellation lands mid-cleanup above
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(
|
||||
config,
|
||||
addresses,
|
||||
subscribe_states=subscribe_states,
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||
|
||||
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||
|
||||
Deliberately importable without the esp8266 component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||
|
||||
Artifacts land in a machine-global cache (shared across projects, like the
|
||||
ESP-IDF install in ``esphome.espidf.framework``):
|
||||
|
||||
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||
|
||||
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||
from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
)
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||
)
|
||||
|
||||
|
||||
def get_arduino8266_tools_path() -> Path:
|
||||
# Machine-global so all projects share one install; see
|
||||
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
framework: Path
|
||||
toolchain: Path
|
||||
ninja: Path
|
||||
|
||||
|
||||
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||
# Config validation enforces this too; keep the module honest when
|
||||
# called directly.
|
||||
raise EsphomeError(
|
||||
f"The native toolchain requires the Arduino core "
|
||||
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||
)
|
||||
# Probe the cheap local dependency before ~110 MB of downloads
|
||||
ninja_path = find_ninja()
|
||||
package_version = framework_package_version(framework_version)
|
||||
framework_path = get_framework_path(package_version)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
|
||||
|
||||
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||
|
||||
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||
Windows suffix, so a toolchain package bump touches one spot.
|
||||
"""
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||
|
||||
|
||||
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Drop empty entries: a trailing separator from an absent PATH would
|
||||
# make the shell search the current directory for tools
|
||||
parts = [
|
||||
str(toolchain_path / "bin"),
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
return env
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""Return ccache settings for the build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||
when disabled. Values the user already set in the environment are
|
||||
respected.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
+111
-18
@@ -11,43 +11,136 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from itertools import count
|
||||
import logging
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long the orphan watcher waits for an abandoned coroutine before giving
|
||||
# up, so a hung operation does not park a watcher thread forever.
|
||||
ORPHAN_WAIT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
_runner_ids = count(1)
|
||||
|
||||
|
||||
class AsyncDispatchTimeout(TimeoutError):
|
||||
"""The caller stopped waiting; the coroutine was abandoned.
|
||||
|
||||
A subclass so callers can tell the dispatcher's own expiry apart from a
|
||||
``TimeoutError`` raised inside the coroutine, while existing
|
||||
``except TimeoutError`` handlers keep working.
|
||||
"""
|
||||
|
||||
|
||||
class AsyncThreadRunner[T](threading.Thread):
|
||||
"""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
|
||||
``event`` is always set, even when the coroutine crashes, so waiters
|
||||
never hang; ``completed`` distinguishes a delivered result (even a
|
||||
legitimate ``None``) from a coroutine that never finished. Prefer
|
||||
:func:`run_async`; use this class directly only when a failure should
|
||||
degrade to a default value instead of raising.
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
|
||||
self._coro_factory = coro_factory
|
||||
self.result: T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.completed = False
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
# Distinguishes a delivered result from "never ran", since None
|
||||
# is a valid result value.
|
||||
self.completed = True
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture everything, including BaseException — otherwise a
|
||||
# cancellation or SystemExit would leave ``exception`` unset and
|
||||
# waiters would mistake the empty ``result`` for success.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self._runner())
|
||||
try:
|
||||
asyncio.run(self._runner())
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# asyncio.run itself can fail before _runner executes (e.g. loop
|
||||
# creation under fd exhaustion); record it so waiters never hang.
|
||||
# A failure during loop cleanup after the coroutine completed
|
||||
# must not clobber the delivered result, hence the guard.
|
||||
if self.exception is None and not self.completed:
|
||||
self.exception = exc
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Event loop teardown failed after outcome recorded",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
|
||||
def run_async[T](
|
||||
coro_factory: Callable[[], Awaitable[T]],
|
||||
timeout: float | None = None,
|
||||
on_orphan: Callable[[T], None] | None = None,
|
||||
) -> T:
|
||||
"""Run a coroutine in a daemon-thread event loop and return its result.
|
||||
|
||||
Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
|
||||
within ``timeout`` seconds; the thread is abandoned and exits with the
|
||||
interpreter. If the abandoned coroutine later produces a result,
|
||||
``on_orphan`` (if given) is called with it so resources such as a
|
||||
connected socket can be released; delivery is best effort and bounded
|
||||
by ``ORPHAN_WAIT_TIMEOUT``.
|
||||
"""
|
||||
runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout):
|
||||
|
||||
def _cleanup() -> None:
|
||||
if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
|
||||
# The one state where a resource can genuinely leak; leave
|
||||
# a trace so a recurring hang is attributable.
|
||||
_LOGGER.info(
|
||||
"Orphan watcher gave up after %.0fs; a late result may leak",
|
||||
ORPHAN_WAIT_TIMEOUT,
|
||||
)
|
||||
return
|
||||
if not runner.completed:
|
||||
# The only place an abandoned thread's real error surfaces;
|
||||
# without it a late failure hides behind the TimeoutError.
|
||||
# INFO, not DEBUG: it fires at most once per abandoned
|
||||
# operation and the cause may not reproduce on a rerun.
|
||||
_LOGGER.info(
|
||||
"Abandoned async operation failed",
|
||||
exc_info=runner.exception,
|
||||
)
|
||||
return
|
||||
if (result := runner.result) is None:
|
||||
return
|
||||
if on_orphan is None:
|
||||
_LOGGER.debug("Discarding late result; no on_orphan handler")
|
||||
return
|
||||
try:
|
||||
on_orphan(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# INFO, not DEBUG: a failed release means a real leak, and
|
||||
# it fires at most once per abandoned operation.
|
||||
_LOGGER.info("Error releasing orphaned result", exc_info=True)
|
||||
|
||||
threading.Thread(
|
||||
target=_cleanup, daemon=True, name="async-orphan-cleanup"
|
||||
).start()
|
||||
raise AsyncDispatchTimeout("Timed out waiting for async operation")
|
||||
if (exc := runner.exception) is not None:
|
||||
raise exc
|
||||
if not runner.completed:
|
||||
raise RuntimeError("Async operation finished without a result or an exception")
|
||||
return cast("T", runner.result)
|
||||
|
||||
+153
-35
@@ -1,17 +1,26 @@
|
||||
"""ESP-IDF direct build generator for ESPHome."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
get_managed_component_require_names,
|
||||
idf_version,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf import variant_to_idf_target
|
||||
from esphome.framework_helpers import (
|
||||
get_project_compile_flags,
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
||||
# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
|
||||
@@ -24,13 +33,54 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
|
||||
list(APPEND esphome_cxx_compile_options "-std={standard}")
|
||||
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
|
||||
|
||||
# Drops the app archive from ldgen's inputs so app-only edits skip the
|
||||
# sections.ld regeneration. Safe: no mapping fragment references it
|
||||
# (run_compile re-checks each build). Filters only the top-level call;
|
||||
# the prior definition stays reachable with an underscore prefix.
|
||||
_LDGEN_OVERRIDE = """\
|
||||
if(COMMAND __ldgen_get_lib_deps_of_target)
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
|
||||
function(__ldgen_get_lib_deps_of_target target out_list_var)
|
||||
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
|
||||
message(FATAL_ERROR "ESPHome ldgen override lost the original "
|
||||
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
|
||||
endif()
|
||||
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
|
||||
if(out_list_var STREQUAL "ldgen_libraries")
|
||||
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_before)
|
||||
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
|
||||
list(LENGTH ${out_list_var} esphome_ldgen_after)
|
||||
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
|
||||
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
|
||||
"nothing; app edits will regenerate sections.ld.")
|
||||
endif()
|
||||
endif()
|
||||
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
else()
|
||||
message(@MISSING@ "ESPHome ldgen override target not found; "
|
||||
"app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
# Runs after project() so the walk has happened; catches the remaining
|
||||
# silent path where the top-level out-var was renamed.
|
||||
_LDGEN_OVERRIDE_CHECK = """\
|
||||
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
|
||||
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
|
||||
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
|
||||
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
|
||||
"archive; app edits will regenerate sections.ld.")
|
||||
endif()"""
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""Get list of built-in ESP-IDF components from project_description.json.
|
||||
"""List the built-in ESP-IDF components from ``project_description.json``.
|
||||
|
||||
Excludes ``src``, IDF-managed components (``managed_components/``), and
|
||||
converted PIO libs (``pio_components/``). Returns ``None`` if the build
|
||||
dir or ``project_description.json`` isn't ready yet.
|
||||
Only components below its ``idf_path/components`` count, which leaves out
|
||||
``src``, IDF-managed components, converted PIO libs and project local
|
||||
ones such as the Arduino ``component_stubs``. Returns ``None`` if the
|
||||
build dir or ``project_description.json`` isn't ready yet.
|
||||
"""
|
||||
if CORE.build_path is None:
|
||||
return None
|
||||
@@ -41,41 +91,44 @@ def get_available_components() -> list[str] | None:
|
||||
try:
|
||||
with project_desc.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
component_info = data.get("build_component_info", {})
|
||||
|
||||
result = []
|
||||
for name, info in component_info.items():
|
||||
# Exclude our own src component
|
||||
if name == "src":
|
||||
continue
|
||||
|
||||
# Exclude IDF-managed and converted-PIO components (external).
|
||||
comp_dir = info.get("dir", "")
|
||||
if "managed_components" in comp_dir or "pio_components" in comp_dir:
|
||||
continue
|
||||
|
||||
result.append(name)
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, OSError):
|
||||
root = (Path(data["idf_path"]) / "components").resolve()
|
||||
result = [
|
||||
name
|
||||
for name, info in data.get("build_component_info", {}).items()
|
||||
if (comp_dir := info.get("dir"))
|
||||
and Path(comp_dir).resolve().is_relative_to(root)
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError, OSError) as err:
|
||||
_LOGGER.debug("Could not read %s: %s", project_desc, err)
|
||||
return None
|
||||
if not result:
|
||||
_LOGGER.warning("No ESP-IDF components found under %s", root)
|
||||
return result
|
||||
|
||||
|
||||
def has_discovered_components() -> bool:
|
||||
"""Check if we have discovered components from a previous configure."""
|
||||
return get_available_components() is not None
|
||||
"""Check if a previous configure discovered any built-in components."""
|
||||
return bool(get_available_components())
|
||||
|
||||
|
||||
def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
def _cmake_quote(value: str) -> str:
|
||||
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def get_project_cmakelists(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
||||
|
||||
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
||||
since ``project_description.json`` may be stale on the first write.
|
||||
``builtin_components`` supplies the discovered list (from the cache)
|
||||
instead of reading it from ``project_description.json``.
|
||||
"""
|
||||
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
idf_target = variant_to_idf_target(get_esp32_variant())
|
||||
|
||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||
@@ -109,6 +162,31 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
else ""
|
||||
)
|
||||
|
||||
# Stops the ~3s sections.ld regeneration on app-only edits; see
|
||||
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
|
||||
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
|
||||
# breaks the override instead of degrading to stock deps.
|
||||
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
|
||||
ldgen_override = ""
|
||||
ldgen_override_check = ""
|
||||
else:
|
||||
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
|
||||
severity = "FATAL_ERROR" if strict else "WARNING"
|
||||
missing = "FATAL_ERROR" if strict else "STATUS"
|
||||
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
|
||||
"@MISSING@", missing
|
||||
)
|
||||
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
|
||||
|
||||
# CMake variables registered via cg.add_cmake_arg(). Emitted before
|
||||
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
|
||||
# set when project.cmake seeds the component list, and on minimal
|
||||
# (discovery) writes too so excluded components never register.
|
||||
cmake_args = "\n".join(
|
||||
f"set({name} {_cmake_quote(value)})"
|
||||
for name, value in sorted(CORE.cmake_args.items())
|
||||
)
|
||||
|
||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
||||
# project-specific names into their cached CMakeLists.
|
||||
@@ -119,8 +197,6 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
# runs as a separate CMake script invocation that doesn't load the
|
||||
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
||||
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
||||
from esphome.components.esp32 import get_managed_component_require_names
|
||||
|
||||
managed_components_property = "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
||||
for name in get_managed_component_require_names()
|
||||
@@ -131,12 +207,24 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
# component's REQUIRES including real IDF components). Referenced by
|
||||
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
||||
# on minimal writes because project_description.json may be stale.
|
||||
# Excluded components are dropped here as well: a stale
|
||||
# project_description.json from a build without exclusions may still
|
||||
# list them, and requiring an excluded component pulls it back into
|
||||
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
|
||||
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
|
||||
# two can never disagree within one generated file.
|
||||
builtin_components_property = (
|
||||
""
|
||||
if minimal
|
||||
else "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
||||
for name in sorted(get_available_components() or [])
|
||||
for name in sorted(
|
||||
set(
|
||||
builtin_components
|
||||
if builtin_components is not None
|
||||
else get_available_components() or []
|
||||
).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -163,8 +251,12 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
|
||||
set(IDF_TARGET {idf_target})
|
||||
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||
|
||||
{cmake_args}
|
||||
|
||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{ldgen_override}
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
@@ -177,6 +269,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
{ldgen_override_check}
|
||||
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
@@ -210,15 +304,27 @@ def get_component_cmakelists() -> str:
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
@@ -236,7 +342,9 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
"""
|
||||
|
||||
|
||||
def write_project(minimal: bool = False) -> None:
|
||||
def write_project(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> None:
|
||||
"""Write ESP-IDF project files."""
|
||||
mkdir_p(CORE.build_path)
|
||||
mkdir_p(CORE.relative_src_path())
|
||||
@@ -244,7 +352,7 @@ def write_project(minimal: bool = False) -> None:
|
||||
# Write top-level CMakeLists.txt
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("CMakeLists.txt"),
|
||||
get_project_cmakelists(minimal=minimal),
|
||||
get_project_cmakelists(minimal=minimal, builtin_components=builtin_components),
|
||||
)
|
||||
|
||||
# Write component CMakeLists.txt in src/
|
||||
@@ -252,3 +360,13 @@ def write_project(minimal: bool = False) -> None:
|
||||
CORE.relative_src_path("CMakeLists.txt"),
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||
# discovery reconfigure when it changes. Excluded components never
|
||||
# register in project_description.json, so re-including one (e.g. a
|
||||
# config gains mqtt) requires a fresh discovery pass before the
|
||||
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("exclude_components.esphomeinternal"),
|
||||
";".join(get_excluded_builtin_components()),
|
||||
)
|
||||
|
||||
@@ -63,6 +63,17 @@ def get_ini_content():
|
||||
# Add extra script for C++ flags
|
||||
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
|
||||
|
||||
# Add CMake args. A user-supplied value (str or list) is deliberately
|
||||
# replaced; this option was always overwritten at FINAL priority.
|
||||
if CORE.cmake_args:
|
||||
CORE.add_platformio_option(
|
||||
"board_build.cmake_extra_args",
|
||||
" ".join(
|
||||
f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
|
||||
),
|
||||
replace=True,
|
||||
)
|
||||
|
||||
content = "[platformio]\n"
|
||||
content += f"description = ESPHome {__version__}\n"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Build helpers shared by the native (non-PlatformIO) toolchains."""
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||
resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
lowered = raw.strip().lower()
|
||||
if not lowered:
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
return False
|
||||
if lowered in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ccache_path() -> str | None:
|
||||
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||
|
||||
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||
Windows extended-length prefix is stripped before probing (#18399).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||
if explicit is False:
|
||||
return None
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return None
|
||||
ccache = strip_win_long_path_prefix(ccache)
|
||||
if not explicit and not _ccache_runs(ccache):
|
||||
return None
|
||||
return ccache
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
Values the user already set in the environment are respected. Depend
|
||||
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||
keeps cache-miss overhead low.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
# An unset build_path means the env was built before preload; fail loudly
|
||||
# rather than silently drop CCACHE_BASEDIR.
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``.
|
||||
"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
|
||||
|
||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF
|
||||
toolchain has no such command, but its CMake build emits
|
||||
``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module
|
||||
turns that file into the same fields consumers (IDE integration, clang-tidy)
|
||||
expect:
|
||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
|
||||
toolchains have no such command, but each build produces a
|
||||
``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
|
||||
compdb tool otherwise). This module turns that file into the same fields
|
||||
consumers (IDE integration, clang-tidy) expect:
|
||||
|
||||
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||
"""
|
||||
@@ -18,6 +18,19 @@ from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
# Everything idedata generation may raise after a successful link; idedata
|
||||
# is a bonus artifact, so consumers warn instead of failing the build
|
||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
EsphomeError,
|
||||
LookupError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||
@@ -30,12 +43,8 @@ _ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
|
||||
def _is_esphome_src(file: str) -> bool:
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit.
|
||||
|
||||
``compile_commands.json`` ``file`` paths use the OS-native separator, so on
|
||||
Windows they contain backslashes; normalize to ``/`` before testing the
|
||||
marker, otherwise no source matches and the build-include union is empty.
|
||||
"""
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||
``/`` first since Windows compile DBs use backslashes."""
|
||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||
_CXX_SUFFIXES
|
||||
)
|
||||
@@ -106,11 +115,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
|
||||
|
||||
def _pick_entry(entries: list[dict]) -> dict:
|
||||
"""Pick a representative ESPHome C++ translation unit.
|
||||
|
||||
All ESPHome sources share the same component flags/defines, so any one of
|
||||
them yields the cxx_path / cxx_flags / defines we need.
|
||||
"""
|
||||
"""Pick a representative ESPHome C++ TU; all share the same component
|
||||
flags/defines."""
|
||||
for entry in entries:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
@@ -120,25 +126,46 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
|
||||
def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
|
||||
# Compiler launchers that may prefix a compile command; a closed launcher
|
||||
# denylist beats enumerating compiler names, an open set.
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||
directory = Path(entry["directory"])
|
||||
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Include paths in compile_commands are interpreted relative to the
|
||||
# entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them
|
||||
# so the cached idedata is usable regardless of the consumer's cwd.
|
||||
# Emit forward slashes (``normpath`` yields ``\`` on Windows) so the
|
||||
# paths match the absolute, already-forward-slash entries in the JSON.
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
# from any cwd; emit forward slashes to match the JSON's own entries
|
||||
raw = raw.strip()
|
||||
if raw and not Path(raw).is_absolute():
|
||||
raw = os.path.normpath(directory / raw)
|
||||
return raw.replace("\\", "/")
|
||||
|
||||
# A launcher-wrapped command ("ccache g++ ...") names the compiler second
|
||||
if launcher is not None and tokens[:1] == [launcher]:
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
# An empty command, or one that was only the launcher; fail by name
|
||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
# Stale DB built with a launcher this run no longer configures; the
|
||||
# real compiler is the next token
|
||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||
tokens = tokens[1:]
|
||||
# token0 is the compiler path; the rest of the command already uses forward
|
||||
# slashes on Windows, so normalize it too for a consistent idedata file.
|
||||
cxx_path = tokens[0].replace("\\", "/")
|
||||
# Enforced here so no caller can record ccache as the compiler
|
||||
reject_launcher_compiler(cxx_path)
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: list[str] = []
|
||||
@@ -168,7 +195,7 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
def _get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
def get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
||||
result = subprocess.run(
|
||||
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
||||
@@ -219,26 +246,128 @@ def _cc_path_from_cxx(cxx_path: str) -> str:
|
||||
return f"{stem}{suffix}"
|
||||
|
||||
|
||||
def idedata_from_build(compile_commands: Path) -> dict:
|
||||
def _cache_usable(cached: object) -> bool:
|
||||
"""Check a cached idedata dict against the guarantees of the write path.
|
||||
|
||||
Caches written by older versions predate the launcher rejection and the
|
||||
include-union shape; serving one would bypass both. The dict check also
|
||||
keeps "in" from substring-matching a bare JSON string.
|
||||
"""
|
||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||
return False
|
||||
cxx_path = cached.get("cxx_path")
|
||||
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
|
||||
|
||||
def load_or_build_idedata(
|
||||
compile_commands: Path,
|
||||
elf_path: Path,
|
||||
cache: Path,
|
||||
launcher: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||
|
||||
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||
is the compiler-launcher path (ccache) the build was generated with, if
|
||||
any; commands in the compile DB are prefixed with it.
|
||||
"""
|
||||
if not compile_commands.is_file():
|
||||
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||
return None
|
||||
|
||||
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
|
||||
try:
|
||||
cached = json.loads(cache.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError) as err:
|
||||
# A recurring cause (interrupted write, disk full) would otherwise
|
||||
# look like unexplained slow builds
|
||||
_LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
|
||||
else:
|
||||
if _cache_usable(cached):
|
||||
# Re-stamp so a relocated build dir cannot serve a stale ELF path
|
||||
cached["prog_path"] = str(elf_path)
|
||||
return cached
|
||||
_LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
|
||||
|
||||
data = idedata_from_build(compile_commands, launcher)
|
||||
data["prog_path"] = str(elf_path)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic so a crash mid-write cannot leave a truncated cache
|
||||
write_file(cache, json.dumps(data, indent=2) + "\n")
|
||||
return data
|
||||
|
||||
|
||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||
must never be probed, cached, or consumed."""
|
||||
if _is_launcher(cxx_path):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
)
|
||||
|
||||
|
||||
def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
|
||||
"""Parse compile_commands.json into the idedata fields consumers expect.
|
||||
|
||||
A single ESP-IDF compile entry only carries its own component's REQUIRES
|
||||
include set, but consumers (clang-tidy) analyze ESPHome headers that
|
||||
transitively pull in other components. So take cxx_path / cxx_flags /
|
||||
defines from a representative ESPHome TU, but union the include dirs across
|
||||
all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata
|
||||
provides).
|
||||
A single compile entry only carries the include set its own translation
|
||||
unit was built with (per-component under ESP-IDF), but consumers
|
||||
(clang-tidy) analyze ESPHome headers that transitively pull in other
|
||||
components. So take cxx_path / cxx_flags / defines from a representative
|
||||
ESPHome TU, but union the include dirs across all ESPHome TUs to get a
|
||||
project-wide superset (as PlatformIO's idedata provides).
|
||||
"""
|
||||
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
||||
cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries))
|
||||
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
|
||||
# A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
|
||||
raise EsphomeError(f"{compile_commands} is not a compile-command list")
|
||||
|
||||
build_includes: dict[str, None] = {}
|
||||
representative = _pick_entry(entries)
|
||||
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
|
||||
|
||||
# Seed with the representative's includes so it is not parsed twice
|
||||
has_esphome_tu = _is_esphome_src(representative["file"])
|
||||
build_includes: dict[str, None] = dict.fromkeys(
|
||||
rep_includes if has_esphome_tu else ()
|
||||
)
|
||||
|
||||
def _shape(entry: dict) -> str:
|
||||
# directory + command minus TU-specific paths: same shape means the
|
||||
# same include set, so tokenize once per shape. Response-file
|
||||
# commands never dedupe (the .rsp contents differ per object)
|
||||
command = entry["command"]
|
||||
directory = entry.get("directory", "")
|
||||
if "@" in command:
|
||||
return f"unique:{directory}|{entry.get('output') or command}"
|
||||
stripped = command.replace(entry.get("file", ""), "").replace(
|
||||
entry.get("output", ""), ""
|
||||
)
|
||||
return f"{directory}|{stripped}"
|
||||
|
||||
seen_shapes = {_shape(representative)}
|
||||
for entry in entries:
|
||||
if not _is_esphome_src(entry["file"]):
|
||||
if entry is representative or not _is_esphome_src(entry["file"]):
|
||||
continue
|
||||
for inc in _parse_entry(entry)[2]:
|
||||
has_esphome_tu = True
|
||||
if (shape := _shape(entry)) in seen_shapes:
|
||||
_LOGGER.debug("Include union: %s shares a command shape", entry["file"])
|
||||
continue
|
||||
seen_shapes.add(shape)
|
||||
for inc in parse_entry(entry, launcher)[2]:
|
||||
build_includes.setdefault(inc, None)
|
||||
|
||||
if not has_esphome_tu:
|
||||
# An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
|
||||
# warning would be cached into permanence; call sites downgrade this
|
||||
raise EsphomeError(
|
||||
f"No ESPHome translation unit found in {compile_commands}; "
|
||||
"refusing to cache unusable idedata"
|
||||
)
|
||||
|
||||
return {
|
||||
"cc_path": _cc_path_from_cxx(cxx_path),
|
||||
"cxx_path": cxx_path,
|
||||
@@ -246,6 +375,6 @@ def idedata_from_build(compile_commands: Path) -> dict:
|
||||
"defines": defines,
|
||||
"includes": {
|
||||
"build": list(build_includes),
|
||||
"toolchain": _get_toolchain_includes(cxx_path),
|
||||
"toolchain": get_toolchain_includes(cxx_path),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ninja_runs(binary: str) -> bool:
|
||||
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||
return tool_version_runs(
|
||||
binary,
|
||||
"Ignoring ninja at %s because it failed to run; "
|
||||
"falling back to the bundled wheel",
|
||||
)
|
||||
|
||||
|
||||
def find_ninja() -> Path:
|
||||
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||
PyPI wheel."""
|
||||
if binary := shutil.which("ninja"):
|
||||
binary = strip_win_long_path_prefix(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
try:
|
||||
import ninja
|
||||
except ImportError as err:
|
||||
import_error = err
|
||||
wheel_binary = None
|
||||
else:
|
||||
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||
"ninja.exe" if os.name == "nt" else "ninja"
|
||||
)
|
||||
if wheel_binary is None or not wheel_binary.is_file():
|
||||
raise EsphomeError(
|
||||
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||
"esphome Python environment"
|
||||
) from import_error
|
||||
return wheel_binary
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||
already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
# Force-quote any token containing a character outside the shlex.quote-style
|
||||
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||
# and friends would be re-parsed as shell syntax.
|
||||
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||
|
||||
|
||||
def shell_token(tok: str, force: bool = False) -> str:
|
||||
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||
|
||||
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||
the command reaches the shell.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||
return tok
|
||||
# An empty token must become '' / "" or it vanishes from the argv
|
||||
if os.name == "nt":
|
||||
return quote_arg(tok)
|
||||
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||
# re-examined for safe characters
|
||||
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def quote_path(value: Path | str) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return shell_token(str(value), force=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""The PlatformIO-format size bar shared by the native toolchains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_bar(used: int, total: int) -> str:
|
||||
"""Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
|
||||
pct_raw = used / total if total else 0
|
||||
blocks = 10
|
||||
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||
progress = "=" * filled
|
||||
return (
|
||||
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||
f"(used {used:d} bytes from {total:d} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def print_size_line(label: str, used: int, total: int) -> None:
|
||||
"""One PlatformIO-format summary line (``RAM``/``Flash``).
|
||||
|
||||
The label padding is part of the format: ``script/ci_memory_impact_extract.py``
|
||||
matches these lines verbatim.
|
||||
"""
|
||||
print(f"{label + ':':<7}{format_bar(used, total)}")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Machine-global tools cache location shared by the native backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
"""A backend's machine-global tools directory, with an env override.
|
||||
|
||||
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||
to the CWD, which ``clean-all`` would then delete.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
if prefix := get_str_env(env_var, "").strip():
|
||||
# resolve(): symlinked prefixes otherwise trip idf.py's
|
||||
# venv-mismatch warning on every build
|
||||
return Path(prefix).expanduser().resolve()
|
||||
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||
# deep IDF trees run into MAX_PATH otherwise
|
||||
return (
|
||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
|
||||
).resolve()
|
||||
|
||||
|
||||
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||
# every entry via tools_cache_path, so listing a cache here is the single
|
||||
# step that registers it for removal; the backends' own path getters use
|
||||
# the same named pairs so the two cannot drift.
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||
+137
-7
@@ -12,7 +12,7 @@ from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -20,6 +20,7 @@ from typing import Any
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.const import (
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_ESPHOME,
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_INCLUDES,
|
||||
@@ -29,12 +30,12 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import filter_yaml_files
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
@@ -51,6 +52,7 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -127,6 +129,15 @@ class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Directories whose YAML files are scanned for !secret references but
|
||||
# never bundled, e.g. git package checkouts the builder re-fetches.
|
||||
secret_scan_dirs: set[Path] = field(default_factory=set)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
@@ -148,6 +159,118 @@ def add_bundle_file(path: Path) -> None:
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
def add_secret_scan_dir(path: Path) -> None:
|
||||
"""Register a directory to scan for ``!secret`` references when bundling.
|
||||
|
||||
The directory's files are not added to the bundle. Components call this
|
||||
for YAML the build consumes without bundling it — such as git-fetched
|
||||
packages, which the builder re-fetches — so the secrets those files
|
||||
reference are still shipped in the filtered secrets file.
|
||||
|
||||
A relative path is taken as relative to the config directory.
|
||||
"""
|
||||
if not path.is_absolute():
|
||||
path = CORE.relative_config_path(path)
|
||||
_get_data().secret_scan_dirs.add(path)
|
||||
|
||||
|
||||
def _secret_scan_yaml_files() -> list[Path]:
|
||||
"""Return the YAML files inside registered secret-scan directories."""
|
||||
return filter_yaml_files(
|
||||
f
|
||||
for scan_dir in _get_data().secret_scan_dirs
|
||||
for f in yaml_util.find_files(scan_dir, "*")
|
||||
)
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -174,6 +297,7 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -214,6 +338,7 @@ class ConfigBundleCreator:
|
||||
yaml_sources = [
|
||||
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
|
||||
]
|
||||
yaml_sources.extend(_secret_scan_yaml_files())
|
||||
used_secret_keys = _find_used_secret_keys(yaml_sources)
|
||||
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
|
||||
|
||||
@@ -298,6 +423,13 @@ class ConfigBundleCreator:
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
self._secrets_paths.update(discovered.secrets)
|
||||
# A !secret inside a file this re-parse does not reach (for example
|
||||
# a git-fetched package the builder re-fetches) still resolves
|
||||
# against the config-dir secrets.yaml at build time, so always
|
||||
# consider that file; filtering no-ops when no key matches.
|
||||
default_secrets = self._config_dir / yaml_util.SECRET_YAML
|
||||
if default_secrets.is_file():
|
||||
self._secrets_paths.add(default_secrets.resolve())
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
@@ -438,6 +570,7 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -522,12 +655,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -620,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add,
|
||||
add_build_flag,
|
||||
add_build_unflag,
|
||||
add_cmake_arg,
|
||||
add_cxx_build_flag,
|
||||
add_define,
|
||||
add_global,
|
||||
@@ -49,10 +50,13 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
build_registry_entry,
|
||||
build_registry_list,
|
||||
extract_registry_entry_config,
|
||||
get_slot_count,
|
||||
gpio_pin_expression,
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
slot_counter,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
+186
-43
@@ -1,26 +1,193 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
|
||||
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
|
||||
paths, UUIDs and enums store the same string form the YAML dumper
|
||||
produced for them. JSON additionally coerces non-str dict keys to
|
||||
strings; validated configs only use string keys (every schema key
|
||||
validator is ``cv.string``). mtime gates staleness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bump when the on-disk shape changes; a mismatched version falls back
|
||||
# to read_config. The envelope also stamps the writing esphome version:
|
||||
# after an upgrade the cache holds the previous release's validation, so
|
||||
# it falls back once and the re-save self-heals.
|
||||
_CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
Mode 0600 because config validation resolved !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
try:
|
||||
# The legacy YAML cache holds inline-resolved secrets and nothing
|
||||
# reads it anymore; drop it even when the write below fails. A
|
||||
# failed removal leaves resolved secrets on disk, so it warns.
|
||||
try:
|
||||
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not remove the legacy validated-config cache: %s", err
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
|
||||
separators=(",", ":"),
|
||||
default=_json_default,
|
||||
)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except TypeError as err:
|
||||
# Structural, not transient: this config can never cache (e.g. a
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
if (
|
||||
old.toolchain is not None
|
||||
and CORE.toolchain is not None
|
||||
and old.toolchain != CORE.toolchain.value
|
||||
):
|
||||
# Platforms normalize toolchain-sensitive keys differently;
|
||||
# never cache a config validated under a different toolchain
|
||||
# than the compile's
|
||||
_LOGGER.debug(
|
||||
"Not caching: config validated with toolchain %r but the "
|
||||
"last compile used %r",
|
||||
CORE.toolchain.value,
|
||||
old.toolchain,
|
||||
)
|
||||
return False
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, a different cache
|
||||
version, or the sidecar is incomplete. The loaded config carries no
|
||||
source ranges; callers must not feed it into read_config/write_cpp.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
envelope = json.loads(
|
||||
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
|
||||
)
|
||||
except (OSError, ValueError) as err:
|
||||
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(envelope, dict)
|
||||
or envelope.get("v") != _CACHE_VERSION
|
||||
or envelope.get("esphome") != ESPHOME_VERSION
|
||||
or not isinstance(config := envelope.get("config"), dict)
|
||||
):
|
||||
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
|
||||
# Remove before 2027.8: by then every maintained install has saved the
|
||||
# JSON cache at least once and dropped its legacy YAML file.
|
||||
def _legacy_compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path of the pre-JSON YAML cache; only ever removed."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
@@ -32,45 +199,21 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
|
||||
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
|
||||
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
IncludeFile/Extend/Remove have no JSON mirror and would stringify
|
||||
wrong, but none survive validation (config.py's packages merge and
|
||||
the substitution pass consume them) so no guard is spent on them.
|
||||
"""
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
if isinstance(value, Lambda):
|
||||
return {_LAMBDA_KEY: value.value}
|
||||
return str(value)
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
def _decode_object(obj: dict[str, Any]) -> Any:
|
||||
"""Revive the Lambda sentinel; every other mapping passes through."""
|
||||
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
|
||||
return Lambda(value)
|
||||
return obj
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Component alias registry.
|
||||
|
||||
Generated by script/build_alias_registry.py - do not edit manually.
|
||||
See the component-alias section of esphome/loader.py.
|
||||
"""
|
||||
|
||||
# alias -> (canonical component, removal version or None)
|
||||
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
|
||||
"rp2040": ("rp2", "2027.7.0"),
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_METER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@MrSuicideParrot"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_MILLIMETER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@TH-Braemer"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
@@ -3,6 +3,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import stepper
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN
|
||||
from esphome.types import ConfigType
|
||||
|
||||
a4988_ns = cg.esphome_ns.namespace("a4988")
|
||||
A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component)
|
||||
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = stepper.STEPPER_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await stepper.register_stepper(var, config)
|
||||
|
||||
@@ -9,6 +9,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_GRAMS_PER_CUBIC_METER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity")
|
||||
AbsoluteHumidityComponent = absolute_humidity_ns.class_(
|
||||
@@ -43,7 +44,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@glmnet"]
|
||||
|
||||
@@ -48,7 +49,13 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if CORE.is_esp32:
|
||||
from esphome.components.esp32 import include_builtin_idf_component
|
||||
|
||||
# Re-enable the gptimer driver (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_driver_gptimer")
|
||||
|
||||
if CORE.is_esp8266:
|
||||
# ac_dimmer uses setTimer1Callback which requires the waveform generator
|
||||
from esphome.components.esp8266.const import require_waveform
|
||||
|
||||
@@ -4,6 +4,9 @@ from esphome.components.light.effects import register_addressable_effect
|
||||
from esphome.components.light.types import AddressableLightEffect
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_NAME, CONF_UART_ID
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
@@ -21,7 +24,7 @@ CONFIG_SCHEMA = cv.Schema({})
|
||||
"Adalight",
|
||||
{cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)},
|
||||
)
|
||||
async def adalight_light_effect_to_code(config, effect_id):
|
||||
async def adalight_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj:
|
||||
effect = cg.new_Pvariable(effect_id, config[CONF_NAME])
|
||||
await uart.register_uart_device(effect, config)
|
||||
return effect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
@@ -16,6 +18,7 @@ from esphome.components.esp32 import (
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
@@ -225,12 +228,13 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
|
||||
}
|
||||
|
||||
|
||||
def validate_adc_pin(value):
|
||||
def validate_adc_pin(value: Any) -> ConfigType | str:
|
||||
if str(value).upper() == "VCC":
|
||||
if CORE.is_rp2:
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.common";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
const LogString *sampling_mode_to_str(SamplingMode mode) {
|
||||
switch (mode) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.esp32";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.esp8266";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.libretiny";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
@@ -17,7 +17,26 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
|
||||
if (this->is_temperature_) {
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
delay(1);
|
||||
adc_select_input(4);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
|
||||
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
|
||||
raw = adc_read();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.zephyr";
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
if (!adc_is_ready_dt(this->channel_)) {
|
||||
|
||||
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
zephyr_add_overlay_builder,
|
||||
zephyr_add_prj_conf,
|
||||
zephyr_add_user,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
|
||||
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
|
||||
|
||||
|
||||
def validate_config(config):
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
|
||||
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
|
||||
|
||||
@@ -67,6 +67,13 @@ def validate_config(config):
|
||||
# Alter value here so `config` command prints the recommended change
|
||||
config[CONF_ATTENUATION] = _attenuation("12db")
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if config[CONF_PIN] == "TEMPERATURE":
|
||||
_LOGGER.warning(
|
||||
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
|
||||
"sensor platform instead. Will be removed in 2027.2.0"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -113,7 +120,19 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
def _overlay_io_channels() -> str:
|
||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||
return f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
io-channels = {entries};
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await sensor.register_sensor(var, config)
|
||||
@@ -121,6 +140,7 @@ async def to_code(config):
|
||||
if config[CONF_PIN] == "VCC":
|
||||
cg.add_define("USE_ADC_SENSOR_VCC")
|
||||
elif config[CONF_PIN] == "TEMPERATURE":
|
||||
# Remove before 2027.2.0
|
||||
cg.add(var.set_is_temperature())
|
||||
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
@@ -173,9 +193,8 @@ async def to_code(config):
|
||||
if isinstance(pin_number, int):
|
||||
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
|
||||
pin_number = GPIO_TO_AIN[pin_number]
|
||||
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
zephyr_add_overlay_builder(_overlay_io_channels)
|
||||
zephyr_add_overlay(f"""
|
||||
&adc {{
|
||||
#address-cells = <1>;
|
||||
#size-cells = <0>;
|
||||
@@ -190,8 +209,7 @@ async def to_code(config):
|
||||
zephyr,oversampling = <8>;
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
MULTI_CONF = True
|
||||
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await spi.register_spi_device(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import sensor, voltage_sampler
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import ADC128S102, adc128s102_ns
|
||||
|
||||
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
config[CONF_CHANNEL],
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@justfalter"]
|
||||
|
||||
@@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID])
|
||||
cg.add(var.set_width(config[CONF_WIDTH]))
|
||||
|
||||
@@ -36,6 +36,7 @@ from esphome.const import (
|
||||
UNIT_WATT,
|
||||
UNIT_WATT_HOURS,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
@@ -243,7 +244,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def neutral_channel(config):
|
||||
async def neutral_channel(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
current = config[CONF_CURRENT]
|
||||
@@ -257,7 +258,7 @@ async def neutral_channel(config):
|
||||
return var
|
||||
|
||||
|
||||
async def power_channel(config):
|
||||
async def power_channel(config: ConfigType) -> MockObj:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
for sensor_type in POWER_SENSOR_TYPES:
|
||||
@@ -280,7 +281,7 @@ async def power_channel(config):
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -23,6 +23,8 @@ from esphome.const import (
|
||||
UNIT_VOLT_AMPS_REACTIVE,
|
||||
UNIT_WATT,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
|
||||
@@ -163,7 +165,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("60s"))
|
||||
|
||||
|
||||
async def register_ade7953(var, config):
|
||||
async def register_ade7953(var: MockObj, config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if irq_pin_config := config.get(CONF_IRQ_PIN):
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import ade7953_base, i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["ade7953_base"]
|
||||
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await i2c.register_i2c_device(var, config)
|
||||
await ade7953_base.register_ade7953(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import ade7953_base, spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
AUTO_LOAD = ["ade7953_base"]
|
||||
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await spi.register_spi_device(var, config)
|
||||
await ade7953_base.register_ade7953(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
MULTI_CONF = True
|
||||
@@ -24,7 +25,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns
|
||||
|
||||
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await sensor.register_sensor(var, config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@solomondg1"]
|
||||
DEPENDENCIES = ["spi"]
|
||||
@@ -23,7 +24,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await spi.register_spi_device(var, config)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns
|
||||
|
||||
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_ADS1118_ID])
|
||||
|
||||
@@ -17,6 +17,9 @@ from esphome.const import (
|
||||
UNIT_OHM,
|
||||
UNIT_PARTS_PER_BILLION,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_RESISTANCE = "resistance"
|
||||
|
||||
@@ -62,7 +65,7 @@ CONFIG_SCHEMA = (
|
||||
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
@@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
AGS10_NEW_I2C_ADDRESS_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
|
||||
async def ags10newi2caddress_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
|
||||
@@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
|
||||
AGS10_SET_ZERO_POINT_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
|
||||
async def ags10setzeropoint_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
mode = await cg.templatable(
|
||||
|
||||
@@ -12,6 +12,7 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -50,7 +51,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -4,6 +4,9 @@ from esphome.components import i2c
|
||||
from esphome.components.audio_dac import AudioDac
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_MODE
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
@@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
SET_AUTO_MUTE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
||||
async def aic3204_set_volume_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble_tracker
|
||||
from esphome.components import ble_device_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
CODEOWNERS = ["@jeromelaban"]
|
||||
|
||||
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
|
||||
AirthingsListener = airthings_ble_ns.class_(
|
||||
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
|
||||
"AirthingsListener", ble_device_base.ESPBTDeviceListener
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AirthingsListener),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("airthings_ble"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AirthingsListener),
|
||||
}
|
||||
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await esp32_ble_tracker.register_ble_device(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
static const char *const TAG = "airthings_ble";
|
||||
|
||||
bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
for (auto &it : device.get_manufacturer_datas()) {
|
||||
if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.data.size() < 4)
|
||||
continue;
|
||||
|
||||
@@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
|
||||
}
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener {
|
||||
class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
@@ -20,6 +20,8 @@ from esphome.const import (
|
||||
UNIT_PERCENT,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@ncareau", "@jeromelaban"]
|
||||
|
||||
@@ -78,7 +80,7 @@ BASE_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def wave_base_to_code(var, config):
|
||||
async def wave_base_to_code(var: MockObj, config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import airthings_wave_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = airthings_wave_base.DEPENDENCIES
|
||||
|
||||
@@ -20,6 +21,6 @@ CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await airthings_wave_base.wave_base_to_code(var, config)
|
||||
|
||||
@@ -83,7 +83,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await airthings_wave_base.wave_base_to_code(var, config)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from esphome.const import (
|
||||
UNIT_VOLT,
|
||||
UNIT_WATT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
alpha3_ns = cg.esphome_ns.namespace("alpha3")
|
||||
Alpha3 = alpha3_ns.class_("Alpha3", ble_client.BLEClientNode, cg.PollingComponent)
|
||||
@@ -68,7 +69,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import ble_client, cover
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_PIN
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@buxtronix"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await cover.new_cover(config)
|
||||
cg.add(var.set_pin(config[CONF_PIN]))
|
||||
cg.add(var.set_invert_position(config[CONF_INVERT_POSITION]))
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["am43"]
|
||||
CODEOWNERS = ["@buxtronix"]
|
||||
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SENSOR_ID, CONF_THRESHOLD
|
||||
from esphome.types import ConfigType
|
||||
|
||||
analog_threshold_ns = cg.esphome_ns.namespace("analog_threshold")
|
||||
|
||||
@@ -32,7 +33,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from . import image as animation_image
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
# The deprecated top-level `animation:` shim gets the same batched
|
||||
# downloads as the `image:` platform form.
|
||||
PREFETCH_FILES = animation_image.PREFETCH_FILES
|
||||
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file import image as file_image
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
|
||||
# The animation platform shares the file platform's remote file handling,
|
||||
# including its batch-download hook.
|
||||
PREFETCH_FILES = file_image.PREFETCH_FILES
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
@@ -74,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema(
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
async def animation_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import ble_client, climate
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_UNIT_OF_MEASUREMENT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
UNITS = {
|
||||
"f": "f",
|
||||
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate.new_climate(config)
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Based on this datasheet:
|
||||
# https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf
|
||||
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
import esphome.config_validation as cv
|
||||
@@ -11,6 +13,8 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_LUX,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -55,7 +59,7 @@ AMBIENT_LIGHT_GAINS = {
|
||||
}
|
||||
|
||||
|
||||
def _validate_measurement_rate(value):
|
||||
def _validate_measurement_rate(value: Any) -> MockObj:
|
||||
value = cv.positive_time_period_milliseconds(value)
|
||||
return cv.enum(MEASUREMENT_RATES, int=True)(value.total_milliseconds)
|
||||
|
||||
@@ -85,7 +89,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
MULTI_CONF = True
|
||||
@@ -57,7 +58,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -2,6 +2,7 @@ import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import APDS9960, CONF_APDS9960_ID
|
||||
|
||||
@@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor")
|
||||
|
||||
@@ -7,6 +7,7 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import APDS9960, CONF_APDS9960_ID
|
||||
|
||||
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_APDS9960_ID])
|
||||
var = await sensor.new_sensor(config)
|
||||
func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor")
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import Condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.logger import request_log_listener
|
||||
from esphome.config_helpers import get_logger_level
|
||||
|
||||
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
|
||||
# components and downstream consumers that import them from api
|
||||
from esphome.components.noise import ( # noqa: F401
|
||||
ENCRYPTION_SCHEMA,
|
||||
decode_encryption_key,
|
||||
encryption_schema,
|
||||
validate_encryption_key,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTION,
|
||||
@@ -13,6 +23,7 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
@@ -36,6 +47,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigFragmentType, ConfigType
|
||||
|
||||
# Compat alias: downstream consumers (e.g. device-builder) referenced the
|
||||
# schema by its old private name before it moved to the noise component
|
||||
_encryption_schema = encryption_schema
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "api"
|
||||
@@ -44,9 +59,15 @@ CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||
"""Conditionally auto-load json only when capture_response is used."""
|
||||
"""Conditionally auto-load noise (encryption) and json (capture_response)."""
|
||||
base = ["socket"]
|
||||
|
||||
# A falsy config is a tooling probe for the maximal set (None from
|
||||
# dependency resolution, {} from the components-graph platform probe);
|
||||
# a validated config always carries defaults, never empty
|
||||
if not config or CONF_ENCRYPTION in config:
|
||||
base = base + ["noise"]
|
||||
|
||||
# Check if any homeassistant.action/homeassistant.service has capture_response: true
|
||||
# This flag is set during config validation in _validate_response_config
|
||||
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
|
||||
@@ -102,7 +123,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
|
||||
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
|
||||
},
|
||||
}
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_BATCH_DELAY = "batch_delay"
|
||||
CONF_CUSTOM_SERVICES = "custom_services"
|
||||
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
@@ -129,20 +149,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def validate_encryption_key(value):
|
||||
value = cv.string_strict(value)
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
except ValueError as err:
|
||||
raise cv.Invalid("Invalid key format, please check it's using base64") from err
|
||||
|
||||
if len(decoded) != 32:
|
||||
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
|
||||
|
||||
# Return original data for roundtrip conversion
|
||||
return value
|
||||
|
||||
|
||||
CONF_SUPPORTS_RESPONSE = "supports_response"
|
||||
|
||||
# Enum values in api::enums namespace
|
||||
@@ -217,7 +223,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_supports_response(value):
|
||||
def _validate_supports_response(value: Any) -> str:
|
||||
"""Validate supports_response after auto-detection has set the value."""
|
||||
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
|
||||
|
||||
@@ -249,18 +255,6 @@ ACTIONS_SCHEMA = automation.validate_automation(
|
||||
),
|
||||
)
|
||||
|
||||
ENCRYPTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _encryption_schema(config):
|
||||
if config is None:
|
||||
config = {}
|
||||
return ENCRYPTION_SCHEMA(config)
|
||||
|
||||
|
||||
def _consume_api_sockets(config: ConfigType) -> ConfigType:
|
||||
"""Register socket needs for API component."""
|
||||
@@ -296,7 +290,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
|
||||
): ACTIONS_SCHEMA,
|
||||
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
|
||||
cv.Optional(CONF_ENCRYPTION): _encryption_schema,
|
||||
cv.Optional(CONF_ENCRYPTION): encryption_schema,
|
||||
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
@@ -393,7 +387,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if actions := config.get(CONF_ACTIONS, []):
|
||||
# Collect all triggers first, then register all at once with initializer_list
|
||||
triggers: list[cg.Pvariable] = []
|
||||
triggers: list[cg.MockObj] = []
|
||||
for conf in actions:
|
||||
func_args: list[tuple[MockObj, str]] = []
|
||||
service_template_args: list[MockObj] = [] # User service argument types
|
||||
@@ -483,7 +477,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
|
||||
if key := encryption_config.get(CONF_KEY):
|
||||
decoded = base64.b64decode(key)
|
||||
decoded = decode_encryption_key(key)
|
||||
cg.add(var.set_noise_psk(list(decoded)))
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
@@ -497,10 +491,6 @@ 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.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")
|
||||
|
||||
@@ -510,6 +500,40 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
|
||||
|
||||
_ID_CALL_PROG = re.compile(r"\bid\s*\(")
|
||||
|
||||
|
||||
# Remove before 2027.3.0: untagged strings that look like lambda source keep
|
||||
# being compiled as lambdas during the deprecation window
|
||||
def _coerce_implicit_lambda(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if cv.looks_like_returning_lambda(value):
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' looks like a lambda but is "
|
||||
"missing the !lambda tag. It is compiled as a lambda for now but "
|
||||
"will be sent as literal text from 2027.3.0. Add !lambda to keep "
|
||||
"it evaluated; literal text belongs under 'data:'.",
|
||||
value,
|
||||
)
|
||||
# cv.templatable runs returning_lambda on the coerced Lambda
|
||||
return cv.lambda_(value)
|
||||
if _ID_CALL_PROG.search(value):
|
||||
# lambda source without a return: issue 5394's mistake class
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' is sent as literal text; wrap "
|
||||
"it in !lambda 'return ...;' to evaluate it instead.",
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Static strings or !lambda values. cv.templatable stays introspectable for
|
||||
# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
|
||||
VARIABLES_SCHEMA = cv.Schema(
|
||||
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||
)
|
||||
|
||||
|
||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||
# Validate dependencies:
|
||||
@@ -546,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
||||
{cv.string: cv.returning_lambda}
|
||||
),
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||
@@ -581,7 +603,7 @@ async def homeassistant_service_to_code(
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
):
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
||||
@@ -609,6 +631,8 @@ async def homeassistant_service_to_code(
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
@@ -647,7 +671,7 @@ async def homeassistant_service_to_code(
|
||||
return var
|
||||
|
||||
|
||||
def validate_homeassistant_event(value):
|
||||
def validate_homeassistant_event(value: Any) -> str:
|
||||
value = cv.string(value)
|
||||
if not value.startswith("esphome."):
|
||||
raise cv.Invalid(
|
||||
@@ -663,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -676,7 +700,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
||||
async def homeassistant_event_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
@@ -704,6 +733,8 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
@@ -724,7 +755,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
|
||||
async def homeassistant_tag_scanned_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
@@ -740,7 +776,7 @@ CONF_SUCCESS = "success"
|
||||
CONF_ERROR_MESSAGE = "error_message"
|
||||
|
||||
|
||||
def _validate_api_respond_data(config):
|
||||
def _validate_api_respond_data(config: ConfigType) -> ConfigType:
|
||||
"""Set flag during validation so AUTO_LOAD can include json component."""
|
||||
if CONF_DATA in config:
|
||||
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
|
||||
@@ -824,18 +860,32 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema(
|
||||
@automation.register_condition(
|
||||
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
|
||||
)
|
||||
async def api_connected_to_code(config, condition_id, template_arg, args):
|
||||
async def api_connected_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(condition_id, template_arg)
|
||||
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
|
||||
cg.add(var.set_state_subscription_only(templ))
|
||||
return var
|
||||
|
||||
|
||||
# user_services.cpp is only needed when user defined actions exist; the
|
||||
# frame helpers are fully #ifdef'd on the protocol defines set in to_code
|
||||
# (both are set when encryption is configured without a key).
|
||||
_define_filter = filter_source_files_from_defines(
|
||||
{
|
||||
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
|
||||
"api_frame_helper_noise.cpp": "USE_API_NOISE",
|
||||
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
"""Filter out api_pb2_dump.cpp when proto message dumping is not enabled,
|
||||
user_services.cpp when no services are defined, and protocol-specific
|
||||
implementations based on encryption configuration."""
|
||||
files_to_filter: list[str] = []
|
||||
files_to_filter = _define_filter()
|
||||
|
||||
# api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined
|
||||
# This is a particularly large file that still needs to be opened and read
|
||||
@@ -846,21 +896,4 @@ def FILTER_SOURCE_FILES() -> list[str]:
|
||||
if get_logger_level() != "VERY_VERBOSE":
|
||||
files_to_filter.append("api_pb2_dump.cpp")
|
||||
|
||||
# user_services.cpp is only needed when services are defined
|
||||
config = CORE.config.get(DOMAIN, {})
|
||||
if config and not config.get(CONF_ACTIONS) and not config[CONF_CUSTOM_SERVICES]:
|
||||
files_to_filter.append("user_services.cpp")
|
||||
|
||||
# Filter protocol-specific implementations based on encryption configuration
|
||||
encryption_config = config.get(CONF_ENCRYPTION) if config else None
|
||||
|
||||
# If encryption is not configured at all, we only need plaintext
|
||||
if encryption_config is None:
|
||||
files_to_filter.append("api_frame_helper_noise.cpp")
|
||||
# If encryption is configured with a key, we only need noise
|
||||
elif encryption_config.get(CONF_KEY):
|
||||
files_to_filter.append("api_frame_helper_plaintext.cpp")
|
||||
# If encryption is configured but no key is provided, we need both
|
||||
# (this allows a plaintext client to provide a noise key)
|
||||
|
||||
return files_to_filter
|
||||
|
||||
@@ -19,6 +19,7 @@ service APIConnection {
|
||||
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -231,6 +232,7 @@ enum SerialProxyPortType {
|
||||
message SerialProxyInfo {
|
||||
string name = 1; // Human-readable port name
|
||||
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
|
||||
uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
|
||||
}
|
||||
|
||||
// DeviceInfoResponse max_data_length values:
|
||||
@@ -243,6 +245,12 @@ message SerialProxyInfo {
|
||||
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
|
||||
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
|
||||
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
|
||||
//
|
||||
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
|
||||
// have moved to that message as of API 1.15, but are still sent here so that
|
||||
// older clients keep working. Do NOT mark them (deprecated) until the removal
|
||||
// release: in this repo (deprecated) makes the generator drop the field
|
||||
// entirely, so the device would stop sending it.
|
||||
message DeviceInfoResponse {
|
||||
option (id) = 10;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -280,6 +288,8 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.9
|
||||
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
|
||||
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
|
||||
@@ -288,11 +298,14 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.10
|
||||
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
|
||||
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
|
||||
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Supports receiving and saving api encryption key
|
||||
@@ -305,10 +318,13 @@ message DeviceInfoResponse {
|
||||
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// Indicates if Z-Wave proxy support is available and features supported
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
|
||||
// Serial proxy instance metadata
|
||||
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
|
||||
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
|
||||
// Device is unprovisioned and accepts Noise handshakes with the well-known
|
||||
@@ -317,6 +333,63 @@ message DeviceInfoResponse {
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
|
||||
// Asks the device which optional features it supports.
|
||||
//
|
||||
// This message exists so that DeviceInfoResponse does not have to keep growing
|
||||
// a flat list of feature flags. DeviceInfoResponse is served before
|
||||
// authentication, so it is limited to identity information. Capabilities are
|
||||
// only served on an authenticated connection (encrypted as well, when
|
||||
// encryption is configured).
|
||||
//
|
||||
// Clients that see api_version >= 1.15 should read these values from
|
||||
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
|
||||
// Older clients keep reading DeviceInfoResponse, which still carries the same
|
||||
// values, so this is not a breaking change.
|
||||
message DeviceCapabilitiesRequest {
|
||||
option (id) = 149;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
// Empty
|
||||
}
|
||||
|
||||
// Each feature gets its own sub-message so that it can gain fields over time
|
||||
// without crowding the top-level field numbering.
|
||||
//
|
||||
// Note: a sub-message whose fields are all at their default value is not sent
|
||||
// at all, so the presence of a sub-message is not a reliable test for "this
|
||||
// feature is compiled in". Clients should test a value inside it, for example
|
||||
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
|
||||
|
||||
message BluetoothProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
string mac_address = 2 [(max_data_length) = 17, (force) = true];
|
||||
}
|
||||
|
||||
message VoiceAssistantCapabilities {
|
||||
// Bitmask of the features this voice assistant supports
|
||||
uint32 feature_flags = 1;
|
||||
}
|
||||
|
||||
message ZWaveProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
uint32 home_id = 2;
|
||||
}
|
||||
|
||||
message DeviceCapabilitiesResponse {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_SERVER;
|
||||
|
||||
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
repeated SerialProxyInfo serial_proxies = 4
|
||||
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
option (id) = 11;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -930,8 +1003,12 @@ message GetTimeResponse {
|
||||
option (no_delay) = true;
|
||||
|
||||
fixed32 epoch_seconds = 1;
|
||||
string timezone = 2;
|
||||
ParsedTimezone parsed_timezone = 3;
|
||||
// Deprecated in 2026.9.0: clients still send this string for older firmware,
|
||||
// but new firmware only reads parsed_timezone. Clients older than Home
|
||||
// Assistant 2026.3.0 that send only the string leave the device on its
|
||||
// codegen-configured timezone (or UTC).
|
||||
string timezone = 2 [deprecated = true];
|
||||
ParsedTimezone parsed_timezone = 3 [(track_presence) = true];
|
||||
}
|
||||
|
||||
// ==================== USER-DEFINES SERVICES ====================
|
||||
@@ -1577,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse {
|
||||
bool disabled_by_default = 6;
|
||||
EntityCategory entity_category = 7;
|
||||
|
||||
bool supports_pause = 8;
|
||||
// Deprecated in ESPHome 2026.9.0; use feature_flags instead.
|
||||
bool supports_pause = 8 [deprecated = true];
|
||||
|
||||
repeated MediaPlayerSupportedFormat supported_formats = 9;
|
||||
|
||||
@@ -1688,7 +1766,7 @@ enum BluetoothDeviceRequestType {
|
||||
message BluetoothDeviceRequest {
|
||||
option (id) = 68;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
BluetoothDeviceRequestType request_type = 2;
|
||||
@@ -1699,7 +1777,7 @@ message BluetoothDeviceRequest {
|
||||
message BluetoothDeviceConnectionResponse {
|
||||
option (id) = 69;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool connected = 2;
|
||||
@@ -1710,7 +1788,7 @@ message BluetoothDeviceConnectionResponse {
|
||||
message BluetoothGATTGetServicesRequest {
|
||||
option (id) = 70;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1754,7 +1832,7 @@ message BluetoothGATTService {
|
||||
message BluetoothGATTGetServicesResponse {
|
||||
option (id) = 71;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
repeated BluetoothGATTService services = 2;
|
||||
@@ -1763,7 +1841,7 @@ message BluetoothGATTGetServicesResponse {
|
||||
message BluetoothGATTGetServicesDoneResponse {
|
||||
option (id) = 72;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1771,7 +1849,7 @@ message BluetoothGATTGetServicesDoneResponse {
|
||||
message BluetoothGATTReadRequest {
|
||||
option (id) = 73;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1780,7 +1858,7 @@ message BluetoothGATTReadRequest {
|
||||
message BluetoothGATTReadResponse {
|
||||
option (id) = 74;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1792,7 +1870,7 @@ message BluetoothGATTReadResponse {
|
||||
message BluetoothGATTWriteRequest {
|
||||
option (id) = 75;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1804,7 +1882,7 @@ message BluetoothGATTWriteRequest {
|
||||
message BluetoothGATTReadDescriptorRequest {
|
||||
option (id) = 76;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1813,7 +1891,7 @@ message BluetoothGATTReadDescriptorRequest {
|
||||
message BluetoothGATTWriteDescriptorRequest {
|
||||
option (id) = 77;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1824,7 +1902,7 @@ message BluetoothGATTWriteDescriptorRequest {
|
||||
message BluetoothGATTNotifyRequest {
|
||||
option (id) = 78;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1834,7 +1912,7 @@ message BluetoothGATTNotifyRequest {
|
||||
message BluetoothGATTNotifyDataResponse {
|
||||
option (id) = 79;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1845,13 +1923,13 @@ message BluetoothGATTNotifyDataResponse {
|
||||
message SubscribeBluetoothConnectionsFreeRequest {
|
||||
option (id) = 80;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
}
|
||||
|
||||
message BluetoothConnectionsFreeResponse {
|
||||
option (id) = 81;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint32 free = 1;
|
||||
uint32 limit = 2;
|
||||
@@ -1864,7 +1942,7 @@ message BluetoothConnectionsFreeResponse {
|
||||
message BluetoothGATTErrorResponse {
|
||||
option (id) = 82;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1874,7 +1952,7 @@ message BluetoothGATTErrorResponse {
|
||||
message BluetoothGATTWriteResponse {
|
||||
option (id) = 83;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1883,7 +1961,7 @@ message BluetoothGATTWriteResponse {
|
||||
message BluetoothGATTNotifyResponse {
|
||||
option (id) = 84;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1892,7 +1970,7 @@ message BluetoothGATTNotifyResponse {
|
||||
message BluetoothDevicePairingResponse {
|
||||
option (id) = 85;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool paired = 2;
|
||||
@@ -1902,7 +1980,7 @@ message BluetoothDevicePairingResponse {
|
||||
message BluetoothDeviceUnpairingResponse {
|
||||
option (id) = 86;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -1918,7 +1996,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
|
||||
message BluetoothDeviceClearCacheResponse {
|
||||
option (id) = 88;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -2550,6 +2628,22 @@ message ZWaveProxyRequest {
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
enum ZWaveProxyStatus {
|
||||
ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
|
||||
ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
|
||||
ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
|
||||
}
|
||||
|
||||
// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
|
||||
message ZWaveProxyRequestResponse {
|
||||
option (id) = 151;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_ZWAVE_PROXY";
|
||||
|
||||
ZWaveProxyRequestType type = 1; // Which request type this responds to
|
||||
ZWaveProxyStatus status = 2; // Result status
|
||||
}
|
||||
|
||||
// ==================== INFRARED ====================
|
||||
// Note: Feature and capability flag enums are defined in
|
||||
// esphome/components/infrared/infrared.h
|
||||
@@ -2693,12 +2787,18 @@ message SerialProxyGetModemPinsResponse {
|
||||
|
||||
uint32 instance = 1; // Instance index (0-based)
|
||||
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
|
||||
SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
|
||||
}
|
||||
|
||||
enum SerialProxyRequestType {
|
||||
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
|
||||
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
|
||||
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
|
||||
// Values below are only valid in SerialProxyRequestResponse.type, identifying which
|
||||
// operation is being acknowledged. Sending them in SerialProxyRequest.type is an
|
||||
// error the device answers with INVALID_ARGUMENT.
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
|
||||
}
|
||||
|
||||
enum SerialProxyStatus {
|
||||
@@ -2707,6 +2807,8 @@ enum SerialProxyStatus {
|
||||
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
|
||||
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
|
||||
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
|
||||
SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
|
||||
}
|
||||
|
||||
// Generic request message for simple serial proxy operations
|
||||
@@ -2719,7 +2821,9 @@ message SerialProxyRequest {
|
||||
SerialProxyRequestType type = 2; // Request type
|
||||
}
|
||||
|
||||
// Response to a SerialProxyRequest (e.g. flush completion or failure)
|
||||
// Acknowledges a serial proxy operation; the type field identifies which
|
||||
// operation is being acknowledged. Flush has been acknowledged since the
|
||||
// message was introduced; all other acknowledgements are sent since API 1.16.
|
||||
message SerialProxyRequestResponse {
|
||||
option (id) = 147;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -2735,7 +2839,7 @@ message SerialProxyRequestResponse {
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 min_interval = 2; // units of 1.25ms
|
||||
@@ -2747,7 +2851,7 @@ message BluetoothSetConnectionParamsRequest {
|
||||
message BluetoothSetConnectionParamsResponse {
|
||||
option (id) = 146;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
#include "api_buffer.h"
|
||||
#include <new>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
void APIBuffer::grow_(size_t n) {
|
||||
auto new_data = make_buffer(n);
|
||||
bool APIBuffer::grow_(size_t n) {
|
||||
// nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
|
||||
// (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
|
||||
// RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
|
||||
std::unique_ptr<uint8_t[]> new_data(new (std::nothrow) uint8_t[n]);
|
||||
if (new_data == nullptr)
|
||||
return false;
|
||||
if (this->size_)
|
||||
std::memcpy(new_data.get(), this->data_.get(), this->size_);
|
||||
this->data_ = std::move(new_data);
|
||||
this->capacity_ = n;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -9,16 +9,6 @@
|
||||
|
||||
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
|
||||
@@ -36,23 +26,23 @@ inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
|
||||
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
|
||||
}
|
||||
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||
[[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
|
||||
/// Returns false if allocation fails; the buffer is left unchanged. No zero-fill.
|
||||
[[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); }
|
||||
/// 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));
|
||||
/// Returns false if allocation fails; the buffer is left unchanged.
|
||||
[[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
|
||||
if (!this->reserve(std::max(reserve_size, new_size)))
|
||||
return false;
|
||||
this->size_ = new_size;
|
||||
return true;
|
||||
}
|
||||
uint8_t *data() { return this->data_.get(); }
|
||||
const uint8_t *data() const { return this->data_.get(); }
|
||||
size_t size() const { return this->size_; }
|
||||
size_t capacity() const { return this->capacity_; }
|
||||
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]; }
|
||||
@@ -64,7 +54,7 @@ class APIBuffer {
|
||||
}
|
||||
|
||||
protected:
|
||||
void grow_(size_t n);
|
||||
bool grow_(size_t n);
|
||||
std::unique_ptr<uint8_t[]> data_;
|
||||
size_t size_{0};
|
||||
size_t capacity_{0};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API
|
||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
||||
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
@@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
|
||||
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
|
||||
|
||||
static const char *const TAG = "api.connection";
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what) {
|
||||
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
|
||||
LOG_STR_ARG(what));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
static const int CAMERA_STOP_STREAM = 5000;
|
||||
#endif
|
||||
@@ -152,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
|
||||
#else
|
||||
#error "No frame helper defined"
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
if (camera::Camera::instance() != nullptr) {
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void APIConnection::start() {
|
||||
@@ -409,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
|
||||
}
|
||||
|
||||
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
|
||||
size_t initial_size = this->deferred_batch_.size();
|
||||
size_t max_batch = MAX_INITIAL_PER_BATCH;
|
||||
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
|
||||
iterator.advance();
|
||||
}
|
||||
// Budget by remaining batch capacity so a pass cannot overfill the batch;
|
||||
// stops early on a refused send and resumes next loop pass
|
||||
size_t batch_size = this->deferred_batch_.size();
|
||||
if (batch_size < MAX_INITIAL_BATCH_SIZE)
|
||||
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
|
||||
|
||||
// If the batch is full, process it immediately
|
||||
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
|
||||
if (this->deferred_batch_.size() >= max_batch) {
|
||||
// Flush immediately once enough is queued (not guaranteed every pass);
|
||||
// partial batches go out via the batch timer or finalize_iterator_sync_()
|
||||
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
|
||||
this->process_batch_();
|
||||
}
|
||||
}
|
||||
@@ -794,6 +797,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
|
||||
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
|
||||
// Current feature flags and other supported parameters
|
||||
msg.feature_flags = traits.get_feature_flags();
|
||||
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
|
||||
msg.supported_modes = &traits.get_supported_modes();
|
||||
msg.visual_min_temperature = traits.get_visual_min_temperature();
|
||||
msg.visual_max_temperature = traits.get_visual_max_temperature();
|
||||
@@ -1095,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
|
||||
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
|
||||
ListEntitiesMediaPlayerResponse msg;
|
||||
auto traits = media_player->get_traits();
|
||||
msg.supports_pause = traits.get_supports_pause();
|
||||
msg.feature_flags = traits.get_feature_flags();
|
||||
for (auto &supported_format : traits.get_supported_formats()) {
|
||||
msg.supported_formats.emplace_back();
|
||||
@@ -1131,6 +1134,7 @@ void APIConnection::try_send_camera_image_() {
|
||||
if (!this->image_reader_)
|
||||
return;
|
||||
|
||||
const auto *cam = camera::Camera::instance();
|
||||
// Send as many chunks as possible without blocking
|
||||
while (this->image_reader_->available()) {
|
||||
if (!this->helper_->can_write_without_blocking())
|
||||
@@ -1140,11 +1144,11 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = camera::Camera::instance()->get_object_id_hash();
|
||||
msg.key = cam->get_object_id_hash();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = camera::Camera::instance()->get_device_id();
|
||||
msg.device_id = cam->get_device_id();
|
||||
#endif
|
||||
|
||||
if (!this->send_message(msg)) {
|
||||
@@ -1160,15 +1164,19 @@ void APIConnection::try_send_camera_image_() {
|
||||
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
|
||||
if (!this->flags_.state_subscription)
|
||||
return;
|
||||
if (!this->image_reader_)
|
||||
if (this->image_reader_ && this->image_reader_->available())
|
||||
return;
|
||||
if (this->image_reader_->available())
|
||||
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
|
||||
return;
|
||||
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
if (!this->image_reader_) {
|
||||
// Created on the first image this connection will send, so connections
|
||||
// that never receive one never pay for a reader. Only a registered
|
||||
// camera's listener can reach this, so instance() is non-null here.
|
||||
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
|
||||
}
|
||||
this->image_reader_->set_image(std::move(image));
|
||||
// Try to send immediately to reduce latency
|
||||
this->try_send_camera_image_();
|
||||
}
|
||||
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
|
||||
auto *camera = static_cast<camera::Camera *>(entity);
|
||||
@@ -1195,31 +1203,28 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
|
||||
if (homeassistant::global_homeassistant_time != nullptr) {
|
||||
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
||||
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
|
||||
if (!value.timezone.empty()) {
|
||||
// Check if the sender provided pre-parsed timezone data.
|
||||
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
|
||||
// For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
|
||||
// Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0
|
||||
// and newer); field presence distinguishes a genuine all-zero UTC timezone from an
|
||||
// absent field. Older clients send only the deprecated timezone string, which is no
|
||||
// longer decoded; for them the device keeps its codegen-configured timezone.
|
||||
if (value.has_parsed_timezone) {
|
||||
const auto &pt = value.parsed_timezone;
|
||||
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
|
||||
time::ParsedTimezone tz{};
|
||||
tz.std_offset_seconds = pt.std_offset_seconds;
|
||||
tz.dst_offset_seconds = pt.dst_offset_seconds;
|
||||
tz.dst_start.time_seconds = pt.dst_start.time_seconds;
|
||||
tz.dst_start.day = static_cast<uint16_t>(pt.dst_start.day);
|
||||
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
|
||||
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
|
||||
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
|
||||
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
|
||||
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
|
||||
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
|
||||
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
|
||||
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
|
||||
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
|
||||
tz.dst_end.day_of_week = static_cast<uint8_t>(pt.dst_end.day_of_week);
|
||||
time::set_global_tz(tz);
|
||||
} else {
|
||||
homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
|
||||
}
|
||||
time::ParsedTimezone tz{};
|
||||
tz.std_offset_seconds = pt.std_offset_seconds;
|
||||
tz.dst_offset_seconds = pt.dst_offset_seconds;
|
||||
tz.dst_start.time_seconds = pt.dst_start.time_seconds;
|
||||
tz.dst_start.day = static_cast<uint16_t>(pt.dst_start.day);
|
||||
tz.dst_start.type = static_cast<time::DSTRuleType>(pt.dst_start.type);
|
||||
tz.dst_start.month = static_cast<uint8_t>(pt.dst_start.month);
|
||||
tz.dst_start.week = static_cast<uint8_t>(pt.dst_start.week);
|
||||
tz.dst_start.day_of_week = static_cast<uint8_t>(pt.dst_start.day_of_week);
|
||||
tz.dst_end.time_seconds = pt.dst_end.time_seconds;
|
||||
tz.dst_end.day = static_cast<uint16_t>(pt.dst_end.day);
|
||||
tz.dst_end.type = static_cast<time::DSTRuleType>(pt.dst_end.type);
|
||||
tz.dst_end.month = static_cast<uint8_t>(pt.dst_end.month);
|
||||
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
|
||||
tz.dst_end.day_of_week = static_cast<uint8_t>(pt.dst_end.day_of_week);
|
||||
time::set_global_tz(tz);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1234,6 +1239,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
|
||||
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
|
||||
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
|
||||
}
|
||||
@@ -1267,13 +1273,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
|
||||
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
|
||||
}
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -1328,7 +1336,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
|
||||
}
|
||||
}
|
||||
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(
|
||||
const VoiceAssistantConfigurationRequest & /*msg*/) {
|
||||
VoiceAssistantConfigurationResponse resp;
|
||||
if (!this->check_voice_assistant_api_connection_()) {
|
||||
// send_message encodes synchronously, so this stack local outlives the encode
|
||||
@@ -1348,22 +1357,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
|
||||
}
|
||||
}
|
||||
|
||||
// Filter external wake words
|
||||
for (auto &wake_word : msg.external_wake_words) {
|
||||
if (wake_word.model_type != "micro") {
|
||||
// microWakeWord only
|
||||
continue;
|
||||
}
|
||||
|
||||
resp.available_wake_words.emplace_back();
|
||||
auto &resp_wake_word = resp.available_wake_words.back();
|
||||
resp_wake_word.id = StringRef(wake_word.id);
|
||||
resp_wake_word.wake_word = StringRef(wake_word.wake_word);
|
||||
for (const auto &lang : wake_word.trained_languages) {
|
||||
resp_wake_word.trained_languages.push_back(lang);
|
||||
}
|
||||
}
|
||||
|
||||
resp.active_wake_words = &config.active_wake_words;
|
||||
resp.max_active_wake_words = config.max_active_wake_words;
|
||||
return this->send_message(resp);
|
||||
@@ -1387,7 +1380,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
|
||||
}
|
||||
|
||||
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
|
||||
ZWaveProxyRequestResponse resp{};
|
||||
resp.type = msg.type;
|
||||
resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1468,6 +1466,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
|
||||
msg.target_temperature_step = traits.get_target_temperature_step();
|
||||
msg.supported_modes = &traits.get_supported_modes();
|
||||
msg.supported_features = traits.get_feature_flags();
|
||||
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
|
||||
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
@@ -1545,19 +1544,60 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
// would flood the congested link it reports on.
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
|
||||
switch (result) {
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
|
||||
return enums::SERIAL_PROXY_STATUS_OK;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
|
||||
return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
|
||||
return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
|
||||
return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
|
||||
return enums::SERIAL_PROXY_STATUS_TIMEOUT;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
|
||||
return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||
case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
|
||||
return enums::SERIAL_PROXY_STATUS_ERROR;
|
||||
}
|
||||
return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
|
||||
}
|
||||
|
||||
static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
|
||||
enums::SerialProxyStatus status) {
|
||||
SerialProxyRequestResponse resp{};
|
||||
resp.instance = instance;
|
||||
resp.type = type;
|
||||
resp.status = status;
|
||||
if (!conn->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
|
||||
static_cast<uint32_t>(proxies.size()));
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
|
||||
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits,
|
||||
msg.data_size);
|
||||
serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
|
||||
this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, msg.data_size);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
|
||||
serial_proxy_result_to_status(result));
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
|
||||
@@ -1566,69 +1606,78 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
|
||||
proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
|
||||
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->set_modem_pins(msg.line_states);
|
||||
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
|
||||
serial_proxy_result_to_status(result));
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
return;
|
||||
}
|
||||
SerialProxyGetModemPinsResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||
this->send_message(resp);
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
// Pre-1.16 clients do not read the status field and would take this error
|
||||
// for a successful "both pins deasserted" answer; let them time out as before
|
||||
if (!this->client_supports_api_version(1, 16)) {
|
||||
return;
|
||||
}
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
} else {
|
||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||
}
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
auto *proxy = proxies[msg.instance];
|
||||
enums::SerialProxyStatus status;
|
||||
switch (msg.type) {
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
|
||||
proxies[msg.instance]->serial_proxy_request(this, msg.type);
|
||||
status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
|
||||
break;
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
|
||||
SerialProxyRequestResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
|
||||
switch (proxies[msg.instance]->flush_port()) {
|
||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_OK;
|
||||
break;
|
||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
|
||||
break;
|
||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
|
||||
break;
|
||||
case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
this->send_message(resp);
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
|
||||
status = serial_proxy_result_to_status(proxy->flush_port(this));
|
||||
break;
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
// Response-only discriminators; never valid in a request
|
||||
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||
status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||
break;
|
||||
}
|
||||
send_serial_proxy_ack(this, msg.instance, msg.type, status);
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1740,15 +1789,17 @@ void APIConnection::complete_authentication_() {
|
||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Copy client name with truncation if needed (set_client_name handles truncation)
|
||||
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
||||
this->client_api_version_major_ = msg.api_version_major;
|
||||
this->client_api_version_minor_ = msg.api_version_minor;
|
||||
this->client_api_version_major_ =
|
||||
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_major, std::numeric_limits<uint8_t>::max()));
|
||||
this->client_api_version_minor_ =
|
||||
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_minor, std::numeric_limits<uint8_t>::max()));
|
||||
char peername[socket::SOCKADDR_STR_LEN];
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(),
|
||||
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 14;
|
||||
resp.api_version_minor = 16;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -1759,7 +1810,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Hello response");
|
||||
}
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
@@ -1784,9 +1837,8 @@ bool APIConnection::send_device_info_response_() {
|
||||
#ifdef USE_AREAS
|
||||
resp.suggested_area = StringRef(App.get_area());
|
||||
#endif
|
||||
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char mac_address[18];
|
||||
uint8_t mac[6];
|
||||
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, mac_address);
|
||||
resp.mac_address = StringRef(mac_address);
|
||||
@@ -1862,8 +1914,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char bluetooth_mac[18];
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
@@ -1882,6 +1933,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||
info.name = StringRef(proxy->get_name());
|
||||
info.port_type = proxy->get_port_type();
|
||||
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
@@ -1917,6 +1969,36 @@ bool APIConnection::send_device_info_response_() {
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
bool APIConnection::send_device_capabilities_response_() {
|
||||
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
|
||||
// below in sync with send_device_info_response_() until those copies are removed.
|
||||
DeviceCapabilitiesResponse resp;
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
|
||||
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
size_t serial_proxy_index = 0;
|
||||
for (auto const &proxy : App.get_serial_proxies()) {
|
||||
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
|
||||
break;
|
||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||
info.name = StringRef(proxy->get_name());
|
||||
info.port_type = proxy->get_port_type();
|
||||
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||
}
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
void APIConnection::on_hello_request(const HelloRequest &msg) {
|
||||
if (!this->send_hello_response_(msg)) {
|
||||
this->on_fatal_error();
|
||||
@@ -1938,6 +2020,11 @@ void APIConnection::on_device_info_request() {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
void APIConnection::on_device_capabilities_request() {
|
||||
if (!this->send_device_capabilities_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
|
||||
@@ -2016,7 +2103,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.call_id = call_id;
|
||||
resp.success = success;
|
||||
resp.error_message = error_message;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
|
||||
@@ -2027,12 +2116,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.error_message = error_message;
|
||||
resp.response_data = response_data;
|
||||
resp.response_data_len = response_data_len;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
if (!this->send_message(call)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action request");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif // USE_API_HOMEASSISTANT_SERVICES
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void APIConnection::send_time_request() {
|
||||
GetTimeRequest req;
|
||||
if (!this->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Time request");
|
||||
}
|
||||
}
|
||||
#endif // USE_HOMEASSISTANT_TIME
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -2060,7 +2171,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
#endif
|
||||
|
||||
psk_t psk{};
|
||||
noise::psk_t psk{};
|
||||
if (msg.key_len == 0) {
|
||||
if (this->parent_->clear_noise_psk(true)) {
|
||||
resp.success = true;
|
||||
@@ -2069,7 +2180,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
|
||||
ESP_LOGW(TAG, "Invalid encryption key length");
|
||||
} else if (APINoiseContext::is_all_zeros(psk)) {
|
||||
} else if (noise::NoiseContext::is_all_zeros(psk)) {
|
||||
// Accepting the reserved provisioning PSK would report success without
|
||||
// enabling encryption (or silently clear an existing key)
|
||||
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
|
||||
@@ -2107,11 +2218,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
if (this->helper_->can_write_without_blocking())
|
||||
return true;
|
||||
if (log_out_of_space) {
|
||||
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
|
||||
// VV: refusals are either reported by the sending call site (naming what
|
||||
// was lost) or retried without loss (the deferred batch), so this generic
|
||||
// line only duplicates them.
|
||||
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn,
|
||||
bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
|
||||
const void *msg) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
|
||||
@@ -2125,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M
|
||||
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
}
|
||||
#endif
|
||||
if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
return false;
|
||||
}
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
this->prepare_first_message_buffer(shared_buf, payload_size);
|
||||
size_t write_start = shared_buf.size();
|
||||
shared_buf.resize(write_start + payload_size);
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(shared_buf.capacity() >= write_start + payload_size);
|
||||
#endif
|
||||
// Capacity reserved above, cannot fail
|
||||
(void) shared_buf.resize(write_start + payload_size);
|
||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
|
||||
@@ -2140,7 +2261,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
|
||||
}
|
||||
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) {
|
||||
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) {
|
||||
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
|
||||
|
||||
if (!this->try_to_clear_buffer(!is_log_message)) {
|
||||
@@ -2164,30 +2285,42 @@ void APIConnection::on_no_setup_connection() {
|
||||
this->on_fatal_error();
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
|
||||
}
|
||||
void APIConnection::fatal_out_of_memory_() {
|
||||
this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY);
|
||||
}
|
||||
void APIConnection::on_fatal_error() {
|
||||
// Don't close socket here - keep it open so getpeername() works for logging
|
||||
// Socket will be closed when client is removed from the list in APIServer::loop()
|
||||
this->flags_.remove = true;
|
||||
}
|
||||
|
||||
bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
||||
bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
|
||||
return this->schedule_batch_();
|
||||
}
|
||||
|
||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index) {
|
||||
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
this->prepare_first_message_buffer(shared_buf, estimated_size);
|
||||
// No local for the shared buffer here: keeping it live across
|
||||
// dispatch_message_ costs a register and spills message_type into the
|
||||
// batching path's dedup loop (measured on x86 GCC -Os)
|
||||
if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
return false;
|
||||
}
|
||||
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
|
||||
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
|
||||
this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) {
|
||||
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_batch_item_(item);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
// An OOM during the immediate attempt marks the connection for removal;
|
||||
// don't queue more work (schedule_message_'s push_back may allocate again)
|
||||
if (this->flags_.remove) [[unlikely]]
|
||||
return false;
|
||||
}
|
||||
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
|
||||
}
|
||||
@@ -2237,7 +2370,11 @@ void APIConnection::process_batch_() {
|
||||
total_estimated_size = MAX_BATCH_PACKET_SIZE;
|
||||
}
|
||||
|
||||
this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size);
|
||||
if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
this->clear_batch_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path for single message - buffer already allocated above
|
||||
if (num_items == 1) {
|
||||
@@ -2252,8 +2389,10 @@ void APIConnection::process_batch_() {
|
||||
#endif
|
||||
this->clear_batch_();
|
||||
} else if (payload_size == 0) {
|
||||
// Message too large to fit in available space
|
||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||
// payload_size == 0 with remove set means encoding hit OOM and the
|
||||
// connection is being dropped; warn only for a genuinely oversized message
|
||||
if (!this->flags_.remove)
|
||||
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
|
||||
this->clear_batch_();
|
||||
}
|
||||
return;
|
||||
@@ -2316,8 +2455,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
|
||||
|
||||
if (items_processed > 0) {
|
||||
// Add footer space for the last message (for Noise protocol MAC)
|
||||
if (footer_size > 0) {
|
||||
shared_buf.resize(shared_buf.size() + footer_size);
|
||||
if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] {
|
||||
this->fatal_out_of_memory_();
|
||||
this->clear_batch_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send all collected messages
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user