mirror of
https://github.com/esphome/esphome.git
synced 2026-10-17 19:52:50 +00:00
Compare commits
619
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c8d04b0d | ||
|
|
6dbd15b903 | ||
|
|
0399379c0d | ||
|
|
6817960365 | ||
|
|
cfdaf958e9 | ||
|
|
1e89abdb13 | ||
|
|
c838d4826e | ||
|
|
69215deb58 | ||
|
|
5b234bcc20 | ||
|
|
c101b3c101 | ||
|
|
c0d8a0dfe9 | ||
|
|
27c6b7caf5 | ||
|
|
699e3bd037 | ||
|
|
a58ce2d641 | ||
|
|
27d8d49b17 | ||
|
|
304627efd3 | ||
|
|
7ed06e0ada | ||
|
|
7e564c12dd | ||
|
|
6f6fdd0f8a | ||
|
|
45ea5f4220 | ||
|
|
108ed67d31 | ||
|
|
6356458081 | ||
|
|
bf67b49104 | ||
|
|
1947c3d355 | ||
|
|
cf4e69273c | ||
|
|
c433e96734 | ||
|
|
93dc80d895 | ||
|
|
7f342054fe | ||
|
|
d924d2daf7 | ||
|
|
49b12daa58 | ||
|
|
d3faafc578 | ||
|
|
7b08e738d3 | ||
|
|
62e3c25c83 | ||
|
|
6a50f5c166 | ||
|
|
bb0837d814 | ||
|
|
ecd5980c5d | ||
|
|
8d963bde5b | ||
|
|
ea6d5499b3 | ||
|
|
41e82b0295 | ||
|
|
39705a65e3 | ||
|
|
a522553e82 | ||
|
|
a07a1ab728 | ||
|
|
9a5d2d9753 | ||
|
|
3188e0d36f | ||
|
|
f3eaf698d6 | ||
|
|
f7057e1270 | ||
|
|
6edfbb594d | ||
|
|
22defcd704 | ||
|
|
0f408234d0 | ||
|
|
92e0e6db6b | ||
|
|
908c2f6778 | ||
|
|
af709ac9a5 | ||
|
|
fe57eef3f6 | ||
|
|
d0b9f195b0 | ||
|
|
eafeef7cb8 | ||
|
|
471774cdbf | ||
|
|
4df6291feb | ||
|
|
d8ca4a9460 | ||
|
|
48a0a41cce | ||
|
|
0e2c4cf5c2 | ||
|
|
ea76073d86 | ||
|
|
dcb943ded3 | ||
|
|
2c6f59738c | ||
|
|
04715fb32b | ||
|
|
3df9360876 | ||
|
|
61f3de0bc0 | ||
|
|
cd97c9a9cb | ||
|
|
d3668a7186 | ||
|
|
ac74796263 | ||
|
|
c56bb14037 | ||
|
|
a4278b8dec | ||
|
|
6d173c7371 | ||
|
|
1779b011c9 | ||
|
|
3c4cf50cb6 | ||
|
|
e869d1f7ab | ||
|
|
e1a996435d | ||
|
|
c5a624a1f2 | ||
|
|
15fb7f775e | ||
|
|
3fa72e61c6 | ||
|
|
163b09a473 | ||
|
|
7afc2768f8 | ||
|
|
56f1a2bf46 | ||
|
|
fee47fe2f1 | ||
|
|
b994e081c3 | ||
|
|
a1f740b71b | ||
|
|
7f144365ba | ||
|
|
928459d16f | ||
|
|
9163359445 | ||
|
|
67307f0eeb | ||
|
|
91e0edc11d | ||
|
|
9fe75e262f | ||
|
|
0132f722a3 | ||
|
|
7d7a78308d | ||
|
|
9c568525b0 | ||
|
|
10f42e3e7c | ||
|
|
25d4b88605 | ||
|
|
d6abf35138 | ||
|
|
66ea99ce65 | ||
|
|
d9298cb7b9 | ||
|
|
34e1cf96d5 | ||
|
|
56cc03c4d4 | ||
|
|
35b416c6c0 | ||
|
|
7f2c3ba3f5 | ||
|
|
a97604d574 | ||
|
|
1146b77836 | ||
|
|
1338902ac5 | ||
|
|
60a1d70805 | ||
|
|
887d4c3a83 | ||
|
|
c1fa73470e | ||
|
|
e41d16ba7e | ||
|
|
5171113c87 | ||
|
|
5718e4b779 | ||
|
|
40e12cd0d8 | ||
|
|
07029d0077 | ||
|
|
bb1119d83f | ||
|
|
7b591b26d6 | ||
|
|
f45e6f0bf4 | ||
|
|
4b8af08155 | ||
|
|
613973b432 | ||
|
|
9ee4314bc2 | ||
|
|
59f55dbe9e | ||
|
|
8fce95454a | ||
|
|
16134ee993 | ||
|
|
bc39d56bb4 | ||
|
|
c5522abc71 | ||
|
|
7cb5906d2a | ||
|
|
d452dbce30 | ||
|
|
81e5b50087 | ||
|
|
426a4a2dce | ||
|
|
c95c17c4e7 | ||
|
|
576d3325fe | ||
|
|
c9cf0ad92e | ||
|
|
f505212e64 | ||
|
|
52a5e6a007 | ||
|
|
3b261d2cf0 | ||
|
|
9238fcc727 | ||
|
|
96902cc122 | ||
|
|
95711d83d1 | ||
|
|
3cdcf0417a | ||
|
|
c6e90de262 | ||
|
|
92f6ab8367 | ||
|
|
f61955baa2 | ||
|
|
36e1d831d3 | ||
|
|
63616bf642 | ||
|
|
d275926f65 | ||
|
|
f265c46c21 | ||
|
|
7d735f0677 | ||
|
|
5c23a827b6 | ||
|
|
e265c0c0fc | ||
|
|
a0c7d754cb | ||
|
|
a6dbd72c53 | ||
|
|
9d530f5c56 | ||
|
|
6562c49686 | ||
|
|
a97ce3a201 | ||
|
|
5a78f636b9 | ||
|
|
53d504137b | ||
|
|
bb0546fc0b | ||
|
|
b43630cf8e | ||
|
|
c9664d4e42 | ||
|
|
39ea6a3220 | ||
|
|
f4a6d6f18b | ||
|
|
6c24d4aa43 | ||
|
|
e01b244c5f | ||
|
|
0dc1d3b5a1 | ||
|
|
017e379c87 | ||
|
|
42341d05f9 | ||
|
|
f941fee5ed | ||
|
|
8721976066 | ||
|
|
05bf7d3440 | ||
|
|
476b442cd0 | ||
|
|
49ab7d058a | ||
|
|
918b7d2553 | ||
|
|
417ab4d2e9 | ||
|
|
0821c84b7a | ||
|
|
a827058928 | ||
|
|
3063b76210 | ||
|
|
55261ae7e5 | ||
|
|
1d8423edd8 | ||
|
|
d27ec8ec49 | ||
|
|
5acbd44067 | ||
|
|
40271fb597 | ||
|
|
95766ed3ad | ||
|
|
4a80d785a8 | ||
|
|
c1c1d2b2ae | ||
|
|
29a668af04 | ||
|
|
fd541bca2c | ||
|
|
192ae31e45 | ||
|
|
77c9fa2fa4 | ||
|
|
25843c5242 | ||
|
|
7881dfc5d8 | ||
|
|
e289a94451 | ||
|
|
5017165800 | ||
|
|
8acf885241 | ||
|
|
f829447b2e | ||
|
|
74e96b7da6 | ||
|
|
753c10e5ea | ||
|
|
98d3d23ce1 | ||
|
|
d6e5b9b03b | ||
|
|
02287fdf7b | ||
|
|
2001f60954 | ||
|
|
5a39097b3a | ||
|
|
fb14db267c | ||
|
|
36900e36f9 | ||
|
|
a035c17e04 | ||
|
|
88fdf822cd | ||
|
|
e7a7aa150d | ||
|
|
54f228766c | ||
|
|
58188305c4 | ||
|
|
afedddafe2 | ||
|
|
c3c235dea7 | ||
|
|
39a7f4482a | ||
|
|
f3f3ca5a8a | ||
|
|
bdd2dcd8c6 | ||
|
|
a3e373b063 | ||
|
|
dc9a7cffbd | ||
|
|
4cae63bbaa | ||
|
|
8aab968136 | ||
|
|
80fa6efd1e | ||
|
|
3ad83ad02b | ||
|
|
b66cde0270 | ||
|
|
331d12af2f | ||
|
|
318e4b79c1 | ||
|
|
99a4ae1f51 | ||
|
|
72ca565a0e | ||
|
|
cce435a31e | ||
|
|
6c86a8903f | ||
|
|
b21354d6d9 | ||
|
|
fa519cea64 | ||
|
|
132e9dac8b | ||
|
|
0c43d689d7 | ||
|
|
73d5bb3c97 | ||
|
|
81c275777d | ||
|
|
54fdeb7b1b | ||
|
|
150e32c4be | ||
|
|
1e13b7f973 | ||
|
|
d08d324a83 | ||
|
|
88668f448e | ||
|
|
c5baae35d5 | ||
|
|
1c315c3bf2 | ||
|
|
f9f41ef16e | ||
|
|
a68cf669aa | ||
|
|
3ddbf8b3ef | ||
|
|
b88eafcd00 | ||
|
|
d7dd25a1bc | ||
|
|
83e5e32e2b | ||
|
|
584d4878eb | ||
|
|
93183c661e | ||
|
|
5a819f4b48 | ||
|
|
2cd7c0eb36 | ||
|
|
1c8808339a | ||
|
|
6bf091104f | ||
|
|
4e3845ba68 | ||
|
|
0b6856c052 | ||
|
|
ffa849753a | ||
|
|
0f087f425a | ||
|
|
833c6634ad | ||
|
|
5a3bee638f | ||
|
|
66ba258baa | ||
|
|
216e1bee5c | ||
|
|
e0436e2600 | ||
|
|
ca69c33530 | ||
|
|
aa0bd9af72 | ||
|
|
9c35bb7426 | ||
|
|
af8a0739f0 | ||
|
|
99b61760c3 | ||
|
|
db9138c550 | ||
|
|
5bb1f0f779 | ||
|
|
4c549d6e92 | ||
|
|
dc719b300b | ||
|
|
f854da4f8d | ||
|
|
2dad6e4f62 | ||
|
|
72ea9ae458 | ||
|
|
9e8845885b | ||
|
|
00872d4336 | ||
|
|
780ccfc462 | ||
|
|
9acc07a973 | ||
|
|
698988669d | ||
|
|
bde67d83f2 | ||
|
|
057a0c5843 | ||
|
|
c212950df5 | ||
|
|
3c88d39192 | ||
|
|
c0d4287c6e | ||
|
|
3472ac3dcc | ||
|
|
5a90bceba0 | ||
|
|
af76f19ab4 | ||
|
|
e92d239996 | ||
|
|
602bf00435 | ||
|
|
1e05106a78 | ||
|
|
061b27af02 | ||
|
|
d265ed2b9e | ||
|
|
20cdca6f18 | ||
|
|
c668ca516c | ||
|
|
7fbdcb0205 | ||
|
|
92a4e283a9 | ||
|
|
ba9061e34e | ||
|
|
0c645ac3a1 | ||
|
|
eb8a793f6e | ||
|
|
36082b23c2 | ||
|
|
8191cd18d2 | ||
|
|
2692e0f189 | ||
|
|
29db7b3f51 | ||
|
|
1c1b4d13ad | ||
|
|
f8076fe90b | ||
|
|
19eeab0385 | ||
|
|
3f21ddf6ff | ||
|
|
5c1c946e88 | ||
|
|
698935c321 | ||
|
|
0c6543c4a6 | ||
|
|
4688df3b94 | ||
|
|
32f416109e | ||
|
|
08a55c97b7 | ||
|
|
d19bb7f4be | ||
|
|
6095d9d610 | ||
|
|
95d9b68ac9 | ||
|
|
2c4b1f3ed5 | ||
|
|
847be14d1e | ||
|
|
40606a6c08 | ||
|
|
650a02beb0 | ||
|
|
c6ed2069a5 | ||
|
|
fa4bc28d3e | ||
|
|
95de93bebb | ||
|
|
028db614ca | ||
|
|
c38674bb7b | ||
|
|
4ba5afaa1b | ||
|
|
0cd239dfd1 | ||
|
|
ed6ab4565d | ||
|
|
5d64b65d72 | ||
|
|
993fd8d7a2 | ||
|
|
9295342c16 | ||
|
|
e16b3a6245 | ||
|
|
da0c59a57c | ||
|
|
40425610d5 | ||
|
|
643f29fae6 | ||
|
|
59d31505e9 | ||
|
|
e50a0d902e | ||
|
|
22c17fe7e1 | ||
|
|
826a7a252a | ||
|
|
ce19978cfd | ||
|
|
427e0c9064 | ||
|
|
0df7b0b366 | ||
|
|
bd4b32873a | ||
|
|
9e580db434 | ||
|
|
87187c5391 | ||
|
|
60402614cd | ||
|
|
2727d35655 | ||
|
|
fb1ef29d54 | ||
|
|
7c0f63ea20 | ||
|
|
5d75828ceb | ||
|
|
bd2e8d184d | ||
|
|
3c7ce71d6f | ||
|
|
58e03a875f | ||
|
|
41a9fca881 | ||
|
|
ec58e864f5 | ||
|
|
375fbd829e | ||
|
|
4bae89bd4a | ||
|
|
897b2f16a9 | ||
|
|
93c8796ae8 | ||
|
|
654afb536e | ||
|
|
97154f9e5f | ||
|
|
c9e49b8e8f | ||
|
|
21804a75de | ||
|
|
3ae52e21cf | ||
|
|
f2395f9001 | ||
|
|
3d248496ab | ||
|
|
34eca948c4 | ||
|
|
58815575ef | ||
|
|
28b012389d | ||
|
|
54f86537ad | ||
|
|
3284d93a33 | ||
|
|
01686a5f67 | ||
|
|
3b0c485701 | ||
|
|
e61d348f0c | ||
|
|
22e594bc62 | ||
|
|
061733672f | ||
|
|
b1f5da9561 | ||
|
|
db517059a3 | ||
|
|
0b40892775 | ||
|
|
8e6ed71e2a | ||
|
|
cf569702c4 | ||
|
|
9a35c5229b | ||
|
|
96eaf42336 | ||
|
|
94894d60b7 | ||
|
|
4155b82f84 | ||
|
|
61d98534f6 | ||
|
|
8060dadd6c | ||
|
|
7d7db6bf90 | ||
|
|
2ef95bbc28 | ||
|
|
3035a08b0a | ||
|
|
9b34db14d4 | ||
|
|
9998849009 | ||
|
|
f2db62adf2 | ||
|
|
36fbf5bf22 | ||
|
|
5cdefa910f | ||
|
|
eb56ce2b76 | ||
|
|
2ab06fa937 | ||
|
|
bea0ef264a | ||
|
|
153c51e39f | ||
|
|
f562d7a6a4 | ||
|
|
b73f151619 | ||
|
|
da43b6292d | ||
|
|
b10eef537e | ||
|
|
5c5737303f | ||
|
|
16a474cce5 | ||
|
|
86192d89f4 | ||
|
|
c130e2808b | ||
|
|
78c7cd725d | ||
|
|
eff3a6f9a2 | ||
|
|
8449820480 | ||
|
|
9355bdc3c5 | ||
|
|
eb1aa2bfd6 | ||
|
|
6322cef8e2 | ||
|
|
c8a300164b | ||
|
|
86af0af0e4 | ||
|
|
8e8261c39e | ||
|
|
e9073f7298 | ||
|
|
5186728d53 | ||
|
|
1312d5eec0 | ||
|
|
c5cbc4a4e0 | ||
|
|
7f7ae4c8fc | ||
|
|
493db0c2bb | ||
|
|
6001cde234 | ||
|
|
3df03e4be9 | ||
|
|
1402cb7b64 | ||
|
|
2681126986 | ||
|
|
3e7fafb705 | ||
|
|
b6096c9437 | ||
|
|
681b481654 | ||
|
|
84694b602a | ||
|
|
cb54f6d6cc | ||
|
|
254690100c | ||
|
|
6bc19d2a1a | ||
|
|
fdf1752307 | ||
|
|
377a43c5be | ||
|
|
70e4463644 | ||
|
|
e6da33d3f0 | ||
|
|
eeaa8c78d8 | ||
|
|
8af91c9663 | ||
|
|
3be8c5b6b8 | ||
|
|
1763adf526 | ||
|
|
924d7364ba | ||
|
|
5f43b8168e | ||
|
|
642c3b7a9e | ||
|
|
78d3b9f5a1 | ||
|
|
6559ea470b | ||
|
|
ebbe3860ec | ||
|
|
ff96530e4d | ||
|
|
865863abf5 | ||
|
|
17cbb75161 | ||
|
|
f70b6a6b03 | ||
|
|
2ac48d408f | ||
|
|
667dbd1ec3 | ||
|
|
a4ef8a6c90 | ||
|
|
ea7c277c08 | ||
|
|
eb1afa4245 | ||
|
|
3447b68136 | ||
|
|
9f4a7cf5cc | ||
|
|
4ba0e1bfa2 | ||
|
|
f4fbc3215d | ||
|
|
721228c1d2 | ||
|
|
f9a81869ef | ||
|
|
d3e7bb119c | ||
|
|
dd45f8a6f8 | ||
|
|
ba7d3bd770 | ||
|
|
dc553aae55 | ||
|
|
f7b9646705 | ||
|
|
0ae24f7536 | ||
|
|
41cc96bbb8 | ||
|
|
6b03d68fac | ||
|
|
640d677913 | ||
|
|
8567e69d46 | ||
|
|
4d187a2bb7 | ||
|
|
073394b1eb | ||
|
|
02bfa07819 | ||
|
|
fdc163a30d | ||
|
|
bc44e8b550 | ||
|
|
e894397503 | ||
|
|
f1a23a02c2 | ||
|
|
95dc9a749b | ||
|
|
59963dd246 | ||
|
|
79fd5edc7f | ||
|
|
5d2433f69a | ||
|
|
1b49e79bf8 | ||
|
|
4e4056d790 | ||
|
|
e5f5ca07c4 | ||
|
|
a4a09043ec | ||
|
|
a613073ee3 | ||
|
|
8416a29862 | ||
|
|
9ab0fa9669 | ||
|
|
05aeb2f094 | ||
|
|
c859c347cb | ||
|
|
7ab24bf850 | ||
|
|
cfbdd58e4a | ||
|
|
8084916e30 | ||
|
|
9d02ab3734 | ||
|
|
3442bb6624 | ||
|
|
23d13d3a6e | ||
|
|
5cd7bf5993 | ||
|
|
f7b80e6d44 | ||
|
|
588fde91b2 | ||
|
|
94e6bd90d8 | ||
|
|
456e3d8979 | ||
|
|
76ba7f0c88 | ||
|
|
0652e6c3fb | ||
|
|
dbad1d4629 | ||
|
|
dd8315059c | ||
|
|
822bbb85dc | ||
|
|
daea315b13 | ||
|
|
52b274c5e8 | ||
|
|
b5716a70c2 | ||
|
|
3eb8155ed2 | ||
|
|
11368a0b16 | ||
|
|
cf0e64f84c | ||
|
|
d98ddeeb56 | ||
|
|
0701329ea7 | ||
|
|
f5ce33a8de | ||
|
|
f89a603582 | ||
|
|
279f68c432 | ||
|
|
b6ee8f8035 | ||
|
|
cea872be0b | ||
|
|
558de5ebee | ||
|
|
aba0c5d7ee | ||
|
|
4c17ee7393 | ||
|
|
24237a07c9 | ||
|
|
d4d5ee87cb | ||
|
|
56e6e332cb | ||
|
|
7ac679aa04 | ||
|
|
8a09e49e31 | ||
|
|
3485297e6e | ||
|
|
541562451e | ||
|
|
824834f847 | ||
|
|
bc6fecfadb | ||
|
|
a2434f9aa3 | ||
|
|
466081acc1 | ||
|
|
d9b9ce2909 | ||
|
|
a0f0949078 | ||
|
|
6f4d0e9488 | ||
|
|
65e8345ab8 | ||
|
|
de5c2cda09 | ||
|
|
9adb7e92e1 | ||
|
|
c7d6a2e898 | ||
|
|
6188778538 | ||
|
|
2affc04e62 | ||
|
|
6513b176f4 | ||
|
|
646f386f12 | ||
|
|
6a463c801e | ||
|
|
d9795399ad | ||
|
|
a648822027 | ||
|
|
c6244fa4b9 | ||
|
|
f6746b4977 | ||
|
|
f1f1a77151 | ||
|
|
ba1af627fd | ||
|
|
b82dfd7e64 | ||
|
|
7de2bcb584 | ||
|
|
d5d11e1077 | ||
|
|
ab66b20944 | ||
|
|
baf1682635 | ||
|
|
eea11834f8 | ||
|
|
1d86cc39e9 | ||
|
|
686252f151 | ||
|
|
f5c15f66a5 | ||
|
|
9d6b50320d | ||
|
|
b903c54be4 | ||
|
|
63b4a59588 | ||
|
|
adc6255fd4 | ||
|
|
b28e7a1357 | ||
|
|
e7ba9a5726 | ||
|
|
2586e78127 | ||
|
|
23f6dedb42 | ||
|
|
10f30ddb59 | ||
|
|
96a66f3ddd | ||
|
|
5bf8ceac66 | ||
|
|
4d42352ec5 | ||
|
|
479232b6c0 | ||
|
|
0d596a20f8 | ||
|
|
5ca272b262 | ||
|
|
ff0d4347d6 | ||
|
|
e03db60a52 | ||
|
|
b4bd95b2ac | ||
|
|
fe21e3ad16 | ||
|
|
85108bfd84 | ||
|
|
92bfd0ea7e | ||
|
|
542de8f03a | ||
|
|
06864917a5 | ||
|
|
bcfe6fd1d2 | ||
|
|
e395e626ec | ||
|
|
f9b5eca2b9 | ||
|
|
920295527f | ||
|
|
9c79fa7430 | ||
|
|
4b2f8122e3 | ||
|
|
052f5a4cc6 | ||
|
|
78bf74eafb | ||
|
|
b1c145dd5f | ||
|
|
3055343b6d | ||
|
|
70f64374d4 | ||
|
|
1362e52aa7 | ||
|
|
e51e0e683f | ||
|
|
7910d02b71 | ||
|
|
f224cb37d6 | ||
|
|
79813a1eac | ||
|
|
9066ad6cd0 | ||
|
|
f5e4c0356f | ||
|
|
3c634ecfd8 | ||
|
|
57eeca6341 | ||
|
|
23ba80c9c7 | ||
|
|
73bbd75d3f | ||
|
|
0a7763d902 | ||
|
|
7aeed8455e | ||
|
|
8eba5a1c28 | ||
|
|
0f552a8eef | ||
|
|
31c488e5b0 | ||
|
|
1ee4940d4d | ||
|
|
640ef1bb17 | ||
|
|
42edfe0681 | ||
|
|
6a6d263c0d | ||
|
|
68a5e33182 | ||
|
|
3f7ec21717 | ||
|
|
b89f20cec5 | ||
|
|
fa04972a26 | ||
|
|
99b5d4a35a |
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: hardware-test
|
||||
description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results.
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
---
|
||||
|
||||
# Hardware testing
|
||||
|
||||
The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was
|
||||
driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing.
|
||||
`$SKILL` below is this directory (`.agents/skills/hardware-test`).
|
||||
|
||||
## Ground rules
|
||||
|
||||
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
|
||||
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
|
||||
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
|
||||
the shell that runs it. Never run `esphome logs` in a
|
||||
`while true` loop: the loop respawns it and holds the port.
|
||||
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
|
||||
`!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs.
|
||||
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
|
||||
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
|
||||
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
|
||||
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
|
||||
the build's `src/` for the change before trusting any number.
|
||||
4. **PR build and baseline build get different `name:` values.** Builds share
|
||||
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
|
||||
Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh
|
||||
name every run.
|
||||
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
|
||||
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
|
||||
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
|
||||
6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in
|
||||
the same session and say so in the results.
|
||||
7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is
|
||||
piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or
|
||||
`perl -e 'alarm shift; exec @ARGV' <seconds> <command>` on macOS without it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the
|
||||
ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name
|
||||
from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell
|
||||
them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at
|
||||
74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version).
|
||||
`ports.py --wait 2e8a` blocks until a Pico re-enumerates.
|
||||
2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need.
|
||||
One yaml per board per variant, file name equal to `name:`.
|
||||
3. **Compile** in the right tree:
|
||||
`(cd <worktree> && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile <abs yaml> > <name>-compile.log 2>&1)`.
|
||||
Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race
|
||||
over tool caches; run them one at a time.
|
||||
4. **Flash** per chip family (table below). Stop the capture on that port first.
|
||||
5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with
|
||||
`grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32)
|
||||
drop the port on reboot; the script reopens it until its deadline.
|
||||
6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that
|
||||
presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:`
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
| Board | Flash | Log |
|
||||
|---|---|---|
|
||||
| ESP8266 D1 mini (CH340) | `python -m esphome upload <yaml> --device <port>` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash |
|
||||
| ESP32 devkit (CP2102 or CH340) | `esphome upload --device <port>`, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests |
|
||||
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
|
||||
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
|
||||
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
|
||||
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test |
|
||||
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
|
||||
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
|
||||
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
|
||||
| Any reachable device | `esphome upload <yaml> --device <ip or name.local>`; `--file <bin>` for a prebuilt image | `esphome logs --device <ip>` bounded with `timeout` |
|
||||
|
||||
ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA port is 8892.
|
||||
|
||||
## Scripts in `$SKILL/scripts`
|
||||
|
||||
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
|
||||
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
|
||||
`--serial-of PORT` (the serial number behind a path).
|
||||
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
|
||||
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
|
||||
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
|
||||
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
|
||||
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
|
||||
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN.
|
||||
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
|
||||
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
|
||||
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
|
||||
`RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network),
|
||||
`RESULT INVALID`
|
||||
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
|
||||
or exits 1 when a build, flash or port step failed.
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
|
||||
serves the captive portal. `example-dut.yaml` is the per-run file with the platform block.
|
||||
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
|
||||
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
|
||||
plain provisioning check.
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
||||
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
|
||||
booted with saved credentials because the name was reused. One AP device at a time on the bench.
|
||||
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3).
|
||||
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
|
||||
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
|
||||
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
|
||||
soak tests.
|
||||
|
||||
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven
|
||||
flows. Add new findings there.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Board facts
|
||||
|
||||
Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run
|
||||
`scripts/ports.py`.
|
||||
|
||||
## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102)
|
||||
|
||||
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
|
||||
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
|
||||
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
|
||||
a plain pyserial capture at 115200 is reliable.
|
||||
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
|
||||
- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay
|
||||
active. `esptool erase_flash` gives a clean device.
|
||||
- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web
|
||||
handler, an lwIP callback). Decode stacks with
|
||||
`xtensa-lx106-elf-addr2line -e <build>/.pioenvs/<name>/firmware.elf -f -C -i <addr...>` from the
|
||||
toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome
|
||||
cache directory (native toolchain builds).
|
||||
- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a
|
||||
row in the PR.
|
||||
- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and
|
||||
stall the link until the broker drops TCP.
|
||||
|
||||
## ESP32 classic devkit
|
||||
|
||||
- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge
|
||||
timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web
|
||||
upload of 739 KB took 107 s with power save and 45 s without).
|
||||
- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP
|
||||
socket loses the same, so that is the AP or driver, not a PR.
|
||||
- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against
|
||||
`<build>/.pioenvs/<name>/firmware.elf` (PlatformIO) or `<build>/build/<name>.elf` (IDF native).
|
||||
- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`.
|
||||
- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an
|
||||
image still pending OTA rollback.
|
||||
- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys`
|
||||
under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile
|
||||
tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier
|
||||
refuses unsigned images afterwards, so restore with a signed plain build.
|
||||
|
||||
## ESP32-S3, C3, C6, H2 with native USB
|
||||
|
||||
- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`.
|
||||
- The USB console can die at the moment the app takes over the console during a reboot (log stops after
|
||||
`Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a
|
||||
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
|
||||
while Wi-Fi is off).
|
||||
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
|
||||
|
||||
## RP2040 / RP2350 (Pico W, Pico 2 W)
|
||||
|
||||
- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect
|
||||
with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port
|
||||
vanishes mid-capture.
|
||||
- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`).
|
||||
- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails;
|
||||
joins must wait for `network::is_connected()`.
|
||||
- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time.
|
||||
- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch.
|
||||
|
||||
## BW15 kit (RTL8720CF, LibreTiny rtl87xx)
|
||||
|
||||
- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and
|
||||
holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail
|
||||
("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released
|
||||
on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses
|
||||
RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python.
|
||||
- `flash write -d <port> <uf2>` takes no family argument; `flash read -d <port> -s 0 -l <len>
|
||||
realtek-ambz2 <out>` does.
|
||||
- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already.
|
||||
- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first
|
||||
plain open after a replug streams logs and the second is silent; always open with the lines released.
|
||||
- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus
|
||||
credentials (it connects after two cooldowns); right after connecting while the AP is still up the
|
||||
driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every
|
||||
STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and
|
||||
lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s.
|
||||
- Decode faults with `arm-none-eabi-addr2line -e <build>/.pioenvs/<name>/raw_firmware.elf -f -C -i <PC>`
|
||||
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
|
||||
Fault dumps print PC, LR and R0 to R3.
|
||||
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
|
||||
|
||||
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
|
||||
|
||||
- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open,
|
||||
holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and
|
||||
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
|
||||
reaches the downloader; no software reboot does.
|
||||
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
|
||||
- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one
|
||||
capture open for the whole test and never reopen mid-phase.
|
||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||
radio enable 160 to 278 ms, disable about 200 ms.
|
||||
|
||||
## BW16 kit (RTL8720DN, AmebaD)
|
||||
|
||||
- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while
|
||||
RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is
|
||||
esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the
|
||||
first pulse after a replug can be lost, retry up to three times.
|
||||
- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud
|
||||
changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first.
|
||||
- The AmebaD core has no mDNS responder (`mdns: disabled: true`).
|
||||
|
||||
## LN882H
|
||||
|
||||
- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking"
|
||||
or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py`
|
||||
to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short
|
||||
BOOT to GND while the tool connects. OTA afterwards.
|
||||
- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there.
|
||||
|
||||
## nRF52840
|
||||
|
||||
- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port
|
||||
name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic
|
||||
`nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1
|
||||
--application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p <port>`); ESPHome downloads
|
||||
it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which
|
||||
`dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button
|
||||
re-enters it.
|
||||
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
|
||||
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
|
||||
button P0.18.
|
||||
|
||||
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
|
||||
|
||||
- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`,
|
||||
`tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`.
|
||||
- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp`
|
||||
for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools,
|
||||
`IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000.
|
||||
- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not
|
||||
enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both.
|
||||
- `dns-sd -L <name> _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead.
|
||||
Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host
|
||||
logs `Unexpected RCP reset: RESET_OTHER` and recovers).
|
||||
@@ -0,0 +1,117 @@
|
||||
# Proven test flows
|
||||
|
||||
Each flow names the pull requests it was used on, so their result tables show what to post.
|
||||
|
||||
## Captive portal `/wifisave` hammer (#20802, #20784)
|
||||
|
||||
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
|
||||
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
|
||||
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
|
||||
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
|
||||
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
|
||||
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
|
||||
|
||||
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
|
||||
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
|
||||
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
|
||||
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
|
||||
|
||||
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
|
||||
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
|
||||
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
|
||||
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
|
||||
refused proves the portal's server is gone.
|
||||
|
||||
## `reboot_timeout` with a fallback AP (#9958)
|
||||
|
||||
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
|
||||
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
|
||||
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
|
||||
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
|
||||
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
|
||||
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
|
||||
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
|
||||
|
||||
## MQTT (#20433, #17935, #20762, #20767, #20768)
|
||||
|
||||
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
|
||||
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
|
||||
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
|
||||
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
|
||||
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
|
||||
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
|
||||
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
|
||||
paho script publishes and polls the serial capture file.
|
||||
|
||||
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
|
||||
|
||||
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
|
||||
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
|
||||
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
|
||||
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
|
||||
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
|
||||
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
|
||||
rejects `%.*s`; use a lambda.
|
||||
|
||||
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
|
||||
|
||||
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
|
||||
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
||||
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
||||
`pkill -f "a\|b"` does not alternate; use two pkills.
|
||||
|
||||
## DHCP lease cache (#17767)
|
||||
|
||||
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
|
||||
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
|
||||
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
|
||||
`upload` then `logs`.
|
||||
|
||||
## HDMI-CEC two-board wire (#9189)
|
||||
|
||||
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
|
||||
filter on the reply handler, template buttons for each target plus lambdas calling
|
||||
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
|
||||
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
|
||||
lags the press).
|
||||
|
||||
## LD2450 polygon zones (#20154)
|
||||
|
||||
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
|
||||
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
|
||||
Writing those numbers blocks the ld2450 loop about 50 ms.
|
||||
|
||||
## Scheduler and loop wake A/B (#20356)
|
||||
|
||||
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
|
||||
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
|
||||
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
|
||||
`button_command`). Build the same yaml twice with different names, one per tree.
|
||||
|
||||
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
|
||||
|
||||
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
|
||||
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
|
||||
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
|
||||
|
||||
## ESP-NOW on the ESP8266 (#14407)
|
||||
|
||||
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
|
||||
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
|
||||
mode the device transmits from the soft-AP MAC.
|
||||
|
||||
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
|
||||
|
||||
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
|
||||
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
|
||||
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
|
||||
supplicant state machine.
|
||||
|
||||
## Thread border router (#18104)
|
||||
|
||||
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
|
||||
|
||||
## nRF52840 dongle boards (#19009, #20814)
|
||||
|
||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||
@@ -0,0 +1,51 @@
|
||||
"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port.
|
||||
|
||||
Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and
|
||||
pyserial raises both lines on open, holding CEN low through the BootROM's
|
||||
listen window. Open with both lines released and pulse the reset right
|
||||
before the LinkChecks instead.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import bk7231tools.serial.main as bk_main
|
||||
from ltchiptool.__main__ import cli
|
||||
from ltchiptool.soc.bk72xx.flash import BK72XXFlash
|
||||
from serial import Serial
|
||||
|
||||
|
||||
class ReleasedSerial(Serial):
|
||||
def __init__(self, port=None, **kwargs):
|
||||
super().__init__(None, **kwargs)
|
||||
self.dtr = False
|
||||
self.rts = False
|
||||
self.port = port
|
||||
self.open()
|
||||
|
||||
|
||||
bk_main.Serial = ReleasedSerial
|
||||
|
||||
_connect = BK72XXFlash.flash_connect
|
||||
|
||||
|
||||
def flash_hw_reset(self) -> None:
|
||||
self._reset_pending = True
|
||||
|
||||
|
||||
def flash_connect(self) -> None:
|
||||
pending = getattr(self, "_reset_pending", False)
|
||||
if self.bk and self.conn.linked and not pending:
|
||||
return
|
||||
self.flash_build_protocol()
|
||||
if pending:
|
||||
self._reset_pending = False
|
||||
self.bk.hw_reset()
|
||||
self.conn.linked = False # the chip rebooted, so link again below
|
||||
_connect(self)
|
||||
|
||||
|
||||
BK72XXFlash.flash_hw_reset = flash_hw_reset
|
||||
BK72XXFlash.flash_connect = flash_connect
|
||||
|
||||
sys.argv = ["ltchiptool", *sys.argv[1:]]
|
||||
cli()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR).
|
||||
|
||||
A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS
|
||||
released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse
|
||||
boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not
|
||||
booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset.
|
||||
|
||||
Usage: python flash_rtl87xx_kit.py flash write -d <port> firmware.uf2
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
_first = [True]
|
||||
_orig_open = serial.Serial.open
|
||||
|
||||
|
||||
def _open(self):
|
||||
self.dtr = True # PA00 high: ROM download mode on the next reset
|
||||
self.rts = False # CEN released
|
||||
_orig_open(self)
|
||||
if _first[0]:
|
||||
_first[0] = False
|
||||
self.rts = True
|
||||
time.sleep(0.1)
|
||||
self.rts = False
|
||||
time.sleep(0.3) # reset into the ROM
|
||||
self.reset_input_buffer()
|
||||
|
||||
|
||||
serial.Serial.open = _open
|
||||
|
||||
from ltchiptool.__main__ import cli_entrypoint # noqa: E402
|
||||
|
||||
|
||||
def port_argument(argv: list[str]) -> str | None:
|
||||
for i, a in enumerate(argv):
|
||||
if a in ("-d", "--device") and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
if a.startswith(("-d=", "--device=")):
|
||||
return a.split("=", 1)[1]
|
||||
if a.startswith("-d") and len(a) > 2:
|
||||
return a[2:]
|
||||
return None
|
||||
|
||||
|
||||
def boot_app(port: str) -> None:
|
||||
s = serial.Serial(port=None)
|
||||
s.port = port
|
||||
s.dtr = False
|
||||
s.rts = False
|
||||
_orig_open(s)
|
||||
s.rts = True
|
||||
time.sleep(0.1)
|
||||
s.rts = False
|
||||
s.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rc = 1
|
||||
try:
|
||||
result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False)
|
||||
rc = int(result) if isinstance(result, int) else 0
|
||||
finally:
|
||||
port = port_argument(sys.argv)
|
||||
if not port:
|
||||
print(
|
||||
"[flash_rtl87xx_kit] no -d PORT given, the application was not booted",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if rc == 0:
|
||||
rc = 2
|
||||
else:
|
||||
try:
|
||||
boot_app(port)
|
||||
print("[flash_rtl87xx_kit] application booted")
|
||||
except (serial.SerialException, OSError) as err:
|
||||
print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr)
|
||||
if rc == 0:
|
||||
rc = 2 # flashed, but the board is still in the ROM: press reset
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""List the boards on USB, or wait for one to enumerate.
|
||||
|
||||
ports.py table: port, vid:pid, product, serial number, guess
|
||||
ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s)
|
||||
ports.py --wait 1a86:7523 --timeout 30
|
||||
ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive)
|
||||
ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under
|
||||
ports.py --serial-of PORT print the USB serial number behind a port name
|
||||
|
||||
Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so
|
||||
re-run this after every replug instead of trusting a name from an earlier session.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from serial.tools import list_ports
|
||||
|
||||
GUESS = [
|
||||
(
|
||||
(0x1A86, 0x7523),
|
||||
"CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner",
|
||||
),
|
||||
((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"),
|
||||
((0x1A86, 0x55D4), "CH343: ESP32 devkit"),
|
||||
((0x1A86, None), "WCH CH34x USB serial bridge"),
|
||||
((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"),
|
||||
(
|
||||
(0x303A, 0x1001),
|
||||
"Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)",
|
||||
),
|
||||
(
|
||||
(0x2E8A, None),
|
||||
"Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot",
|
||||
),
|
||||
((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"),
|
||||
(
|
||||
(0x2FE3, 0x0100),
|
||||
"nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second",
|
||||
),
|
||||
((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"),
|
||||
((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"),
|
||||
]
|
||||
|
||||
|
||||
def guess(vid: int, pid: int) -> str:
|
||||
for (gv, gp), text in GUESS:
|
||||
if vid == gv and (gp is None or pid == gp):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def parse_id(text: str) -> tuple[int, int | None]:
|
||||
vid, _, pid = text.partition(":")
|
||||
return int(vid, 16), (int(pid, 16) if pid else None)
|
||||
|
||||
|
||||
def matches(
|
||||
port, vid: int | None, pid: int | None, product: str | None, serial: str | None
|
||||
) -> bool:
|
||||
if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)):
|
||||
return False
|
||||
if product and (port.product or "").lower() != product.lower():
|
||||
return False
|
||||
return not serial or (port.serial_number or "") == serial
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument(
|
||||
"--wait",
|
||||
metavar="VID[:PID]",
|
||||
help="block until a matching port appears, print it",
|
||||
)
|
||||
ap.add_argument("--product", help="USB product string to match")
|
||||
ap.add_argument("--serial", help="USB serial number to match")
|
||||
ap.add_argument(
|
||||
"--serial-of", metavar="PORT", help="print the USB serial number of PORT"
|
||||
)
|
||||
ap.add_argument("--timeout", type=float, default=60)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.serial_of:
|
||||
for p in list_ports.comports():
|
||||
if p.device == args.serial_of and p.serial_number:
|
||||
print(p.serial_number)
|
||||
return 0
|
||||
print(f"no USB serial number for {args.serial_of}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
vid = pid = None
|
||||
if args.wait:
|
||||
vid, pid = parse_id(args.wait)
|
||||
|
||||
if args.wait or args.product or args.serial:
|
||||
deadline = time.time() + args.timeout
|
||||
while True:
|
||||
for p in sorted(list_ports.comports(), key=lambda p: p.device):
|
||||
if p.vid and matches(p, vid, pid, args.product, args.serial):
|
||||
print(p.device)
|
||||
return 0
|
||||
if time.time() >= deadline:
|
||||
print("no matching port", file=sys.stderr)
|
||||
return 1
|
||||
time.sleep(1)
|
||||
|
||||
rows = [p for p in list_ports.comports() if p.vid]
|
||||
if not rows:
|
||||
print("no USB serial ports")
|
||||
return 1
|
||||
for p in sorted(rows, key=lambda p: p.device):
|
||||
print(
|
||||
f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} "
|
||||
f"{p.serial_number or '':18} {guess(p.vid, p.pid)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Bounded, timestamped serial capture that always exits and closes the port.
|
||||
|
||||
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
|
||||
|
||||
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
|
||||
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
|
||||
(DTR) and reset (RTS), so a plain open holds the chip in reset.
|
||||
--reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot.
|
||||
--baud 115200 by default; 74880 shows the ESP8266 ROM boot messages.
|
||||
--append keep what OUTFILE already holds; by default it is truncated so counts cover this run only.
|
||||
|
||||
Exit status 1 when nothing was received (wrong port name, or a port another capture still holds).
|
||||
Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as <secret>.
|
||||
Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell
|
||||
loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards
|
||||
re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial
|
||||
number when it comes back under another device path.
|
||||
|
||||
Never run an open-ended serial session: every capture has a deadline, and a flash on the same port
|
||||
needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
from serial.tools import list_ports
|
||||
|
||||
ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]")
|
||||
# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end
|
||||
SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
|
||||
PORT_ERRORS = (serial.SerialException, OSError)
|
||||
|
||||
|
||||
def identify(port: str) -> tuple[str | None, int]:
|
||||
"""USB serial number of the port and its position among that device's ports (a dongle has two)."""
|
||||
ports = sorted(list_ports.comports(), key=lambda p: p.device)
|
||||
serial_number = next((p.serial_number for p in ports if p.device == port), None)
|
||||
siblings = [
|
||||
p.device for p in ports if serial_number and p.serial_number == serial_number
|
||||
]
|
||||
return serial_number, siblings.index(port) if port in siblings else 0
|
||||
|
||||
|
||||
def current_path(port: str, serial_number: str | None, index: int) -> str:
|
||||
"""The port's current device path: a USB CDC board can come back under a new name after a reboot."""
|
||||
if serial_number:
|
||||
ports = sorted(list_ports.comports(), key=lambda p: p.device)
|
||||
siblings = [p.device for p in ports if p.serial_number == serial_number]
|
||||
if index < len(siblings):
|
||||
return siblings[index]
|
||||
return port
|
||||
|
||||
|
||||
def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
|
||||
s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2)
|
||||
s.port = path
|
||||
if args.released:
|
||||
s.dtr = False
|
||||
s.rts = False
|
||||
s.open()
|
||||
if args.reset:
|
||||
try:
|
||||
s.dtr = False
|
||||
s.rts = True
|
||||
time.sleep(0.1)
|
||||
s.rts = False
|
||||
s.reset_input_buffer()
|
||||
except PORT_ERRORS:
|
||||
s.close()
|
||||
raise
|
||||
return s
|
||||
|
||||
|
||||
def write_line(out, tag: bytes, data: bytes) -> None:
|
||||
stamp = time.strftime("[%H:%M:%S]").encode()
|
||||
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
|
||||
out.flush()
|
||||
|
||||
|
||||
def note(out, text: str) -> None:
|
||||
write_line(out, b"[capture] ", text.encode())
|
||||
|
||||
|
||||
def capture(args: argparse.Namespace, out) -> int:
|
||||
"""Returns the number of bytes received."""
|
||||
deadline = time.time() + args.seconds
|
||||
buf = b""
|
||||
s = None
|
||||
received = 0
|
||||
serial_number, index = None, 0
|
||||
while time.time() < deadline:
|
||||
if s is None:
|
||||
path = current_path(args.port, serial_number, index)
|
||||
try:
|
||||
s = open_port(args, path)
|
||||
args.reset = False # only reset on the first open
|
||||
if serial_number is None:
|
||||
serial_number, index = identify(path)
|
||||
if path != args.port:
|
||||
note(out, f"reopened as {path}")
|
||||
except PORT_ERRORS as err:
|
||||
note(out, f"open failed: {err}")
|
||||
time.sleep(1)
|
||||
continue
|
||||
try:
|
||||
data = s.read(4096)
|
||||
except PORT_ERRORS as err:
|
||||
if buf: # keep the cut-off line apart from the next boot's first line
|
||||
write_line(out, b"[partial]", buf)
|
||||
buf = b""
|
||||
note(out, f"port lost: {err}")
|
||||
with contextlib.suppress(*PORT_ERRORS):
|
||||
s.close()
|
||||
s = None
|
||||
time.sleep(1)
|
||||
continue
|
||||
if not data:
|
||||
continue
|
||||
received += len(data)
|
||||
buf += data
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
write_line(out, b"", line.rstrip(b"\r"))
|
||||
if buf:
|
||||
write_line(out, b"[partial]", buf)
|
||||
if s is not None:
|
||||
s.close()
|
||||
return received
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("port")
|
||||
ap.add_argument("seconds", type=float)
|
||||
ap.add_argument("outfile")
|
||||
ap.add_argument("--baud", type=int, default=115200)
|
||||
ap.add_argument("--released", action="store_true")
|
||||
ap.add_argument("--reset", action="store_true")
|
||||
ap.add_argument("--append", action="store_true")
|
||||
args = ap.parse_args()
|
||||
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
|
||||
received = capture(args, out)
|
||||
if received == 0:
|
||||
print(f"[capture] nothing received from {args.port}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts
|
||||
# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and
|
||||
# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml.
|
||||
#
|
||||
# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S]
|
||||
# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number),
|
||||
# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py)
|
||||
# *_YAML absolute path; the file name without .yaml must equal the config's `name`
|
||||
# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome)
|
||||
# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90)
|
||||
# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY
|
||||
# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python)
|
||||
# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt
|
||||
# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local).
|
||||
# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network,
|
||||
# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show
|
||||
# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run);
|
||||
# 1 when a build, flash or port step fails (nothing is reported).
|
||||
set -u
|
||||
SKILL=$(cd "$(dirname "$0")/.." && pwd)
|
||||
KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40}
|
||||
if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; }
|
||||
else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi
|
||||
LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml)
|
||||
CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml)
|
||||
DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log
|
||||
INVALID=""
|
||||
AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240
|
||||
# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line
|
||||
CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]'
|
||||
# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too),
|
||||
# while on a UART board a port loss comes from the adapter or the hub, not the chip
|
||||
BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost'
|
||||
DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60))
|
||||
|
||||
bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; }
|
||||
count() { LC_ALL=C grep -a -c "$1" "$2"; }
|
||||
wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout
|
||||
local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; }
|
||||
# Only this run's captures: another capture on the host (a soak on a third board) must survive
|
||||
stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; }
|
||||
fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; }
|
||||
port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware
|
||||
LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; }
|
||||
compile() { # compile YAML TREE
|
||||
(cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$?
|
||||
echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; }
|
||||
esp_upload() { # esp_upload YAML PORT TREE
|
||||
(cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1)
|
||||
local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; }
|
||||
|
||||
stop_tails
|
||||
compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log"
|
||||
compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log"
|
||||
|
||||
TAILOPT=""
|
||||
case $KIND in
|
||||
esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;;
|
||||
pico)
|
||||
SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT"
|
||||
esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed"
|
||||
# The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now;
|
||||
# the capture follows the serial number if the path changes again
|
||||
DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate"
|
||||
echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;;
|
||||
bw15|bk)
|
||||
UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1)
|
||||
[ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME"
|
||||
echo "[hammer] uf2: ${UF2##*/}"
|
||||
W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py
|
||||
(cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$?
|
||||
echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log"
|
||||
TAILOPT="--released" ;;
|
||||
*) echo "unknown DUT_KIND $KIND"; exit 1 ;;
|
||||
esac
|
||||
|
||||
: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &)
|
||||
if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then
|
||||
[ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err"
|
||||
port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG"
|
||||
fail "DUT never raised its AP within ${n}s, see $DLOG"
|
||||
fi
|
||||
echo "[hammer] DUT fallback AP after ${n}s"; sleep 2
|
||||
# reboots are counted from here; the first banner may predate the capture
|
||||
BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG")
|
||||
|
||||
esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed"
|
||||
: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &)
|
||||
if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then
|
||||
echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE"
|
||||
else
|
||||
[ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err"
|
||||
port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG"
|
||||
INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID"
|
||||
fi
|
||||
|
||||
# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own
|
||||
for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do
|
||||
port=${spec%%:*}; err=${spec#*:}
|
||||
pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err"
|
||||
[ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err"
|
||||
done
|
||||
CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG")
|
||||
BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE ))
|
||||
if [ "$KIND" = pico ]; then
|
||||
REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger
|
||||
else
|
||||
REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)"
|
||||
fi
|
||||
STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}')
|
||||
SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG")
|
||||
echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA"
|
||||
FAILED=$(count 'bogus save status -1' "$CLOG")
|
||||
echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}"
|
||||
# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in
|
||||
LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1<redacted>/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt"
|
||||
echo "[hammer] events: $DDIR/$DNAME-events.txt"
|
||||
stop_tails
|
||||
[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; }
|
||||
# Provisioning is proven by the device joining the network; the client's answer to the real save can time
|
||||
# out because the device leaves the AP before replying, so its status is information only
|
||||
if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then
|
||||
echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3
|
||||
fi
|
||||
# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit
|
||||
CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15}
|
||||
if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then
|
||||
echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2
|
||||
fi
|
||||
[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer"
|
||||
echo "[hammer] RESULT PASS"; exit 0
|
||||
@@ -0,0 +1,77 @@
|
||||
# Package for the client board: it joins the device under test's AP (priority 10), posts
|
||||
# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once.
|
||||
# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml);
|
||||
# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe.
|
||||
# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts:
|
||||
# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200"
|
||||
# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not
|
||||
# start a second series on top of the first.
|
||||
substitutions:
|
||||
ap_password: hwtestpass
|
||||
bogus_saves: "15"
|
||||
save_interval: 3s
|
||||
bogus_ssid: hwtest-no-such-network
|
||||
bogus_psk: nosuchpassword
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
http_request:
|
||||
id: http
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
wifi:
|
||||
id: wifi_id
|
||||
networks:
|
||||
- ssid: ${ap_ssid}
|
||||
password: ${ap_password}
|
||||
priority: 10
|
||||
- ssid: ${real_ssid}
|
||||
password: ${real_psk}
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
char b[40];
|
||||
ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b));
|
||||
- if:
|
||||
condition:
|
||||
lambda: |-
|
||||
char b[40];
|
||||
return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0;
|
||||
then:
|
||||
- script.execute: hammer
|
||||
|
||||
script:
|
||||
- id: hammer
|
||||
mode: single
|
||||
then:
|
||||
- delay: 3s
|
||||
- logger.log: "hammering /wifisave with bogus credentials"
|
||||
- repeat:
|
||||
count: ${bogus_saves}
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1);
|
||||
- http_request.get:
|
||||
url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}"
|
||||
capture_response: true
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("test", "bogus save status %d", response->status_code);
|
||||
on_error:
|
||||
- lambda: ESP_LOGW("test", "bogus save status -1");
|
||||
- delay: ${save_interval}
|
||||
- logger.log: "posting the real credentials"
|
||||
- http_request.get:
|
||||
url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}"
|
||||
capture_response: true
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("test", "real save status %d", response->status_code);
|
||||
on_error:
|
||||
- lambda: ESP_LOGW("test", "real save status -1");
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||
@@ -0,0 +1,31 @@
|
||||
# Package for a device under test that never finds its network, falls back to its AP and serves the
|
||||
# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions
|
||||
# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused
|
||||
# name boots straight onto the real network and never raises the AP.
|
||||
substitutions:
|
||||
ap_password: hwtestpass
|
||||
ap_timeout: 15s
|
||||
bogus_ssid: hwtest-no-such-network
|
||||
bogus_psk: nosuchpassword
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: ${bogus_ssid}
|
||||
password: ${bogus_psk}
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
password: ${ap_password}
|
||||
ap_timeout: ${ap_timeout}
|
||||
|
||||
captive_portal:
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||
@@ -0,0 +1,18 @@
|
||||
# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml;
|
||||
# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe.
|
||||
substitutions:
|
||||
name: hwtest-client-1
|
||||
ap_ssid: hwtest-ap
|
||||
real_ssid: !secret wifi_ssid
|
||||
real_psk: !secret wifi_password
|
||||
bogus_saves: "15"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
# esp8266: {board: d1_mini}
|
||||
|
||||
packages:
|
||||
hwtest: !include client-wifisave.yaml
|
||||
@@ -0,0 +1,16 @@
|
||||
# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal
|
||||
# to `name`, pick one platform block, bump `name` every run.
|
||||
substitutions:
|
||||
name: hwtest-dut-1
|
||||
ap_ssid: hwtest-ap
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
# esp32: {board: esp32dev, framework: {type: esp-idf}}
|
||||
# rtl87xx: {board: bw15}
|
||||
# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default
|
||||
# rp2040: {board: rpipico2w}
|
||||
|
||||
packages:
|
||||
hwtest: !include dut-fallback-ap.yaml
|
||||
@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => {
|
||||
});
|
||||
const currentLabels = currentLabelsData.map(label => label.name);
|
||||
const managedLabels = currentLabels.filter(label =>
|
||||
label.startsWith('component: ') || MANAGED_LABELS.includes(label)
|
||||
label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label)
|
||||
);
|
||||
|
||||
// Check for mega-PR early - if present, skip most automatic labeling
|
||||
|
||||
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
|
||||
|
||||
message +=
|
||||
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
|
||||
`this PR is on the large side `;
|
||||
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
|
||||
`large side `;
|
||||
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
message +=
|
||||
@@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
}
|
||||
|
||||
message += `, which makes it harder for maintainers to review.\n\n`;
|
||||
let condition;
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
condition =
|
||||
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
|
||||
`at most ${MAX_LABELS} components/areas`;
|
||||
} else if (tooManyLabels) {
|
||||
condition = `touches at most ${MAX_LABELS} components/areas`;
|
||||
} else {
|
||||
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
|
||||
}
|
||||
message +=
|
||||
`Smaller, focused PRs tend to be reviewed much faster since they ` +
|
||||
`fit into the short gaps between other maintainer work; large ones ` +
|
||||
`often have to wait for a rare long uninterrupted block of time. ` +
|
||||
`If you can break this up into smaller pieces that can be reviewed ` +
|
||||
`independently, it will almost certainly land faster overall.\n\n`;
|
||||
`**This PR will not be reviewed until it ${condition}.** We know ` +
|
||||
`that is not what you want to hear after this much work, and we want ` +
|
||||
`to be upfront so you do not wait for a review that is not coming: ` +
|
||||
`please split the change. The label is removed automatically once ` +
|
||||
`the PR ${condition}.\n\n`;
|
||||
message +=
|
||||
`Before putting more time in, it's also worth popping into ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
|
||||
`you scope things and flag anything already in flight.\n\n`;
|
||||
`There is no exception you can request. Rarely, a maintainer agrees in ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
|
||||
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
|
||||
`but that is rare enough that you should plan on splitting rather than ` +
|
||||
`wait for it. Smaller, focused PRs are picked up much faster because ` +
|
||||
`they fit into the short gaps between other maintainer work. For a new ` +
|
||||
`component, the first PR brings the hub and a single platform (for ` +
|
||||
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
|
||||
message +=
|
||||
`For more details (including how to split the work up), see: ` +
|
||||
`https://developers.esphome.io/contributing/submitting-your-work/` +
|
||||
@@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateReviewMessages,
|
||||
handleReviews,
|
||||
handleMaintainerAccessComment
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateReviewMessages } = require('../reviews');
|
||||
|
||||
const MAX_LABELS = 15;
|
||||
const TOO_BIG_THRESHOLD = 1000;
|
||||
|
||||
function tooBigMessage(labelCount, additions) {
|
||||
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
|
||||
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
|
||||
assert.equal(messages.length, 1);
|
||||
return messages[0];
|
||||
}
|
||||
|
||||
describe('too-big review message', () => {
|
||||
it('names the line cap when the PR is too long', () => {
|
||||
const message = tooBigMessage(3, 1500);
|
||||
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
|
||||
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
|
||||
assert.doesNotMatch(message, /components\/areas\./);
|
||||
});
|
||||
|
||||
it('names the area cap when the PR touches too many areas but is short', () => {
|
||||
const message = tooBigMessage(20, 200);
|
||||
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
|
||||
assert.doesNotMatch(message, /until it is at most 1000 lines/);
|
||||
});
|
||||
|
||||
it('names both caps when both are exceeded', () => {
|
||||
const message = tooBigMessage(20, 1500);
|
||||
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
|
||||
});
|
||||
|
||||
it('tells new-component authors to bring one platform first', () => {
|
||||
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
|
||||
assert.match(tooBigMessage(3, 1500), /mega-pr/);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: git diff
|
||||
- if: failure()
|
||||
name: Archive artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: generated-proto-files
|
||||
path: |
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Upload compile-test image artifact
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
# The tar is already compressed, so upload it as-is. archive: false
|
||||
# skips the redundant zip and makes the file name the artifact name
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: compile-test-image.tar.zst
|
||||
- name: Load image
|
||||
|
||||
+147
-45
@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -81,8 +81,8 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
@@ -415,8 +415,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
@@ -438,7 +438,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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -477,7 +477,7 @@ jobs:
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: integration-test-artifacts-${{ matrix.bucket.name }}
|
||||
path: test_artifacts/
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
# script/update_integration_test_durations.py; only full matrix dev
|
||||
# runs produce usable data.
|
||||
if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-integration-${{ strategy.job-index }}
|
||||
path: junit-integration.xml
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
script/check_import_time.py --check --har importtime.har
|
||||
- name: Upload waterfall HAR
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: import-time-waterfall
|
||||
path: importtime.har
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
|
||||
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -680,7 +680,7 @@ jobs:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP8266
|
||||
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
|
||||
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA
|
||||
pio_cache_key: tidyesp8266
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 Arduino
|
||||
@@ -693,12 +693,16 @@ jobs:
|
||||
ignore_errors: false
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for RP2
|
||||
options: --environment rp2-tidy --grep USE_RP2
|
||||
options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER
|
||||
pio_cache_key: tidyrp2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for host
|
||||
options: --environment host-tidy --grep USE_HOST
|
||||
pio_cache_key: tidyhost
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for LibreTiny
|
||||
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
|
||||
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
|
||||
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
pio_cache_key: tidylibretiny
|
||||
|
||||
steps:
|
||||
@@ -707,6 +711,16 @@ jobs:
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
- name: Install apt packages (cached)
|
||||
if: matrix.pio_cache_key == 'tidyhost'
|
||||
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
||||
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
|
||||
# exactly.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
@@ -893,14 +907,20 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 4/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 5/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
@@ -983,14 +1003,14 @@ jobs:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 S3
|
||||
# yamllint disable-line rule:line-length
|
||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
|
||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD
|
||||
tidy_environment: esp32s3-idf-tidy
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 P4
|
||||
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
|
||||
# so their code paths differ -- lint them under the P4 build too.
|
||||
# yamllint disable-line rule:line-length
|
||||
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
||||
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
||||
tidy_environment: esp32p4-idf-tidy
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 C6
|
||||
@@ -1096,8 +1116,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -1329,6 +1349,91 @@ jobs:
|
||||
# config first, so a separate config pass is redundant.
|
||||
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
test-esp8266-native-windows:
|
||||
name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }})
|
||||
runs-on: windows-latest
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
||||
# A hung compiler must fail the job, not hold the runner for six hours.
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both ways the build runs on Windows: ESPHome Desktop bundles
|
||||
# ccache and puts it on PATH, a pip install has none.
|
||||
ccache: ["1", "0"]
|
||||
env:
|
||||
ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }}
|
||||
# The test script prints characters the cp1252 console cannot encode
|
||||
PYTHONUTF8: "1"
|
||||
# The exact static build ESPHome Desktop ships
|
||||
CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip
|
||||
CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f
|
||||
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: Install ccache
|
||||
if: matrix.ccache == '1'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dir = Join-Path $env:RUNNER_TEMP "ccache"
|
||||
New-Item -ItemType Directory -Force $dir | Out-Null
|
||||
$zip = Join-Path $dir "ccache.zip"
|
||||
Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($hash -ne $env:CCACHE_SHA256) {
|
||||
Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256"
|
||||
exit 1
|
||||
}
|
||||
Expand-Archive -Path $zip -DestinationPath $dir
|
||||
$bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName
|
||||
& $bin.FullName --version
|
||||
|
||||
- name: Compile
|
||||
shell: pwsh
|
||||
# One component test: web_server brings wifi, api and the async web
|
||||
# server libraries, the shape of a typical ESP8266 device
|
||||
run: |
|
||||
. .\venv\Scripts\Activate.ps1
|
||||
$arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests"
|
||||
$p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru
|
||||
# Reading the handle keeps ExitCode available after the process exits
|
||||
$null = $p.Handle
|
||||
if (-not $p.WaitForExit(25 * 60 * 1000)) {
|
||||
Write-Host "::error::The compile did not finish in 25 minutes; processes still running:"
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } |
|
||||
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
|
||||
Format-List | Out-String -Width 4096 | Write-Host
|
||||
Get-Process |
|
||||
Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } |
|
||||
Select-Object Id, Name, CPU, WorkingSet64 |
|
||||
Format-Table | Out-String | Write-Host
|
||||
taskkill /PID $p.Id /T /F
|
||||
exit 1
|
||||
}
|
||||
exit $p.ExitCode
|
||||
|
||||
- name: Show ccache statistics
|
||||
# Also after a failed or killed compile: the counters say whether
|
||||
# ccache was ever reached
|
||||
if: always() && matrix.ccache == '1'
|
||||
shell: pwsh
|
||||
# The build keeps its store under the esphome tools cache
|
||||
run: |
|
||||
$env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache"
|
||||
ccache -s
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1357,7 +1462,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -1416,31 +1521,27 @@ jobs:
|
||||
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
|
||||
fi
|
||||
|
||||
# Check if test files exist on the target branch for the requested
|
||||
# components and platform. When a PR adds new test files for a platform,
|
||||
# the target branch won't have them yet, so skip instead of failing.
|
||||
# This check must be done here (not in determine-jobs.py) because
|
||||
# determine-jobs runs on the PR branch and cannot see what the target
|
||||
# branch has.
|
||||
# The build below uses --base-only, so skip unless the target branch has
|
||||
# test.<platform>.yaml for every requested component (test-*.yaml
|
||||
# variants alone compile nothing, and a partial set would compare a
|
||||
# smaller target build against the PR build). Checked here, not in
|
||||
# determine-jobs.py, because determine-jobs runs on the PR branch and
|
||||
# cannot see the target branch.
|
||||
- name: Check for test files on target branch
|
||||
id: check-tests
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
run: |
|
||||
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
|
||||
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
|
||||
found=false
|
||||
missing=""
|
||||
for component in $(echo "$components" | jq -r '.[]'); do
|
||||
# Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml)
|
||||
for f in tests/components/${component}/test*.${platform}.yaml; do
|
||||
if [ -f "$f" ]; then
|
||||
found=true
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then
|
||||
missing="${missing} ${component}"
|
||||
fi
|
||||
done
|
||||
if [ "$found" = false ]; then
|
||||
if [ -n "$missing" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis"
|
||||
echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -1560,7 +1661,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: memory-analysis-target.json
|
||||
@@ -1623,7 +1724,7 @@ jobs:
|
||||
--platform "$platform"
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: memory-analysis-pr.json
|
||||
@@ -1653,13 +1754,13 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Download target analysis JSON
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: ./memory-analysis
|
||||
continue-on-error: true
|
||||
- name: Download PR analysis JSON
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: ./memory-analysis
|
||||
@@ -1703,6 +1804,7 @@ jobs:
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- test-esp8266-native
|
||||
- test-esp8266-native-windows
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
# version: ${{ needs.init.outputs.tag }}
|
||||
|
||||
- name: Upload digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: digests-${{ matrix.platform.arch }}
|
||||
path: /tmp/digests
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: digests-*
|
||||
path: /tmp/digests
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -16,16 +16,18 @@ jobs:
|
||||
# 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@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # 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-stale-conflicting: 30
|
||||
days-before-close: 7
|
||||
stale-label: stale
|
||||
exempt-label: not-stale
|
||||
conflict-label: needs-rebase
|
||||
ignored-users: esphbot,codecov-commenter
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
@@ -36,6 +38,18 @@ jobs:
|
||||
to keep it open. Also, please merge the latest dev branch into your
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
conflict-pr-message: >
|
||||
This pull request has merge conflicts. Please merge the latest dev
|
||||
branch into your branch to resolve them.
|
||||
stale-conflicting-pr-message: >
|
||||
The merge conflicts on this pull request have not been resolved for
|
||||
30 days, so it has been marked as stale and will be closed if they
|
||||
are not resolved within 7 days.
|
||||
|
||||
Please merge the latest dev branch into your branch. Once the pull
|
||||
request is mergeable again the stale label is removed.
|
||||
|
||||
Thank you for your contribution!
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.10
|
||||
rev: v0.17.0
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -36,7 +36,7 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
rev: v3.22.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
|
||||
@@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
|
||||
- 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
|
||||
@@ -143,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
* **C++ Preprocessor Directives:**
|
||||
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
|
||||
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
|
||||
and inside functions; `static const` is only for a value computed at run time. A function-local
|
||||
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
|
||||
* **Use `#define` only for:**
|
||||
- Conditional compilation (`#ifdef`, `#ifndef`)
|
||||
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
|
||||
@@ -177,6 +181,15 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
|
||||
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
|
||||
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
|
||||
* **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm
|
||||
rebuilds the callback and re-queues a scheduler item, and the period drifts.
|
||||
* **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a
|
||||
`static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other
|
||||
components, so number them from 0 and keep all of a component's ids together in one place.
|
||||
```cpp
|
||||
static constexpr uint32_t READ_TIMEOUT_ID = 0;
|
||||
this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); });
|
||||
```
|
||||
* **Don't override a default with the same value:** if a base class method already returns what you
|
||||
want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a
|
||||
component that wants `DATA` should simply leave it alone.
|
||||
@@ -184,6 +197,36 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
// Bad - this is exactly what the base class already does
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
```
|
||||
* **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope
|
||||
constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the
|
||||
table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`);
|
||||
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
|
||||
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
|
||||
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
|
||||
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
|
||||
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
|
||||
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
|
||||
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
|
||||
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
|
||||
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
|
||||
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
|
||||
stays inside the `#ifdef`.
|
||||
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
|
||||
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
|
||||
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
|
||||
runtime check of something the build already knows.
|
||||
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
|
||||
can be stored in flash rather than RAM.
|
||||
```cpp
|
||||
@@ -230,6 +273,10 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
```
|
||||
|
||||
* **Component Metadata:**
|
||||
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
|
||||
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: List of required components
|
||||
- `AUTO_LOAD`: Components to automatically load
|
||||
- `CONFLICTS_WITH`: Incompatible components
|
||||
@@ -243,6 +290,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
@@ -384,6 +432,25 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only
|
||||
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
|
||||
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
|
||||
`forward` from `MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
@@ -503,8 +570,12 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
|
||||
* **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`.
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **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)`.
|
||||
@@ -538,6 +609,18 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
|
||||
```
|
||||
tests/
|
||||
@@ -584,6 +667,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
name: My Sensor
|
||||
```
|
||||
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
|
||||
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
|
||||
+57
-3
@@ -39,12 +39,17 @@ esphome/components/am43/sensor/* @buxtronix
|
||||
esphome/components/analog_threshold/* @ianchi
|
||||
esphome/components/animation/* @syndlex
|
||||
esphome/components/anova/* @buxtronix
|
||||
esphome/components/apc1/* @ademuri
|
||||
esphome/components/apc_proteous/* @clydebarrow
|
||||
esphome/components/apds9306/* @aodrenah
|
||||
esphome/components/apds9930/* @iyesin
|
||||
esphome/components/api/* @esphome/core
|
||||
esphome/components/aqi/* @freekode @jasstrong @ximex
|
||||
esphome/components/aranet/* @patrickod
|
||||
esphome/components/as5600/* @ammmze
|
||||
esphome/components/as5600/sensor/* @ammmze
|
||||
esphome/components/as7341/* @mrgnr
|
||||
esphome/components/as734x/* @latonita @mrgnr
|
||||
esphome/components/async_tcp/* @esphome/core
|
||||
esphome/components/at581x/* @X-Ryl669
|
||||
esphome/components/atc_mithermometer/* @ahpohl
|
||||
@@ -65,6 +70,7 @@ esphome/components/bedjet/climate/* @jhansche
|
||||
esphome/components/bedjet/fan/* @jhansche
|
||||
esphome/components/bedjet/sensor/* @javawizard @jhansche
|
||||
esphome/components/beken_spi_led_strip/* @Mat931
|
||||
esphome/components/bh1745/* @latonita
|
||||
esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
@@ -100,6 +106,9 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
|
||||
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
|
||||
esphome/components/bp1658cj/* @Cossid
|
||||
esphome/components/bp5758d/* @Cossid
|
||||
esphome/components/bq25186/* @Rapsssito
|
||||
esphome/components/bq27220/* @rggammon
|
||||
esphome/components/bq76972/* @limpkin
|
||||
esphome/components/bridge/* @kbx81
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
esphome/components/button/* @esphome/core
|
||||
@@ -148,6 +157,7 @@ esphome/components/delonghi/* @grob6000
|
||||
esphome/components/dew_point/* @CFlix
|
||||
esphome/components/dfplayer/* @glmnet
|
||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||
esphome/components/dfrobot_sen0609/* @remcom
|
||||
esphome/components/dht/* @OttoWinter
|
||||
esphome/components/display_menu_base/* @numo68
|
||||
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||
@@ -156,8 +166,10 @@ esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds1603l/* @JakeLC15
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
esphome/components/ektf2232/touchscreen/* @jesserockz
|
||||
esphome/components/emc2101/* @ellull
|
||||
@@ -174,6 +186,7 @@ esphome/components/es7243e/* @kbx81
|
||||
esphome/components/es8156/* @kbx81
|
||||
esphome/components/es8311/* @kahrendt @kroimon
|
||||
esphome/components/es8388/* @P4uLT
|
||||
esphome/components/es9038q2m_katana/* @GioMez
|
||||
esphome/components/esp32/* @esphome/core
|
||||
esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito
|
||||
esphome/components/esp32_ble_client/* @bdraco @jesserockz
|
||||
@@ -183,11 +196,13 @@ esphome/components/esp32_camera_web_server/* @ayufan
|
||||
esphome/components/esp32_can/* @Sympatron
|
||||
esphome/components/esp32_hosted/* @swoboda1337
|
||||
esphome/components/esp32_hosted/update/* @swoboda1337
|
||||
esphome/components/esp32_power_management/* @rwrozelle
|
||||
esphome/components/esp32_rmt/* @jesserockz
|
||||
esphome/components/esp32_rmt_led_strip/* @jesserockz
|
||||
esphome/components/esp8266/* @esphome/core
|
||||
esphome/components/esp_ldo/* @clydebarrow
|
||||
esphome/components/espectre/* @francescopace
|
||||
esphome/components/esphome/ota/* @esphome/core
|
||||
esphome/components/espnow/* @jesserockz
|
||||
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
|
||||
esphome/components/ethernet_info/* @gtjadsonsantos
|
||||
@@ -196,15 +211,19 @@ esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/filter_lifetime/* @tuct
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/friedrich/* @rwrozelle
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
esphome/components/ft5x06/* @clydebarrow
|
||||
esphome/components/ft63x6/* @gpambrozio
|
||||
esphome/components/fusb302b/* @remcom
|
||||
esphome/components/gcja5/* @gcormier
|
||||
esphome/components/gdk101/* @Szewcson
|
||||
esphome/components/gl_r01_i2c/* @pkejval
|
||||
@@ -226,9 +245,13 @@ esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
esphome/components/haier/button/* @paveldn
|
||||
esphome/components/haier/select/* @paveldn
|
||||
esphome/components/haier/sensor/* @paveldn
|
||||
esphome/components/haier/switch/* @paveldn
|
||||
esphome/components/haier/text_sensor/* @paveldn
|
||||
esphome/components/haier_tundra/* @fauxpark
|
||||
esphome/components/haier_tundra/button/* @fauxpark
|
||||
esphome/components/haier_tundra/switch/* @fauxpark
|
||||
esphome/components/havells_solar/* @sourabhjaiswal
|
||||
esphome/components/hbridge/fan/* @WeekendWarrior
|
||||
esphome/components/hbridge/light/* @DotNetDann
|
||||
@@ -237,6 +260,7 @@ esphome/components/hc8/* @omartijn
|
||||
esphome/components/hdc2010/* @optimusprimespace @ssieb
|
||||
esphome/components/hdc2080/* @G-Pereira @jesserockz
|
||||
esphome/components/hdc302x/* @joshuasing
|
||||
esphome/components/hdmi_cec/* @JosVanEijndhoven
|
||||
esphome/components/he60r/* @clydebarrow
|
||||
esphome/components/heatpumpir/* @rob-deutsch
|
||||
esphome/components/hitachi_ac424/* @sourabhjaiswal
|
||||
@@ -263,6 +287,7 @@ esphome/components/http_request/ota/* @oarcher
|
||||
esphome/components/http_request/update/* @jesserockz
|
||||
esphome/components/htu31d/* @betterengineering
|
||||
esphome/components/hub75/* @stuartparmenter
|
||||
esphome/components/husb238/* @latonita
|
||||
esphome/components/hydreon_rgxx/* @functionpointer
|
||||
esphome/components/hyt271/* @Philippe12
|
||||
esphome/components/i2c/* @esphome/core
|
||||
@@ -270,7 +295,9 @@ esphome/components/i2c_device/* @gabest11
|
||||
esphome/components/i2s_audio/* @jesserockz
|
||||
esphome/components/i2s_audio/microphone/* @jesserockz
|
||||
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/i2s_clockless_led_strip/* @j9brown
|
||||
esphome/components/iaqcore/* @yozik04
|
||||
esphome/components/icm20689/* @lboue
|
||||
esphome/components/icnt86/* @danepowell
|
||||
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
|
||||
esphome/components/improv_base/* @esphome/core
|
||||
@@ -299,9 +326,11 @@ esphome/components/kuntze/* @ssieb
|
||||
esphome/components/lc709203f/* @ilikecake
|
||||
esphome/components/lcd_menu/* @numo68
|
||||
esphome/components/ld2410/* @regevbr @sebcaps
|
||||
esphome/components/ld2410s/* @NovakIrs
|
||||
esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld2460/* @jesusvallejo
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
@@ -310,6 +339,10 @@ esphome/components/libretiny_pwm/* @kuba2k2
|
||||
esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lis2dw12/* @latonita
|
||||
esphome/components/lis2dw12_i2c/* @latonita
|
||||
esphome/components/lis2dw12_spi/* @latonita
|
||||
esphome/components/lis3dh/* @zebble
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
@@ -317,6 +350,7 @@ esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
esphome/components/logger/select/* @clydebarrow
|
||||
esphome/components/lp5562/* @DennisGaida
|
||||
esphome/components/lps22/* @nagisa
|
||||
esphome/components/lsm6ds/* @clydebarrow
|
||||
esphome/components/ltr390/* @latonita @sjtrny
|
||||
@@ -327,7 +361,9 @@ esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
esphome/components/max6956/* @looping40
|
||||
esphome/components/max7219digit/* @rspaargaren
|
||||
@@ -346,7 +382,6 @@ esphome/components/mcp4461/* @p1ngb4ck
|
||||
esphome/components/mcp4728/* @berfenger
|
||||
esphome/components/mcp47a1/* @jesserockz
|
||||
esphome/components/mcp9600/* @mreditor97
|
||||
esphome/components/mcp9808/* @k7hpn
|
||||
esphome/components/md5/* @esphome/core
|
||||
esphome/components/mdns/* @esphome/core
|
||||
esphome/components/media_player/* @jesserockz
|
||||
@@ -357,9 +392,11 @@ esphome/components/microphone/* @jesserockz @kahrendt
|
||||
esphome/components/mics_4514/* @jesserockz
|
||||
esphome/components/midea/* @dudanov
|
||||
esphome/components/midea_ir/* @dudanov
|
||||
esphome/components/midea_portasplit/* @Fexiven
|
||||
esphome/components/mipi_dsi/* @clydebarrow
|
||||
esphome/components/mipi_rgb/* @clydebarrow
|
||||
esphome/components/mipi_spi/* @clydebarrow
|
||||
esphome/components/mistral_ir/* @lerhum
|
||||
esphome/components/mitsubishi/* @RubyBailey
|
||||
esphome/components/mitsubishi_cn105/* @crnjan
|
||||
esphome/components/mixer/speaker/* @kahrendt
|
||||
@@ -398,6 +435,8 @@ esphome/components/noblex/* @AGalfra
|
||||
esphome/components/noise/* @esphome/core
|
||||
esphome/components/npi19/* @bakerkj
|
||||
esphome/components/nrf52/* @tomaszduda23
|
||||
esphome/components/ns2009/* @tchilov
|
||||
esphome/components/ns2009/touchscreen/* @tchilov
|
||||
esphome/components/number/* @esphome/core
|
||||
esphome/components/one_wire/* @ssieb
|
||||
esphome/components/online_image/* @clydebarrow @guillempages
|
||||
@@ -408,10 +447,12 @@ esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
|
||||
esphome/components/pcf85063/* @brogon
|
||||
esphome/components/pcf8563/* @KoenBreeman
|
||||
esphome/components/pcm5122/* @remcom
|
||||
esphome/components/pd_pioneer_ir/* @martinsah
|
||||
esphome/components/pi4ioe5v6408/* @jesserockz
|
||||
esphome/components/pid/* @OttoWinter
|
||||
esphome/components/pipsolar/* @andreashergert1984
|
||||
@@ -430,6 +471,7 @@ esphome/components/pn7160/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_spi/* @jesserockz @kbx81
|
||||
esphome/components/pn71xx/* @jesserockz @kbx81
|
||||
esphome/components/power_management/* @rwrozelle
|
||||
esphome/components/power_supply/* @esphome/core
|
||||
esphome/components/preferences/* @esphome/core
|
||||
esphome/components/provisioning/* @esphome/core
|
||||
@@ -450,6 +492,8 @@ esphome/components/rc522/* @glmnet
|
||||
esphome/components/rc522_i2c/* @glmnet
|
||||
esphome/components/rc522_spi/* @glmnet
|
||||
esphome/components/rd03d/* @jasstrong
|
||||
esphome/components/remko_ar715/* @naKruul
|
||||
esphome/components/renogy_inverter_ble/* @emilioaray-dev
|
||||
esphome/components/resampler/speaker/* @kahrendt
|
||||
esphome/components/restart/* @esphome/core
|
||||
esphome/components/rf_bridge/* @jesserockz
|
||||
@@ -467,8 +511,10 @@ esphome/components/rtttl/* @glmnet @ximex
|
||||
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
|
||||
esphome/components/runtime_stats/* @bdraco
|
||||
esphome/components/rx8025t/* @remcom
|
||||
esphome/components/rx8111/* @techdregs
|
||||
esphome/components/rx8130/* @beormund
|
||||
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
|
||||
esphome/components/samsung/* @jorofi
|
||||
esphome/components/scd4x/* @martgras @sjtrny
|
||||
esphome/components/script/* @esphome/core
|
||||
esphome/components/sdl/* @bdm310 @clydebarrow
|
||||
@@ -497,11 +543,14 @@ esphome/components/sfa30/* @ghsensdev
|
||||
esphome/components/sfa40/* @NoQuarrel
|
||||
esphome/components/sgp40/* @SenexCrenshaw
|
||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||
esphome/components/sh1122_base/* @pl4nkton
|
||||
esphome/components/sh1122_spi/* @pl4nkton
|
||||
esphome/components/sha256/* @esphome/core
|
||||
esphome/components/shelly_dimmer/* @edge90 @rnauber
|
||||
esphome/components/sht3xd/* @mrtoy-me
|
||||
esphome/components/sht4x/* @sjtrny
|
||||
esphome/components/shutdown/* @esphome/core @jsuanet
|
||||
esphome/components/siemens_ira211/* @fonix232
|
||||
esphome/components/sigma_delta_output/* @Cat-Ion
|
||||
esphome/components/sim800l/* @glmnet
|
||||
esphome/components/sm10bit_base/* @Cossid
|
||||
@@ -518,6 +567,7 @@ esphome/components/sound_level/* @kahrendt
|
||||
esphome/components/spa06_base/* @danielkent-net
|
||||
esphome/components/spa06_i2c/* @danielkent-net
|
||||
esphome/components/spa06_spi/* @danielkent-net
|
||||
esphome/components/spd2010/* @grischard
|
||||
esphome/components/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
|
||||
esphome/components/speaker_source/* @kahrendt
|
||||
@@ -563,12 +613,13 @@ esphome/components/t6615/* @tylermenezes
|
||||
esphome/components/tas2780/* @remcom
|
||||
esphome/components/tas58xx/* @mrtoy-me @remcom
|
||||
esphome/components/tc74/* @sethgirvan
|
||||
esphome/components/tca8418/* @zebble
|
||||
esphome/components/tca9548a/* @andreashergert1984
|
||||
esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax
|
||||
esphome/components/teleinfo/* @0hax @FredM67
|
||||
esphome/components/tem3200/* @bakerkj
|
||||
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
|
||||
esphome/components/template/datetime/* @rfdarter
|
||||
@@ -580,12 +631,15 @@ esphome/components/thermopro_ble/* @sittner
|
||||
esphome/components/thermostat/* @kbx81
|
||||
esphome/components/time/* @esphome/core
|
||||
esphome/components/tinyusb/* @kbx81
|
||||
esphome/components/tinyusb_keyboard/* @frederikbolding
|
||||
esphome/components/tlc5947/* @rnauber
|
||||
esphome/components/tlc5971/* @IJIJI
|
||||
esphome/components/tm1621/* @Philippe12
|
||||
esphome/components/tm1637/* @glmnet
|
||||
esphome/components/tm1638/* @skykingjwc
|
||||
esphome/components/tm1651/* @mrtoy-me
|
||||
esphome/components/tmc2209/* @remcom
|
||||
esphome/components/tmc22xx/* @remcom
|
||||
esphome/components/tmp102/* @timsavage
|
||||
esphome/components/tmp1075/* @sybrenstuvel
|
||||
esphome/components/tmp117/* @Azimath
|
||||
@@ -637,7 +691,7 @@ esphome/components/web_server_idf/* @dentra
|
||||
esphome/components/weikai/* @DrCoolZic
|
||||
esphome/components/weikai_i2c/* @DrCoolZic
|
||||
esphome/components/weikai_spi/* @DrCoolZic
|
||||
esphome/components/whirlpool/* @glmnet
|
||||
esphome/components/whirlpool/* @arpena @glmnet
|
||||
esphome/components/whynter/* @aeonsablaze
|
||||
esphome/components/wiegand/* @ssieb
|
||||
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
|
||||
|
||||
+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.21.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+221
-31
@@ -7,6 +7,7 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -47,14 +48,18 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
PLATFORM_HOST,
|
||||
SECRETS_FILES,
|
||||
ErrorFormat,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
@@ -68,6 +73,7 @@ from esphome.util import (
|
||||
get_serial_ports,
|
||||
is_picotool_usb_permission_error,
|
||||
list_yaml_files,
|
||||
read_secret_line,
|
||||
run_external_command,
|
||||
run_external_process,
|
||||
safe_print,
|
||||
@@ -477,12 +483,9 @@ def has_ota() -> bool:
|
||||
|
||||
def has_native_ota() -> bool:
|
||||
"""Check if native API OTA upload is available (``platform: esphome``)."""
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
for ota_item in CORE.config[CONF_OTA]
|
||||
)
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return bool(esphome_ota_items(CORE.config))
|
||||
|
||||
|
||||
def has_web_server_ota() -> bool:
|
||||
@@ -1230,14 +1233,28 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
port_type = get_port_type(host)
|
||||
ota_key = getattr(args, "ota_key", None)
|
||||
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to network uploads; ignored for %s",
|
||||
host,
|
||||
)
|
||||
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
|
||||
# A platform's own network path (nrf52 mcumgr) has no key handshake
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to the %s OTA platform; "
|
||||
"ignored for %s",
|
||||
CONF_ESPHOME,
|
||||
host,
|
||||
)
|
||||
return 0, host
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
|
||||
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
|
||||
is_partition_table = getattr(args, "partition_table", False)
|
||||
@@ -1280,6 +1297,8 @@ def upload_program(
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
|
||||
if chosen_platform == CONF_WEB_SERVER:
|
||||
if ota_key:
|
||||
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
|
||||
if is_partition_table or is_bootloader:
|
||||
raise EsphomeError(
|
||||
f"{option_string} is only supported with the esphome OTA platform; "
|
||||
@@ -1293,6 +1312,17 @@ def upload_program(
|
||||
return _upload_via_native_api(config, network_devices, args)
|
||||
|
||||
|
||||
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
|
||||
"""The network OTA platforms the config provides, one entry each, in
|
||||
config order; the web_server final-validate hook merges duplicates."""
|
||||
return {
|
||||
ota_platform: None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
if (ota_platform := ota_item.get(CONF_PLATFORM))
|
||||
in (CONF_ESPHOME, CONF_WEB_SERVER)
|
||||
}
|
||||
|
||||
|
||||
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
|
||||
|
||||
@@ -1305,16 +1335,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
# membership checks see one entry per platform even if the user has
|
||||
# multiple ``ota:`` items of the same platform; the web_server OTA
|
||||
# platform's final-validate hook merges duplicates anyway.
|
||||
available: dict[str, None] = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
platform = ota_item.get(CONF_PLATFORM)
|
||||
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[platform] = None
|
||||
|
||||
available = _ota_upload_platforms(config)
|
||||
if not available:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
|
||||
@@ -1335,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
return CONF_WEB_SERVER
|
||||
|
||||
|
||||
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
|
||||
"""The validated esphome OTA platform block, if configured."""
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return next(iter(esphome_ota_items(config)), None)
|
||||
|
||||
|
||||
def _upload_via_native_api(
|
||||
config: ConfigType, network_devices: list[str], args: ArgsProtocol
|
||||
) -> tuple[int, str | None]:
|
||||
ota_conf: ConfigType = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
|
||||
ota_conf = ota_item
|
||||
break
|
||||
ota_conf = _esphome_ota_conf(config) or {}
|
||||
|
||||
from esphome import espota2
|
||||
from esphome.components.noise import static_encryption_key
|
||||
@@ -1352,9 +1376,15 @@ def _upload_via_native_api(
|
||||
# Fail closed: an encryption block whose key did not resolve must never
|
||||
# fall back to a plaintext upload
|
||||
noise_psk = None
|
||||
old_noise_psk = None
|
||||
plaintext_fallback = False
|
||||
allow_plaintext_upload = False
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
if ota_key := getattr(args, "ota_key", None):
|
||||
# The user presents a key the device still runs instead of the
|
||||
# configured one (a key change in progress); an explicit key never
|
||||
# downgrades to plaintext
|
||||
noise_psk = ota_key
|
||||
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
noise_psk = encryption_conf.get(CONF_KEY)
|
||||
allow_plaintext_upload = bool(
|
||||
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
|
||||
@@ -1367,6 +1397,8 @@ def _upload_via_native_api(
|
||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||
noise_psk = str(noise_psk)
|
||||
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
|
||||
old_noise_psk = str(old_key)
|
||||
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
|
||||
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
|
||||
noise_psk = str(api_key)
|
||||
@@ -1416,19 +1448,30 @@ def _upload_via_native_api(
|
||||
noise_psk,
|
||||
plaintext_fallback=plaintext_fallback,
|
||||
allow_plaintext_upload=allow_plaintext_upload,
|
||||
old_noise_psk=old_noise_psk,
|
||||
)
|
||||
|
||||
|
||||
def _old_key_notice(config: ConfigType) -> None:
|
||||
"""After `run`, whose image was just built from this config; a plain
|
||||
`upload` may send a stale image that still carries the previous key."""
|
||||
from esphome import espota2
|
||||
|
||||
for item in espota2.esphome_ota_items(config):
|
||||
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
|
||||
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
|
||||
|
||||
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
from esphome import web_server_ota
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
if any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in esphome_ota_items(config)
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"This config has OTA encryption, but the web_server OTA path sends "
|
||||
@@ -1736,7 +1779,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return 0
|
||||
|
||||
|
||||
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
|
||||
OTA_KEY_PLATFORM_ERROR = (
|
||||
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
|
||||
"platform; to flash by serial instead, name the port with --device"
|
||||
)
|
||||
|
||||
|
||||
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
|
||||
"""Take the key to present to the device for this upload, from the
|
||||
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
|
||||
not buried in build output."""
|
||||
prompt = getattr(args, "prompt_ota_key", False)
|
||||
env_key = getattr(args, "env_ota_key", None)
|
||||
if not prompt and env_key is None:
|
||||
return
|
||||
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
|
||||
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
|
||||
return
|
||||
# The HTTP path has no key handshake; decide before prompting or
|
||||
# compiling. A serial target is a serial flash: the key is not even
|
||||
# asked for, and --ota-platform means nothing to it. Every entry must
|
||||
# be serial: a missing port is skipped and the next entry may be a host
|
||||
devices = getattr(args, "device", None) or []
|
||||
serial_target = bool(devices) and all(
|
||||
device == "SERIAL"
|
||||
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
|
||||
for device in devices
|
||||
)
|
||||
chosen = (
|
||||
_choose_ota_platform(config, getattr(args, "ota_platform", None))
|
||||
if not serial_target and _ota_upload_platforms(config)
|
||||
else None
|
||||
)
|
||||
if chosen == CONF_WEB_SERVER:
|
||||
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
|
||||
from esphome.components.noise import validate_encryption_key
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
if serial_target:
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to network uploads; ignored for %s",
|
||||
", ".join(devices),
|
||||
)
|
||||
return
|
||||
key = read_secret_line("OTA encryption key: ") if prompt else env_key
|
||||
try:
|
||||
validate_encryption_key(key)
|
||||
except Invalid as err:
|
||||
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
|
||||
args.ota_key = key
|
||||
|
||||
|
||||
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
_read_ota_key(args, config)
|
||||
# Get devices, resolving special identifiers like OTA
|
||||
devices = choose_upload_log_host(
|
||||
default=args.device,
|
||||
@@ -1784,6 +1880,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# Elsewhere the flag is ignored, so serial stays fine there.
|
||||
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
|
||||
return 1
|
||||
_read_ota_key(args, config)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
@@ -1809,6 +1906,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
exit_code, successful_device = upload_program(config, args, devices)
|
||||
if exit_code == 0:
|
||||
_LOGGER.info("Successfully uploaded program.")
|
||||
_old_key_notice(config)
|
||||
else:
|
||||
_LOGGER.warning("Failed to upload to %s", devices)
|
||||
return exit_code
|
||||
@@ -1945,7 +2043,10 @@ def run_multiple_configs(
|
||||
safe_print()
|
||||
|
||||
cmd = command_builder(f)
|
||||
rc = run_external_process(*cmd)
|
||||
# The parent already logged the Intel macOS warning; children skip it.
|
||||
rc = run_external_process(
|
||||
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
|
||||
@@ -2201,6 +2302,25 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _error_format(value: str) -> ErrorFormat:
|
||||
try:
|
||||
return ErrorFormat(value.lower())
|
||||
except ValueError:
|
||||
choices = ", ".join(ErrorFormat)
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"invalid choice: '{value}' (choose from {choices})"
|
||||
) from None
|
||||
|
||||
|
||||
ARGUMENT_HELP_PROMPT_OTA_KEY = (
|
||||
"Ask for the OTA encryption key to present to the device instead of using "
|
||||
"the one in the configuration, for a device that still runs a previous key. "
|
||||
"The key is read without echo, or as one line from stdin when that is not "
|
||||
"a terminal; it is never taken from the command line. A calling program "
|
||||
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
options_parser = argparse.ArgumentParser(add_help=False)
|
||||
options_parser.add_argument(
|
||||
@@ -2263,6 +2383,17 @@ def parse_args(argv):
|
||||
),
|
||||
)
|
||||
|
||||
options_parser.add_argument(
|
||||
"--error-format",
|
||||
type=_error_format,
|
||||
metavar="{" + ",".join(f.value for f in ErrorFormat) + "}",
|
||||
help=(
|
||||
"How configuration errors are shown. 'yaml' shows them inside the "
|
||||
"failing YAML; 'line' prints one 'file:line:column: error: message' "
|
||||
"line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'."
|
||||
),
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=f"ESPHome {const.__version__}", parents=[options_parser]
|
||||
)
|
||||
@@ -2358,6 +2489,11 @@ def parse_args(argv):
|
||||
help="Upload as partition table (OTA).",
|
||||
action="store_true",
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--prompt-ota-key",
|
||||
action="store_true",
|
||||
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--bootloader",
|
||||
help="Upload as bootloader (OTA).",
|
||||
@@ -2438,6 +2574,11 @@ def parse_args(argv):
|
||||
"(HTTP Basic auth) when that is the only configured platform."
|
||||
),
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--prompt-ota-key",
|
||||
action="store_true",
|
||||
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
|
||||
)
|
||||
|
||||
parser_clean = subparsers.add_parser(
|
||||
"clean-mqtt",
|
||||
@@ -2566,9 +2707,18 @@ def parse_args(argv):
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
if unknown_args:
|
||||
_LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args)
|
||||
return args
|
||||
else:
|
||||
args = parser.parse_args(arguments)
|
||||
|
||||
return parser.parse_args(arguments)
|
||||
# Read here rather than as the option default, so a bad value is reported
|
||||
# against the environment variable instead of --error-format
|
||||
if args.error_format is None:
|
||||
env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value)
|
||||
try:
|
||||
args.error_format = _error_format(env_value)
|
||||
except argparse.ArgumentTypeError as err:
|
||||
parser.error(f"ESPHOME_ERROR_FORMAT: {err}")
|
||||
return args
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
@@ -2614,12 +2764,34 @@ def _warn_if_source_tree_mismatch() -> None:
|
||||
)
|
||||
|
||||
|
||||
_INTEL_MACOS_REMOVAL = "2027.6.0"
|
||||
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
|
||||
|
||||
|
||||
def _warn_if_intel_macos() -> None:
|
||||
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
|
||||
if (
|
||||
not IS_MACOS
|
||||
or platform.machine() != "x86_64"
|
||||
or _INTEL_MACOS_WARNED_ENV in os.environ
|
||||
):
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
|
||||
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
|
||||
"so future releases will not install on this machine. On an Apple "
|
||||
"Silicon Mac, switch to a native arm64 Python.",
|
||||
_INTEL_MACOS_REMOVAL,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
args = parse_args(argv)
|
||||
CORE.dashboard = args.dashboard
|
||||
CORE.testing_mode = args.testing_mode
|
||||
CORE.error_format = args.error_format
|
||||
|
||||
# Create address cache from command-line arguments
|
||||
CORE.address_cache = AddressCache.from_cli_args(
|
||||
@@ -2632,7 +2804,25 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
# Taken out of the environment before any command runs, so no build
|
||||
# tool inherits a key a calling program handed over
|
||||
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
|
||||
if args.command in ("upload", "run"):
|
||||
# One key is one device's; several configs run in child processes
|
||||
# that would never see it
|
||||
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
|
||||
args.configuration
|
||||
) > 1:
|
||||
raise EsphomeError(
|
||||
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
|
||||
f"{len(args.configuration)} were given"
|
||||
)
|
||||
elif args.env_ota_key is not None:
|
||||
_LOGGER.warning(
|
||||
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
|
||||
)
|
||||
_warn_if_source_tree_mismatch()
|
||||
_warn_if_intel_macos()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
|
||||
@@ -49,40 +49,40 @@ class FrameworkRelease(NamedTuple):
|
||||
# Arduino core version -> its build in esphome-libs/arduino-esp8266
|
||||
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
|
||||
Version(3, 1, 2): FrameworkRelease(
|
||||
"3.1.2-esphome.1",
|
||||
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
|
||||
37189311,
|
||||
"3.1.2-esphome.3",
|
||||
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
|
||||
37185650,
|
||||
),
|
||||
}
|
||||
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "10.3.0-esphome.2"
|
||||
TOOLCHAIN_VERSION = "10.3.0-esphome.3"
|
||||
_TOOLCHAIN_RELEASES = (
|
||||
"https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/"
|
||||
)
|
||||
# Registry system tag -> (sha256, size) of that host's archive
|
||||
TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = {
|
||||
"darwin_arm64": (
|
||||
"849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201",
|
||||
60830321,
|
||||
"ad0ea929238d9b527c43e0d20257acc2a5680bdb0d2b1cf8f2c99169786217e9",
|
||||
60747260,
|
||||
),
|
||||
"darwin_x86_64": (
|
||||
"ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897",
|
||||
64149487,
|
||||
"997090cac80c8eb59604ec52b73fb3568e5f2d9b2971f83c3f2a5c545c9c949c",
|
||||
64065752,
|
||||
),
|
||||
"linux_aarch64": (
|
||||
"60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6",
|
||||
67573397,
|
||||
"403f14d0d5755682fcd6eaea1e4c457a1d8cad30690e52fb0c804d8db266b908",
|
||||
67487067,
|
||||
),
|
||||
"linux_x86_64": (
|
||||
"1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4",
|
||||
68431336,
|
||||
"64f21fa3ba736b363dc8c234dbbb39e005e574e33de08a78e9a00c55a64ae706",
|
||||
68343668,
|
||||
),
|
||||
"windows_amd64": (
|
||||
"af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b",
|
||||
67664137,
|
||||
"1e62abf8df041ce5158f5770dffef955f2f981ce7f7adee88fcd5a8197705aa6",
|
||||
67536405,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+309
-109
@@ -1,4 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import string
|
||||
@@ -23,7 +23,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
|
||||
from esphome.cpp_generator import (
|
||||
FlashStringLiteral,
|
||||
Expression,
|
||||
LambdaExpression,
|
||||
MockObj,
|
||||
MockObjClass,
|
||||
@@ -245,11 +245,26 @@ ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition)
|
||||
|
||||
def flash_string(config: ConfigType, value: str) -> str:
|
||||
"""Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266."""
|
||||
return str(cg.progmem_string(value))
|
||||
|
||||
|
||||
def flash_compare(config: ConfigType, value: str) -> str:
|
||||
"""Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266."""
|
||||
if CORE.is_esp8266:
|
||||
return f"progmem_string({FlashStringLiteral(value)})"
|
||||
return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}"
|
||||
return str(cg.safe_exp(value))
|
||||
|
||||
|
||||
def _default_std_string(
|
||||
members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str]
|
||||
) -> list[tuple[str, Any, Any]]:
|
||||
"""Give ``cg.std_string`` members without a renderer the given default."""
|
||||
return [
|
||||
(key, t, fn or (renderer if t is cg.std_string else None))
|
||||
for key, t, fn in members
|
||||
]
|
||||
|
||||
|
||||
def literal_with_length(config: ConfigType, value: str) -> str:
|
||||
"""Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length.
|
||||
|
||||
@@ -259,13 +274,24 @@ def literal_with_length(config: ConfigType, value: str) -> str:
|
||||
return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}"
|
||||
|
||||
|
||||
def string_ref_literal(config: ConfigType, value: str) -> str:
|
||||
"""Renderer for a ``StringRef`` comparison: a flash literal on ESP8266, else ``StringRef(literal, length)``."""
|
||||
if CORE.is_esp8266:
|
||||
return str(cg.FlashStringLiteral(value))
|
||||
return f"StringRef({literal_with_length(config, value)})"
|
||||
|
||||
|
||||
def _names_parent(text: str) -> bool:
|
||||
return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyCall:
|
||||
"""One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``.
|
||||
|
||||
Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a
|
||||
path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use
|
||||
``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an
|
||||
path into nested sections. ``target`` and a plain ``str`` ``type_`` (raw C++ type text) may
|
||||
name the parent object as ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an
|
||||
id bypasses it. The statement is skipped when none of its keys is set, always emitted when it
|
||||
has no keys, and a partial set is a config error.
|
||||
"""
|
||||
@@ -277,13 +303,13 @@ class ApplyCall:
|
||||
fields = [
|
||||
f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None
|
||||
]
|
||||
if any(fields):
|
||||
if any(f not in ("", "parent") for f in fields):
|
||||
raise ValueError(
|
||||
f"apply target {self.target!r}: only bare {{}} placeholders"
|
||||
f"apply target {self.target!r}: only {{}} and {{parent}} placeholders"
|
||||
)
|
||||
if len(fields) != len(self.args):
|
||||
if (count := fields.count("")) != len(self.args):
|
||||
raise ValueError(
|
||||
f"apply target {self.target!r} has {len(fields)} "
|
||||
f"apply target {self.target!r} has {count} "
|
||||
f"placeholder(s) for {len(self.args)} config key(s)"
|
||||
)
|
||||
if any(len(arg) not in (2, 3) for arg in self.args):
|
||||
@@ -291,6 +317,12 @@ class ApplyCall:
|
||||
f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])"
|
||||
)
|
||||
|
||||
@property
|
||||
def names_parent(self) -> bool:
|
||||
return _names_parent(self.target) or any(
|
||||
isinstance(arg[1], str) and _names_parent(arg[1]) for arg in self.args
|
||||
)
|
||||
|
||||
@property
|
||||
def members(self) -> list[tuple[Any, Any, Any]]:
|
||||
"""Each arg as ``(conf_key, type_, const_fn or None)``."""
|
||||
@@ -365,9 +397,16 @@ def _check_key_in_schema(
|
||||
schema = schema.schema[markers[part]]
|
||||
|
||||
|
||||
def parent_ref(var: MockObj) -> MockObj:
|
||||
"""``var`` named from global scope, so a trigger argument cannot shadow it.
|
||||
|
||||
Also how a generated callback names its Automation.
|
||||
"""
|
||||
return MockObj(f"::{var}", "->")
|
||||
|
||||
|
||||
async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str:
|
||||
# Global-scope qualified so a trigger arg named like the id cannot shadow it.
|
||||
return f"::{await cg.get_variable(config[id_key])}"
|
||||
return str(parent_ref(await cg.get_variable(config[id_key])))
|
||||
|
||||
|
||||
def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType:
|
||||
@@ -399,7 +438,7 @@ async def _render_values(
|
||||
members: list[tuple[Any, Any, Any]],
|
||||
values: list[Any],
|
||||
config: ConfigType,
|
||||
parent: str,
|
||||
parent: str | None,
|
||||
lambda_args: TemplateArgsType,
|
||||
compare: bool = False,
|
||||
) -> list[str]:
|
||||
@@ -443,6 +482,22 @@ def register_apply_action(
|
||||
) -> None:
|
||||
"""Register an action that only forwards config values to its parent, with no C++ class.
|
||||
|
||||
See ``apply_action_builder``.
|
||||
"""
|
||||
register_action(name, ApplyAction, schema, synchronous=True)(
|
||||
apply_action_builder(name, schema, *fields, call=call, id_key=id_key)
|
||||
)
|
||||
|
||||
|
||||
def apply_action_builder(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
*fields: ApplyField | ApplyCall,
|
||||
call: str | None = None,
|
||||
id_key: str = CONF_ID,
|
||||
) -> Callable[..., Awaitable[MockObj]]:
|
||||
"""The builder ``register_apply_action`` registers, for a caller that wraps it.
|
||||
|
||||
Generates one static function with the parent (read from ``id_key``) and constants baked
|
||||
in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it.
|
||||
A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names.
|
||||
@@ -453,10 +508,7 @@ def register_apply_action(
|
||||
statements_spec = [
|
||||
(
|
||||
c.target,
|
||||
[
|
||||
(key, t, fn or (flash_string if t is cg.std_string else None))
|
||||
for key, t, fn in c.members
|
||||
],
|
||||
_default_std_string(c.members, flash_string),
|
||||
)
|
||||
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
|
||||
]
|
||||
@@ -477,12 +529,12 @@ def register_apply_action(
|
||||
statements: list[str] = []
|
||||
for target, members in statements_spec:
|
||||
values = _apply_values(config, members)
|
||||
if members and all(value is None for value in values):
|
||||
if not _apply_call_active(members, values):
|
||||
continue
|
||||
exprs = await _render_values(
|
||||
name, target, members, values, config, parent, lambda_args
|
||||
)
|
||||
statements.append(f"{receiver}{target.format(*exprs)};")
|
||||
statements.append(f"{receiver}{target.format(*exprs, parent=parent)};")
|
||||
if call:
|
||||
statements = [
|
||||
f"auto apply_call = {parent}->{call}();",
|
||||
@@ -493,7 +545,28 @@ def register_apply_action(
|
||||
action_id, cg.void, template_arg, lambda_args, statements
|
||||
)
|
||||
|
||||
register_action(name, ApplyAction, schema, synchronous=True)(builder)
|
||||
return builder
|
||||
|
||||
|
||||
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
|
||||
"""An ``ApplyCall`` is emitted unless it has keys and none of them is set."""
|
||||
return not members or any(value is not None for value in values)
|
||||
|
||||
|
||||
async def _render_check(
|
||||
name: str,
|
||||
target: str,
|
||||
members: list[tuple[Any, Any, Any]],
|
||||
values: list[Any],
|
||||
config: ConfigType,
|
||||
parent: str | None,
|
||||
lambda_args: TemplateArgsType,
|
||||
) -> str:
|
||||
"""Render a boolean check with ``values`` compared against config."""
|
||||
exprs = await _render_values(
|
||||
name, target, members, values, config, parent, lambda_args, compare=True
|
||||
)
|
||||
return target.format(*exprs, parent=parent)
|
||||
|
||||
|
||||
def register_apply_condition(
|
||||
@@ -504,11 +577,12 @@ def register_apply_condition(
|
||||
``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an
|
||||
``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares
|
||||
against config values, all of which must be present. Write ``== false`` to negate.
|
||||
String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member.
|
||||
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
|
||||
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
|
||||
Generates one static predicate and an ``ApplyCondition`` templated on it.
|
||||
"""
|
||||
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
|
||||
members = call.members
|
||||
members = _default_std_string(call.members, flash_compare)
|
||||
_check_key_in_schema(name, schema, id_key)
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
@@ -521,22 +595,16 @@ def register_apply_condition(
|
||||
) -> MockObj:
|
||||
parent = await _apply_parent(config, id_key)
|
||||
lambda_args = _apply_lambda_args(args)
|
||||
exprs = await _render_values(
|
||||
name,
|
||||
call.target,
|
||||
members,
|
||||
_apply_values(config, members),
|
||||
config,
|
||||
parent,
|
||||
lambda_args,
|
||||
compare=True,
|
||||
values = _apply_values(config, members)
|
||||
check = await _render_check(
|
||||
name, call.target, members, values, config, parent, lambda_args
|
||||
)
|
||||
return _apply_function(
|
||||
condition_id,
|
||||
cg.bool_,
|
||||
template_arg,
|
||||
lambda_args,
|
||||
[f"return {parent}->{call.target.format(*exprs)};"],
|
||||
[f"return {parent}->{check};"],
|
||||
)
|
||||
|
||||
register_condition(name, ApplyCondition, schema)(builder)
|
||||
@@ -672,54 +740,22 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
|
||||
|
||||
|
||||
@register_condition("and", AndCondition, validate_condition_list)
|
||||
async def and_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
async def or_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("all", AndCondition, validate_condition_list)
|
||||
async def all_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("or", OrCondition, validate_condition_list)
|
||||
@register_condition("any", OrCondition, validate_condition_list)
|
||||
async def any_condition_to_code(
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def condition_group_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
# A group of one is that condition for and, or and xor; skip the wrapper.
|
||||
if len(conditions) == 1:
|
||||
return conditions[0]
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions
|
||||
)
|
||||
|
||||
|
||||
@@ -734,19 +770,6 @@ async def not_condition_to_code(
|
||||
return cg.new_Pvariable(condition_id, template_arg, condition)
|
||||
|
||||
|
||||
@register_condition("xor", XorCondition, validate_condition_list)
|
||||
async def xor_condition_to_code(
|
||||
config: ConfigType,
|
||||
condition_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
conditions = await build_condition_list(config, template_arg, args)
|
||||
return cg.new_Pvariable(
|
||||
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
|
||||
)
|
||||
|
||||
|
||||
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
|
||||
async def lambda_condition_to_code(
|
||||
config: ConfigType,
|
||||
@@ -848,11 +871,13 @@ async def if_action_to_code(
|
||||
condition = await build_condition(config[cond_conf], template_arg, args)
|
||||
var = cg.new_Pvariable(action_id, if_template_arg, condition)
|
||||
if CONF_THEN in config:
|
||||
actions = await build_action_list(config[CONF_THEN], template_arg, args)
|
||||
cg.add(var.add_then(actions))
|
||||
for action in await build_action_list(config[CONF_THEN], template_arg, args):
|
||||
cg.add(var.add_then_action(action))
|
||||
cg.add(var.finish_then())
|
||||
if has_else:
|
||||
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
|
||||
cg.add(var.add_else(actions))
|
||||
for action in await build_action_list(config[CONF_ELSE], template_arg, args):
|
||||
cg.add(var.add_else_action(action))
|
||||
cg.add(var.finish_else())
|
||||
return var
|
||||
|
||||
|
||||
@@ -875,11 +900,27 @@ async def while_action_to_code(
|
||||
) -> MockObj:
|
||||
condition = await build_condition(config[CONF_CONDITION], template_arg, args)
|
||||
var = cg.new_Pvariable(action_id, template_arg, condition)
|
||||
actions = await build_action_list(config[CONF_THEN], template_arg, args)
|
||||
cg.add(var.add_then(actions))
|
||||
for action in await build_action_list(config[CONF_THEN], template_arg, args):
|
||||
cg.add(var.add_then_action(action))
|
||||
cg.add(var.finish_then())
|
||||
return var
|
||||
|
||||
|
||||
def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType:
|
||||
"""Rename an inherited `iteration` so the inner repeat's own counter can shadow it."""
|
||||
names = {name for _, name in args}
|
||||
renamed: TemplateArgsType = []
|
||||
for type_, name in args:
|
||||
if name == "iteration":
|
||||
depth = 1
|
||||
while f"iteration_{depth}" in names:
|
||||
depth += 1
|
||||
name = f"iteration_{depth}"
|
||||
names.add(name)
|
||||
renamed.append((type_, name))
|
||||
return renamed
|
||||
|
||||
|
||||
@register_action(
|
||||
"repeat",
|
||||
RepeatAction,
|
||||
@@ -903,9 +944,11 @@ async def repeat_action_to_code(
|
||||
actions = await build_action_list(
|
||||
config[CONF_THEN],
|
||||
cg.TemplateArguments(cg.uint32, *template_arg.args),
|
||||
[(cg.uint32, "iteration"), *args],
|
||||
[(cg.uint32, "iteration"), *_shadow_iteration(args)],
|
||||
)
|
||||
cg.add(var.add_then(actions))
|
||||
for action in actions:
|
||||
cg.add(var.add_then_action(action))
|
||||
cg.add(var.finish_then())
|
||||
return var
|
||||
|
||||
|
||||
@@ -1041,30 +1084,141 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool:
|
||||
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
|
||||
trigger args for later execution, making non-owning types like StringRef
|
||||
unsafe.
|
||||
|
||||
Accepts an action list, a single action, or an automation. Only the keys of
|
||||
action entries are looked up as action names; the options of an action are
|
||||
only searched for nested action lists (``then:``, ``else:``, ...), so an
|
||||
option that shares a name with an action, such as the ``delay:`` option of
|
||||
``speaker_source.set_playlist_delay``, is not mistaken for that action.
|
||||
"""
|
||||
if isinstance(actions, list):
|
||||
return any(has_non_synchronous_actions(item) for item in actions)
|
||||
if isinstance(actions, dict):
|
||||
for key in actions:
|
||||
for key, options in actions.items():
|
||||
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
|
||||
return True
|
||||
return any(
|
||||
has_non_synchronous_actions(v)
|
||||
for v in actions.values()
|
||||
if isinstance(v, (list, dict))
|
||||
)
|
||||
if _options_have_non_synchronous_actions(options):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _options_have_non_synchronous_actions(options: Any) -> bool:
|
||||
"""Search an action's options for nested action lists, without treating option keys as actions."""
|
||||
if isinstance(options, list):
|
||||
return has_non_synchronous_actions(options)
|
||||
if isinstance(options, dict):
|
||||
return any(_options_have_non_synchronous_actions(v) for v in options.values())
|
||||
return False
|
||||
|
||||
|
||||
def validate_no_deferring_actions(
|
||||
context: str, data: str = "the trigger data"
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Return a validator that rejects deferring actions in a validated automation.
|
||||
|
||||
Use it for triggers whose arguments only live while the callback runs (views into a reused
|
||||
buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later.
|
||||
|
||||
:param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``.
|
||||
:param data: What is only valid while the automation runs, for the error message.
|
||||
"""
|
||||
|
||||
def validator(value: ConfigType) -> ConfigType:
|
||||
if has_non_synchronous_actions(value):
|
||||
raise cv.Invalid(
|
||||
f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in "
|
||||
f"{context}: {data} is only valid while the automation runs. Copy what you need "
|
||||
"into globals first, then defer in a separate script or automation."
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def validate_synchronous_automation(
|
||||
context: str,
|
||||
data: str = "the trigger data",
|
||||
extra_schema=None,
|
||||
extra_validators=None,
|
||||
single: bool = False,
|
||||
) -> cv.All:
|
||||
"""``validate_automation()`` that also rejects deferring actions.
|
||||
|
||||
See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are
|
||||
passed to ``validate_automation()``.
|
||||
"""
|
||||
return cv.All(
|
||||
validate_automation(
|
||||
extra_schema=extra_schema,
|
||||
extra_validators=extra_validators,
|
||||
single=single,
|
||||
),
|
||||
validate_no_deferring_actions(context, data),
|
||||
)
|
||||
|
||||
|
||||
async def _new_automation(
|
||||
args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj
|
||||
) -> MockObj:
|
||||
"""Create the Automation for ``config`` with its actions."""
|
||||
templ = cg.TemplateArguments(*(arg[0] for arg in args))
|
||||
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args)
|
||||
actions = await build_action_list(config[CONF_THEN], templ, args)
|
||||
for action in actions:
|
||||
cg.add(obj.add_action(action))
|
||||
return obj
|
||||
|
||||
|
||||
async def build_automation(
|
||||
trigger: MockObj, args: TemplateArgsType, config: ConfigType
|
||||
) -> MockObj:
|
||||
arg_types = [arg[0] for arg in args]
|
||||
templ = cg.TemplateArguments(*arg_types)
|
||||
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger)
|
||||
actions = await build_action_list(config[CONF_THEN], templ, args)
|
||||
cg.add(obj.add_actions(actions))
|
||||
return obj
|
||||
return await _new_automation(args, config, trigger)
|
||||
|
||||
|
||||
async def build_trigger_callback(
|
||||
args: TemplateArgsType,
|
||||
config: ConfigType,
|
||||
params: TemplateArgsType,
|
||||
forward: Sequence[str | Expression] | None = None,
|
||||
when: str | ApplyCall | None = None,
|
||||
parent: MockObj | None = None,
|
||||
) -> LambdaExpression:
|
||||
"""Build the Automation for ``config`` and return a stateless callback that triggers it.
|
||||
|
||||
``params`` are the parent callback's parameters, ``forward`` the expressions passed to
|
||||
``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``),
|
||||
``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none
|
||||
of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g.
|
||||
``"{parent}->is_fully_open()"``.
|
||||
"""
|
||||
members: list[tuple[Any, Any, Any]] = []
|
||||
if when is not None:
|
||||
call = when if isinstance(when, ApplyCall) else ApplyCall(when)
|
||||
members = call.members
|
||||
if parent is None and call.names_parent:
|
||||
raise ValueError(f"trigger filter {call.target!r} names {{parent}}")
|
||||
obj = await _new_automation(args, config)
|
||||
lambda_args = _apply_lambda_args(params)
|
||||
statements: list[str] = []
|
||||
if when is not None:
|
||||
values = _apply_values(config, members)
|
||||
if _apply_call_active(members, values):
|
||||
check = await _render_check(
|
||||
"trigger filter",
|
||||
call.target,
|
||||
members,
|
||||
values,
|
||||
config,
|
||||
None if parent is None else str(parent_ref(parent)),
|
||||
lambda_args,
|
||||
)
|
||||
statements.append(f"if (!({check}))\n return;")
|
||||
if forward is None:
|
||||
forward = [name for _, name in params]
|
||||
statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});")
|
||||
return LambdaExpression(
|
||||
["\n".join(statements)], lambda_args, capture="", return_type=cg.void
|
||||
)
|
||||
|
||||
|
||||
async def build_callback_automation(
|
||||
@@ -1073,6 +1227,9 @@ async def build_callback_automation(
|
||||
args: TemplateArgsType,
|
||||
config: ConfigType,
|
||||
forwarder: MockObj | MockObjClass | None = None,
|
||||
params: TemplateArgsType | None = None,
|
||||
forward: Sequence[str | Expression] | None = None,
|
||||
when: str | ApplyCall | None = None,
|
||||
) -> None:
|
||||
"""Build an Automation and register it as a callback on the parent.
|
||||
|
||||
@@ -1084,6 +1241,9 @@ async def build_callback_automation(
|
||||
pointer-sized (single Automation* field) to fit inline in Callback::ctx_
|
||||
and avoid heap allocation.
|
||||
|
||||
With ``params``, ``forward`` or ``when`` the callback is instead the stateless
|
||||
lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them.
|
||||
|
||||
:param parent: The component object (e.g., button, sensor).
|
||||
:param callback_method: Name of the callback method (e.g., "add_on_press_callback").
|
||||
:param args: Automation template args as list of (type, name) tuples.
|
||||
@@ -1092,22 +1252,56 @@ async def build_callback_automation(
|
||||
TriggerForwarder<Ts...>. Pass any struct type whose aggregate init takes
|
||||
a single Automation pointer (e.g., TriggerOnTrueForwarder).
|
||||
"""
|
||||
arg_types = [arg[0] for arg in args]
|
||||
templ = cg.TemplateArguments(*arg_types)
|
||||
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ)
|
||||
actions = await build_action_list(config[CONF_THEN], templ, args)
|
||||
cg.add(obj.add_actions(actions))
|
||||
if params is not None or forward is not None or when is not None:
|
||||
if forwarder is not None:
|
||||
raise ValueError(
|
||||
"forwarder cannot be combined with params, forward or when"
|
||||
)
|
||||
callback = await build_trigger_callback(
|
||||
args, config, args if params is None else params, forward, when, parent
|
||||
)
|
||||
cg.add(getattr(parent, callback_method)(callback))
|
||||
return
|
||||
obj = await _new_automation(args, config)
|
||||
# Use template forwarder structs for deduplication. The compiler generates
|
||||
# one operator() per forwarder type; different automation pointers are just
|
||||
# data in the struct.
|
||||
if forwarder is None:
|
||||
forwarder = TriggerForwarder.template(templ)
|
||||
forwarder = TriggerForwarder.template(*(arg[0] for arg in args))
|
||||
# RawExpression for aggregate init — both forwarder and obj are codegen
|
||||
# MockObjs (not user input), and there's no Expression type for positional
|
||||
# aggregate initialization (StructInitializer uses named fields).
|
||||
cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
|
||||
|
||||
|
||||
async def build_parent_callback_automation(
|
||||
parent: MockObj, callback_method: str, arg: tuple[Any, str], config: ConfigType
|
||||
) -> None:
|
||||
"""Register an Automation that receives ``parent`` from a callback that carries nothing.
|
||||
|
||||
``arg`` is the automation's ``(type, name)``, e.g. ``(Fan.operator("ptr"), "x")``.
|
||||
"""
|
||||
await build_callback_automation(
|
||||
parent, callback_method, [arg], config, params=[], forward=[parent_ref(parent)]
|
||||
)
|
||||
|
||||
|
||||
async def build_trigger_automations(
|
||||
parent: MockObj | None,
|
||||
config: ConfigType,
|
||||
entries: tuple[tuple[str, TemplateArgsType], ...],
|
||||
) -> None:
|
||||
"""Instantiate each entry's Trigger class, with ``parent`` when given, and build its automations.
|
||||
|
||||
``entries`` are ``(conf_key, args)`` pairs; the class comes from the entry's ``CONF_TRIGGER_ID``.
|
||||
"""
|
||||
ctor_args = () if parent is None else (parent,)
|
||||
for conf_key, args in entries:
|
||||
for conf in config.get(conf_key, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], *ctor_args)
|
||||
await build_automation(trigger, args, conf)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallbackAutomation:
|
||||
"""A single callback automation entry for build_callback_automations."""
|
||||
@@ -1116,6 +1310,9 @@ class CallbackAutomation:
|
||||
callback_method: str
|
||||
args: TemplateArgsType = field(default_factory=list)
|
||||
forwarder: MockObj | MockObjClass | None = None
|
||||
params: TemplateArgsType | None = None
|
||||
forward: Sequence[str | Expression] | None = None
|
||||
when: str | ApplyCall | None = None
|
||||
|
||||
|
||||
async def build_callback_automations(
|
||||
@@ -1137,4 +1334,7 @@ async def build_callback_automations(
|
||||
entry.args,
|
||||
conf,
|
||||
forwarder=entry.forwarder,
|
||||
params=entry.params,
|
||||
forward=entry.forward,
|
||||
when=entry.when,
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ from esphome.components.esp8266.const import (
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_SCANF_FLOAT,
|
||||
THROW_STUBS_HEADER,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
@@ -1056,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
cxx = (toolchain_tool(paths.toolchain, "g++"),)
|
||||
lines = [
|
||||
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
*compile_rule_lines(ccache),
|
||||
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
@@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
|
||||
core_exclude |= _CORE_EXCLUDE_WAVEFORM
|
||||
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries)
|
||||
archives += lib_archives
|
||||
|
||||
# One source of truth with the PlatformIO path: esp8266/__init__ pins
|
||||
# build_src_flags (the throw_stubs force-include); -include paths
|
||||
# resolve against the source root
|
||||
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
|
||||
# the PlatformIO path; -include paths resolve against the source root
|
||||
src_other: list[str] = []
|
||||
src_includes: list[str] = []
|
||||
src_it = iter(
|
||||
@@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
src_includes.append(tok[len("-include") :])
|
||||
else:
|
||||
src_other.append(_shell_token(tok))
|
||||
# The throw stubs abort: none anywhere with exceptions on, else the core and
|
||||
# libraries get them too (src keeps them after its pch include)
|
||||
if config.exceptions:
|
||||
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
|
||||
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
|
||||
framework_flags = ""
|
||||
if THROW_STUBS_HEADER in src_includes:
|
||||
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
|
||||
lines.append(f"frameworkflags = {stubs_flag}")
|
||||
framework_flags = "$frameworkflags"
|
||||
archives = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = compile_edges(
|
||||
lines, variant_sources, variant_dir, "variant", flags=framework_flags
|
||||
)
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = compile_edges(
|
||||
lines,
|
||||
collect_sources(core_dir, core_exclude),
|
||||
core_dir,
|
||||
"core",
|
||||
flags=framework_flags,
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
# (app_entry, the exception vectors) far from the cause
|
||||
raise EsphomeError(
|
||||
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
|
||||
)
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
|
||||
archives += lib_archives
|
||||
|
||||
# One shared variable instead of repeating the flags line on every src
|
||||
# edge (hundreds of edges in a real project)
|
||||
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
|
||||
|
||||
+51
-15
@@ -237,6 +237,35 @@ def idf_macro_matches(idf_path: Path) -> bool:
|
||||
return live == _EXPECTED_MACRO
|
||||
|
||||
|
||||
def _cmake_args_for_write(minimal: bool) -> dict[str, str]:
|
||||
"""The cg.add_cmake_arg() values for one project write.
|
||||
|
||||
The discovery (minimal) write lets every built-in component register so
|
||||
the list it finds, cached per target, serves any exclusion set; the full
|
||||
write drops the excluded names from that list and passes all of them to
|
||||
IDF. Names outside the framework's components directory (managed
|
||||
components) stay excluded on the discovery write too, since the list
|
||||
only holds built-in ones. A checkout supplied through IDF_PATH caches
|
||||
nothing, so its discovery keeps every exclusion.
|
||||
"""
|
||||
from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path
|
||||
|
||||
args = dict(CORE.cmake_args)
|
||||
if (
|
||||
not minimal
|
||||
or not _esphome_manages_idf()
|
||||
or not (excluded := args.get("EXCLUDE_COMPONENTS"))
|
||||
):
|
||||
return args
|
||||
root = _get_idf_path() / "components"
|
||||
outside = [name for name in excluded.split(";") if not (root / name).is_dir()]
|
||||
if outside:
|
||||
args["EXCLUDE_COMPONENTS"] = ";".join(outside)
|
||||
else:
|
||||
del args["EXCLUDE_COMPONENTS"]
|
||||
return args
|
||||
|
||||
|
||||
def get_project_cmakelists(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> str:
|
||||
@@ -300,11 +329,10 @@ def get_project_cmakelists(
|
||||
|
||||
# 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.
|
||||
# set when project.cmake seeds the component list.
|
||||
cmake_args = "\n".join(
|
||||
f"set({name} {_cmake_quote(value)})"
|
||||
for name, value in sorted(CORE.cmake_args.items())
|
||||
for name, value in sorted(_cmake_args_for_write(minimal).items())
|
||||
)
|
||||
|
||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||
@@ -327,10 +355,10 @@ def get_project_cmakelists(
|
||||
# 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).
|
||||
# Excluded components are dropped here as well: the discovery list
|
||||
# (cached per target) still holds 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 = (
|
||||
@@ -397,15 +425,24 @@ project({CORE.name})
|
||||
# Emit per-memory-type JSON size data for ESPHome to read post-build.
|
||||
# json2 stays small; raw dumps every symbol (~2s on a large map) and
|
||||
# this command runs inside the link edge, blocking everything downstream.
|
||||
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
|
||||
# target depends on the map and can then be built in the same run as all.
|
||||
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
|
||||
# IDF's size target depends on the map, so ninja has to know the link
|
||||
# writes it for size to build in the same run as all. IDF declares the
|
||||
# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja
|
||||
# rejects two rules for one output, so declare it here only when IDF has
|
||||
# not. add_custom_command(OUTPUT) marks its outputs GENERATED.
|
||||
get_source_file_property(esphome_map_declared
|
||||
${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED)
|
||||
if(esphome_map_declared)
|
||||
set(esphome_map_byproducts)
|
||||
else()
|
||||
set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map)
|
||||
endif()
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
|
||||
${{esphome_map_byproducts}}
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
VERBATIM
|
||||
)
|
||||
@@ -554,10 +591,9 @@ def write_project(
|
||||
)
|
||||
|
||||
# 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.
|
||||
# reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS
|
||||
# property drops excluded names, so re-including one (e.g. a config
|
||||
# gains mqtt) needs a reconfigure to rebuild it from the discovery list.
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("exclude_components.esphomeinternal"),
|
||||
";".join(get_excluded_builtin_components()),
|
||||
|
||||
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
|
||||
lines = [
|
||||
*tool_lines(compilers.cc, compilers.cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
*compile_rule_lines(ccache),
|
||||
*pch_rule_lines(),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
|
||||
|
||||
@@ -74,33 +74,40 @@ def tool_lines(
|
||||
# path gets
|
||||
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
|
||||
f"buildtool = {_q(_BUILD_TOOL)}",
|
||||
f"ccache = {_q(ccache) if ccache else ''}",
|
||||
*([f"ccache = {_q(ccache)}"] if ccache else []),
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def compile_rule_lines() -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
|
||||
def compile_rule_lines(ccache: str | None) -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
|
||||
|
||||
``ccache`` is the launcher ``tool_lines`` was given; the same test here
|
||||
keeps the variable and the rules in step. It is spliced in rather than
|
||||
left as an empty variable: a leading space on the command line makes
|
||||
CreateProcess fail on Windows.
|
||||
"""
|
||||
launcher = "$ccache " if ccache else ""
|
||||
return [
|
||||
"rule c",
|
||||
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CC $out",
|
||||
"rule cxx",
|
||||
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CXX $out",
|
||||
"rule aspp",
|
||||
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = AS $out",
|
||||
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
|
||||
# depfile and no $flags (defines/includes) either
|
||||
"rule asm",
|
||||
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
|
||||
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
|
||||
" description = AS $out",
|
||||
]
|
||||
|
||||
@@ -207,10 +214,12 @@ def compile_edges(
|
||||
|
||||
|
||||
def library_edges(
|
||||
lines: list[str], libraries: list[ArduinoLibrary]
|
||||
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Emit every library's compile and archive edges.
|
||||
|
||||
``extra_flags`` (raw ninja text) precede each library's own flags.
|
||||
|
||||
Returns the archive names and the objects that link directly. A
|
||||
library's own include dirs lead its compile lines, as PlatformIO searched
|
||||
them first: the include path is one global list, so another library's
|
||||
@@ -232,7 +241,7 @@ def library_edges(
|
||||
lib.sources,
|
||||
common_parent(lib.sources),
|
||||
f"lib/{lib.name}",
|
||||
flags=" ".join(_shell_token(f) for f in lib.flags),
|
||||
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
|
||||
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
|
||||
)
|
||||
if not lib.lib_archive:
|
||||
|
||||
+20
-12
@@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Matches !secret references in YAML text. An optional surrounding
|
||||
# quote pair around the key is allowed and ignored: YAML treats
|
||||
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
|
||||
# intentionally a simple regex scan rather than a YAML parse — it may
|
||||
# match inside comments or multi-line strings, which is the conservative
|
||||
# direction (include more secrets rather than fewer).
|
||||
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
|
||||
|
||||
|
||||
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
"""Scan YAML files for ``!secret <key>`` references."""
|
||||
keys: set[str] = set()
|
||||
for fpath in yaml_files:
|
||||
try:
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not scan %s for !secret references (%s); the bundled "
|
||||
"secret set may be incomplete",
|
||||
fpath,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
for match in _SECRET_RE.finditer(text):
|
||||
keys.add(match.group(1))
|
||||
keys |= yaml_util.find_secret_references(text)
|
||||
return keys
|
||||
|
||||
|
||||
@@ -422,6 +418,18 @@ class ConfigBundleCreator:
|
||||
must ship every candidate so the remote build can pick any one.
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
if discovered.load_errors:
|
||||
_LOGGER.warning(
|
||||
"Bundle may be incomplete; could not load all configuration files: %s",
|
||||
"; ".join(discovered.load_errors),
|
||||
)
|
||||
if discovered.unresolved:
|
||||
_LOGGER.warning(
|
||||
"Bundle may be incomplete; %d !include path(s) use "
|
||||
"substitutions and cannot be captured: %s",
|
||||
len(discovered.unresolved),
|
||||
", ".join(discovered.unresolved),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -34,14 +34,17 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
extern_progmem_array,
|
||||
get_variable,
|
||||
get_variable_with_full_id,
|
||||
is_static_pointer,
|
||||
is_template,
|
||||
new_Pvariable,
|
||||
new_variable,
|
||||
process_lambda,
|
||||
progmem_array,
|
||||
progmem_string,
|
||||
safe_exp,
|
||||
set_cpp_standard,
|
||||
shared_progmem_array,
|
||||
shared_progmem_string,
|
||||
statement,
|
||||
static_const_array,
|
||||
static_function,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@MrSuicideParrot"]
|
||||
DOMAIN = "a01nyub"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::a01nyub {
|
||||
|
||||
static const char *const TAG = "a01nyub.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "a01nyub.sensor");
|
||||
|
||||
void A01nyubComponent::loop() {
|
||||
uint8_t data;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@TH-Braemer"]
|
||||
DOMAIN = "a02yyuw"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::a02yyuw {
|
||||
|
||||
static const char *const TAG = "a02yyuw.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor");
|
||||
|
||||
void A02yyuwComponent::loop() {
|
||||
uint8_t data;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "a4988"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::a4988 {
|
||||
|
||||
static const char *const TAG = "a4988.stepper";
|
||||
ESPHOME_LOG_TAG(TAG, "a4988.stepper");
|
||||
|
||||
void A4988::setup() {
|
||||
if (this->sleep_pin_ != nullptr) {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@DAVe3283"]
|
||||
DOMAIN = "absolute_humidity"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::absolute_humidity {
|
||||
|
||||
static const char *const TAG{"absolute_humidity.sensor"};
|
||||
ESPHOME_LOG_TAG(TAG, "absolute_humidity.sensor");
|
||||
|
||||
void AbsoluteHumidityComponent::setup() {
|
||||
this->temperature_sensor_->add_on_state_callback([this](float state) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "ac_dimmer"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
namespace esphome::ac_dimmer {
|
||||
|
||||
static const char *const TAG = "ac_dimmer";
|
||||
ESPHOME_LOG_TAG(TAG, "ac_dimmer");
|
||||
|
||||
// Global array to store dimmer objects
|
||||
static AcDimmerDataStore *all_dimmers[32]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include "esp_clk_tree.h"
|
||||
#include "soc/clk_tree_defs.h"
|
||||
|
||||
static const char *const TAG = "hw_timer_esp_idf";
|
||||
ESPHOME_LOG_TAG(TAG, "hw_timer_esp_idf");
|
||||
|
||||
namespace esphome::ac_dimmer {
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "adalight"
|
||||
|
||||
adalight_ns = cg.esphome_ns.namespace("adalight")
|
||||
AdalightLightEffect = adalight_ns.class_(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::adalight {
|
||||
|
||||
static const char *const TAG = "adalight_light_effect";
|
||||
ESPHOME_LOG_TAG(TAG, "adalight_light_effect");
|
||||
|
||||
static const uint32_t ADALIGHT_ACK_INTERVAL = 1000;
|
||||
static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000;
|
||||
|
||||
@@ -22,6 +22,7 @@ from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
DOMAIN = "adc"
|
||||
|
||||
adc_ns = cg.esphome_ns.namespace("adc")
|
||||
|
||||
|
||||
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
#endif // USE_RP2
|
||||
|
||||
protected:
|
||||
uint8_t sample_count_{1};
|
||||
bool output_raw_{false};
|
||||
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
|
||||
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
|
||||
InternalGPIOPin *pin_;
|
||||
SamplingMode sampling_mode_{SamplingMode::AVG};
|
||||
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
|
||||
|
||||
#ifdef USE_ESP32
|
||||
float sample_autorange_();
|
||||
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
bool autorange_{false};
|
||||
adc_oneshot_unit_handle_t adc_handle_{nullptr};
|
||||
adc_cali_handle_t calibration_handle_{nullptr};
|
||||
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
|
||||
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
|
||||
adc_channel_t channel_{};
|
||||
adc_unit_t adc_unit_{};
|
||||
struct SetupFlags {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
ESPHOME_LOG_TAG(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";
|
||||
ESPHOME_LOG_TAG(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";
|
||||
ESPHOME_LOG_TAG(TAG, "adc");
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
ESPHOME_LOG_TAG(TAG, "adc");
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
ESPHOME_LOG_TAG(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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
ESPHOME_LOG_TAG(TAG, "adc");
|
||||
|
||||
void ADCSensor::setup() {
|
||||
if (!adc_is_ready_dt(this->channel_)) {
|
||||
|
||||
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
|
||||
CONF_SAMPLES = "samples"
|
||||
CONF_SAMPLING_MODE = "sampling_mode"
|
||||
|
||||
# Keep in sync with the initializers in adc_sensor.h
|
||||
DEFAULT_ATTENUATION = "0db"
|
||||
DEFAULT_SAMPLING_MODE = "avg"
|
||||
DEFAULT_SAMPLES = 1
|
||||
|
||||
|
||||
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
|
||||
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
|
||||
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
|
||||
):
|
||||
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
|
||||
|
||||
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
|
||||
if (
|
||||
config.get(CONF_ATTENUATION, None) == "auto"
|
||||
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"Automatic attenuation cannot be used when multisampling is set"
|
||||
)
|
||||
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_PIN): validate_adc_pin,
|
||||
cv.Optional(CONF_RAW, default=False): cv.boolean,
|
||||
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
|
||||
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
|
||||
cv.only_on_esp32, _attenuation
|
||||
),
|
||||
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
|
||||
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
|
||||
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
|
||||
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
|
||||
min=1, max=255
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
|
||||
): _sampling_mode,
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s")),
|
||||
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
cg.add(var.set_pin(pin))
|
||||
|
||||
cg.add(var.set_output_raw(config[CONF_RAW]))
|
||||
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
|
||||
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
|
||||
# Skip the setters when the config matches the C++ initializers.
|
||||
if config[CONF_RAW]:
|
||||
cg.add(var.set_output_raw(True))
|
||||
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
|
||||
cg.add(var.set_sample_count(samples))
|
||||
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
|
||||
cg.add(var.set_sampling_mode(sampling_mode))
|
||||
|
||||
if CORE.is_esp32:
|
||||
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
|
||||
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
if attenuation := config.get(CONF_ATTENUATION):
|
||||
if attenuation == "auto":
|
||||
cg.add(var.set_autorange(cg.global_ns.true))
|
||||
else:
|
||||
elif attenuation != DEFAULT_ATTENUATION:
|
||||
cg.add(var.set_attenuation(attenuation))
|
||||
|
||||
variant = get_esp32_variant()
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
DOMAIN = "adc128s102"
|
||||
MULTI_CONF = True
|
||||
CODEOWNERS = ["@DeerMaximum"]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::adc128s102 {
|
||||
|
||||
static const char *const TAG = "adc128s102";
|
||||
ESPHOME_LOG_TAG(TAG, "adc128s102");
|
||||
|
||||
float ADC128S102::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::adc128s102 {
|
||||
|
||||
static const char *const TAG = "adc128s102.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "adc128s102.sensor");
|
||||
|
||||
ADC128S102Sensor::ADC128S102Sensor(uint8_t channel) : channel_(channel) {}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "addressable_light"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::addressable_light {
|
||||
|
||||
static const char *const TAG = "addressable_light.display";
|
||||
ESPHOME_LOG_TAG(TAG, "addressable_light.display");
|
||||
|
||||
int AddressableLightDisplay::get_width_internal() { return this->width_; }
|
||||
int AddressableLightDisplay::get_height_internal() { return this->height_; }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "ade7880"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
namespace esphome::ade7880 {
|
||||
|
||||
static const char *const TAG = "ade7880";
|
||||
ESPHOME_LOG_TAG(TAG, "ade7880");
|
||||
|
||||
void IRAM_ATTR ADE7880Store::gpio_intr(ADE7880Store *arg) { arg->reset_done = true; }
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
DOMAIN = "ade7953"
|
||||
|
||||
@@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
DOMAIN = "ade7953_base"
|
||||
|
||||
CONF_CURRENT_A = "current_a"
|
||||
CONF_CURRENT_B = "current_b"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
namespace esphome::ade7953_base {
|
||||
|
||||
static const char *const TAG = "ade7953";
|
||||
ESPHOME_LOG_TAG(TAG, "ade7953");
|
||||
|
||||
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
|
||||
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
DOMAIN = "ade7953_i2c"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ade7953_i2c {
|
||||
|
||||
static const char *const TAG = "ade7953";
|
||||
ESPHOME_LOG_TAG(TAG, "ade7953");
|
||||
|
||||
void AdE7953I2c::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "ADE7953_i2c:");
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
DOMAIN = "ade7953_spi"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ade7953_spi {
|
||||
|
||||
static const char *const TAG = "ade7953";
|
||||
ESPHOME_LOG_TAG(TAG, "ade7953");
|
||||
|
||||
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
|
||||
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "ads1115"
|
||||
MULTI_CONF = True
|
||||
|
||||
ads1115_ns = cg.esphome_ns.namespace("ads1115")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ads1115 {
|
||||
|
||||
static const char *const TAG = "ads1115";
|
||||
ESPHOME_LOG_TAG(TAG, "ads1115");
|
||||
static const uint8_t ADS1115_REGISTER_CONVERSION = 0x00;
|
||||
static const uint8_t ADS1115_REGISTER_CONFIG = 0x01;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ads1115 {
|
||||
|
||||
static const char *const TAG = "ads1115.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "ads1115.sensor");
|
||||
|
||||
float ADS1115Sensor::sample() {
|
||||
return this->parent_->request_measurement(this->multiplexer_, this->gain_, this->resolution_, this->samplerate_);
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@solomondg1"]
|
||||
DEPENDENCIES = ["spi"]
|
||||
DOMAIN = "ads1118"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_ADS1118_ID = "ads1118_id"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ads1118 {
|
||||
|
||||
static const char *const TAG = "ads1118";
|
||||
ESPHOME_LOG_TAG(TAG, "ads1118");
|
||||
static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111;
|
||||
|
||||
void ADS1118::setup() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ads1118 {
|
||||
|
||||
static const char *const TAG = "ads1118.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "ads1118.sensor");
|
||||
|
||||
void ADS1118Sensor::dump_config() {
|
||||
LOG_SENSOR(" ", "ADS1118 Sensor", this);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@mak-42"]
|
||||
DOMAIN = "ags10"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome::ags10 {
|
||||
static const char *const TAG = "ags10";
|
||||
ESPHOME_LOG_TAG(TAG, "ags10");
|
||||
|
||||
// Data acquisition.
|
||||
static const uint8_t REG_TVOC = 0x00;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "aht10"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
namespace esphome::aht10 {
|
||||
|
||||
static const char *const TAG = "aht10";
|
||||
ESPHOME_LOG_TAG(TAG, "aht10");
|
||||
static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00};
|
||||
static const uint8_t AHT20_INITIALIZE_CMD[] = {0xBE, 0x08, 0x00};
|
||||
static const uint8_t AHT10_MEASURE_CMD[] = {0xAC, 0x33, 0x00};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "aic3204"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::aic3204 {
|
||||
|
||||
static const char *const TAG = "aic3204";
|
||||
ESPHOME_LOG_TAG(TAG, "aic3204");
|
||||
|
||||
#define ERROR_CHECK(err, msg) \
|
||||
if (!(err)) { \
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
CODEOWNERS = ["@jeromelaban"]
|
||||
DOMAIN = "airthings_ble"
|
||||
|
||||
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
|
||||
AirthingsListener = airthings_ble_ns.class_(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
static const char *const TAG = "airthings_ble";
|
||||
ESPHOME_LOG_TAG(TAG, "airthings_ble");
|
||||
|
||||
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
for (auto &it : device.get_manufacturer_datas()) {
|
||||
|
||||
@@ -24,6 +24,7 @@ from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@ncareau", "@jeromelaban"]
|
||||
DOMAIN = "airthings_wave_base"
|
||||
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
namespace esphome::airthings_wave_base {
|
||||
|
||||
static const char *const TAG = "airthings_wave_base";
|
||||
ESPHOME_LOG_TAG(TAG, "airthings_wave_base");
|
||||
|
||||
static constexpr uint32_t BATTERY_READ_TIMEOUT_ID = 0;
|
||||
static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1;
|
||||
|
||||
void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
@@ -181,7 +184,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) {
|
||||
// read the battery again at the configured update interval
|
||||
if (this->battery_update_interval_ != this->update_interval_) {
|
||||
this->read_battery_next_update_ = false;
|
||||
this->set_timeout("battery", this->battery_update_interval_,
|
||||
this->set_timeout(BATTERY_READ_TIMEOUT_ID, this->battery_update_interval_,
|
||||
[this]() { this->read_battery_next_update_ = true; });
|
||||
}
|
||||
}
|
||||
@@ -204,7 +207,7 @@ void AirthingsWaveBase::response_received_() {
|
||||
}
|
||||
|
||||
void AirthingsWaveBase::set_response_timeout_() {
|
||||
this->set_timeout("response_timeout", 30 * 1000, [this]() {
|
||||
this->set_timeout(RESPONSE_TIMEOUT_ID, 30 * 1000, [this]() {
|
||||
this->responses_pending_ = 1;
|
||||
this->response_received_();
|
||||
});
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@ncareau"]
|
||||
DOMAIN = "airthings_wave_mini"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::airthings_wave_mini {
|
||||
|
||||
static const char *const TAG = "airthings_wave_mini";
|
||||
ESPHOME_LOG_TAG(TAG, "airthings_wave_mini");
|
||||
|
||||
void AirthingsWaveMini::read_sensors(uint8_t *raw_value, uint16_t value_len) {
|
||||
auto *value = (WaveMiniReadings *) raw_value;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
CODEOWNERS = ["@jeromelaban", "@precurse"]
|
||||
DOMAIN = "airthings_wave_plus"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::airthings_wave_plus {
|
||||
|
||||
static const char *const TAG = "airthings_wave_plus";
|
||||
ESPHOME_LOG_TAG(TAG, "airthings_wave_plus");
|
||||
|
||||
void AirthingsWavePlus::read_sensors(uint8_t *raw_value, uint16_t value_len) {
|
||||
auto *value = (WavePlusReadings *) raw_value;
|
||||
|
||||
@@ -21,6 +21,7 @@ from esphome.core.entity_helpers import (
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
|
||||
CODEOWNERS = ["@grahambrown11", "@hwstar"]
|
||||
DOMAIN = "alarm_control_panel"
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
CONF_ON_TRIGGERED = "on_triggered"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
namespace esphome::alarm_control_panel {
|
||||
|
||||
static const char *const TAG = "alarm_control_panel";
|
||||
ESPHOME_LOG_TAG(TAG, "alarm_control_panel");
|
||||
|
||||
AlarmControlPanelCall AlarmControlPanel::make_call() { return AlarmControlPanelCall(this); }
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::alarm_control_panel {
|
||||
|
||||
static const char *const TAG = "alarm_control_panel";
|
||||
ESPHOME_LOG_TAG(TAG, "alarm_control_panel");
|
||||
|
||||
AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent_(parent) {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user