mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
678
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb473e1e5 | ||
|
|
0e2496a49a | ||
|
|
e36d7fe82b | ||
|
|
d38a458de9 | ||
|
|
5821915aad | ||
|
|
fae2f7a11d | ||
|
|
1df2759db6 | ||
|
|
ce9b221be4 | ||
|
|
e50fae3f46 | ||
|
|
15711f8112 | ||
|
|
7ad662781d | ||
|
|
2d1530adf7 | ||
|
|
40b4cd8e9b | ||
|
|
e11a295680 | ||
|
|
f9cd789f7c | ||
|
|
80c17fb2cd | ||
|
|
fb21320826 | ||
|
|
3899429cfa | ||
|
|
44f08d15db | ||
|
|
963a3379a6 | ||
|
|
4951f4fc2e | ||
|
|
a87ad66746 | ||
|
|
60662830dc | ||
|
|
33ccaf8a85 | ||
|
|
d64e1ebba3 | ||
|
|
6733e930eb | ||
|
|
c23a107a87 | ||
|
|
4224d90567 | ||
|
|
fb1b1db87f | ||
|
|
be78b8011d | ||
|
|
a99d03e172 | ||
|
|
c7d16dde15 | ||
|
|
be7748ccb6 | ||
|
|
41902d7538 | ||
|
|
ca1f89e500 | ||
|
|
306d200b02 | ||
|
|
98ee7e0f82 | ||
|
|
ad3e2f83b8 | ||
|
|
33dacb06ff | ||
|
|
363a91c185 | ||
|
|
4209f242e5 | ||
|
|
9af0302bb9 | ||
|
|
4c571c3ec7 | ||
|
|
a9df82fa0a | ||
|
|
6ad804fbc5 | ||
|
|
227f1d3842 | ||
|
|
72f904dcfb | ||
|
|
324e222bd2 | ||
|
|
7b1d70c2c0 | ||
|
|
b80fa4ae19 | ||
|
|
a4611907a9 | ||
|
|
17bd1b2a42 | ||
|
|
985a08e247 | ||
|
|
95786459c1 | ||
|
|
4f3db4c15a | ||
|
|
56512abb7e | ||
|
|
71349a6feb | ||
|
|
489e3d17ca | ||
|
|
d93772fed6 | ||
|
|
50f02aa523 | ||
|
|
5a87ad8fc0 | ||
|
|
e40579ad93 | ||
|
|
37fe59dc37 | ||
|
|
3829d368ff | ||
|
|
013c5d7217 | ||
|
|
e38ae51de2 | ||
|
|
333ad42e29 | ||
|
|
bb8ffac269 | ||
|
|
10303b3fa7 | ||
|
|
93eec5c961 | ||
|
|
16930c4e2c | ||
|
|
c2476911a3 | ||
|
|
98d99fb9fd | ||
|
|
ce6c122449 | ||
|
|
f1f8b102e2 | ||
|
|
e1ab5a85bb | ||
|
|
0959141f88 | ||
|
|
01616c4f30 | ||
|
|
7c89e81449 | ||
|
|
6420de53f0 | ||
|
|
fa8c7e60da | ||
|
|
5eeb780538 | ||
|
|
19511f5787 | ||
|
|
56028d0932 | ||
|
|
ca08803425 | ||
|
|
e209bbb5ae | ||
|
|
580b3a1a69 | ||
|
|
c395ea00cc | ||
|
|
89654fea66 | ||
|
|
700c0b0460 | ||
|
|
c0aa121c3d | ||
|
|
345bd11a2c | ||
|
|
071ef5016d | ||
|
|
fdc2974c0d | ||
|
|
899ed02ef2 | ||
|
|
a930baab7c | ||
|
|
43526a815e | ||
|
|
8f321bf346 | ||
|
|
91cbcab25e | ||
|
|
67e0058e72 | ||
|
|
b395a87cad | ||
|
|
8a7d2d0ca0 | ||
|
|
ed066cf0fe | ||
|
|
741fbbff5a | ||
|
|
cf17749307 | ||
|
|
98f2a0b4a4 | ||
|
|
9dd7dcb06d | ||
|
|
559614077d | ||
|
|
b5e18eb101 | ||
|
|
e0644ab20a | ||
|
|
98e9dd7954 | ||
|
|
fbb1a76e2a | ||
|
|
7fbb647c65 | ||
|
|
a2c749c277 | ||
|
|
0833e91fb5 | ||
|
|
eaa79b3696 | ||
|
|
58385bebff | ||
|
|
eb7575f59e | ||
|
|
6648ccc278 | ||
|
|
9ac4039c01 | ||
|
|
5e2d428e69 | ||
|
|
d12300679e | ||
|
|
e224a781ff | ||
|
|
a210c27d14 | ||
|
|
f0800336b8 | ||
|
|
33fa9f5069 | ||
|
|
427a504308 | ||
|
|
dd2fbdd43a | ||
|
|
d7d1e5c2f6 | ||
|
|
b832e135cb | ||
|
|
66615a24a7 | ||
|
|
52ace9c448 | ||
|
|
e1cedaba87 | ||
|
|
f7bf4e9727 | ||
|
|
e46bdf9e18 | ||
|
|
a08070a836 | ||
|
|
18f93c0e41 | ||
|
|
deba7a1f0c | ||
|
|
4817904637 | ||
|
|
98c6fa294a | ||
|
|
312b0c53c2 | ||
|
|
5dfce9b90c | ||
|
|
69085a7a42 | ||
|
|
2edb089f71 | ||
|
|
078b1aa1d6 | ||
|
|
1f8fea5379 | ||
|
|
9c211abc54 | ||
|
|
4afb619672 | ||
|
|
4d3b4659d7 | ||
|
|
37be04f58e | ||
|
|
1e4bf48ea3 | ||
|
|
52c6a50b6d | ||
|
|
008c85c7d5 | ||
|
|
e84880e4ec | ||
|
|
a30f9f7c65 | ||
|
|
e931919fb4 | ||
|
|
bbc5669efa | ||
|
|
718b04c15a | ||
|
|
24a8634a4b | ||
|
|
fc6664d737 | ||
|
|
388e411469 | ||
|
|
a8ebdcb8f2 | ||
|
|
f5f1c48f51 | ||
|
|
7c55de311f | ||
|
|
307faa6c38 | ||
|
|
5f2adcf9b3 | ||
|
|
7738464f0b | ||
|
|
5a86e26f68 | ||
|
|
786b47d8c2 | ||
|
|
83092ea05c | ||
|
|
629afd38f6 | ||
|
|
3ffc3a9610 | ||
|
|
9de7bd7461 | ||
|
|
5df922e0df | ||
|
|
b571d2a5ab | ||
|
|
231a2897c0 | ||
|
|
2223b14794 | ||
|
|
3d340f4d90 | ||
|
|
2f99466f3a | ||
|
|
f05e15522c | ||
|
|
f37dad683b | ||
|
|
a0fb14bf54 | ||
|
|
d4443be0c1 | ||
|
|
0dfb573e18 | ||
|
|
572fc033cd | ||
|
|
4062f0a323 | ||
|
|
cd40fb1c68 | ||
|
|
7afe7750cd | ||
|
|
e08bdf8cca | ||
|
|
b3172ecae8 | ||
|
|
2b4d9c0af9 | ||
|
|
3a10d2c187 | ||
|
|
05e2c6b133 | ||
|
|
2797349c75 | ||
|
|
95a01ac2ab | ||
|
|
ea01c909b7 | ||
|
|
d3655597ea | ||
|
|
cd8d76fa34 | ||
|
|
500d4aa9e9 | ||
|
|
271c1b409a | ||
|
|
8463dd22ec | ||
|
|
9a0e8606cd | ||
|
|
d5681ac6a0 | ||
|
|
057143838d | ||
|
|
4268d91da3 | ||
|
|
6f39030b41 | ||
|
|
3e1a9e8a3a | ||
|
|
bb1318dce1 | ||
|
|
26fcc068b6 | ||
|
|
2a96fa44ff | ||
|
|
5fa93513f2 | ||
|
|
4a1f54ce4b | ||
|
|
76655cf951 | ||
|
|
dfb988c563 | ||
|
|
0443b4849b | ||
|
|
977376d55c | ||
|
|
1f93345af8 | ||
|
|
96dd2382c1 | ||
|
|
3064fb0d48 | ||
|
|
4c9dab9c98 | ||
|
|
0b0e706349 | ||
|
|
017040ec8e | ||
|
|
52fe461e9c | ||
|
|
a7dad14449 | ||
|
|
1493a09521 | ||
|
|
2a59c0ad9e | ||
|
|
70421fb14b | ||
|
|
bacc223360 | ||
|
|
94be1da893 | ||
|
|
cb66dc01ab | ||
|
|
ebaf32c82a | ||
|
|
f918c299b1 | ||
|
|
328a2018d2 | ||
|
|
a4a9feac16 | ||
|
|
6f27c7f3fe | ||
|
|
300ab1be35 | ||
|
|
7aed5fb94b | ||
|
|
2132cae1c9 | ||
|
|
735f8d607d | ||
|
|
cc6392785f | ||
|
|
7a2d13da90 | ||
|
|
e2df9fb554 | ||
|
|
4507d05822 | ||
|
|
3caae3031a | ||
|
|
a624659856 | ||
|
|
6c8d0050ab | ||
|
|
09da2766ed | ||
|
|
72bd45538e | ||
|
|
5b37049dff | ||
|
|
c0e78a5574 | ||
|
|
f53394e46f | ||
|
|
2218edf20c | ||
|
|
dcdd044234 | ||
|
|
bc8510c6d8 | ||
|
|
5b4ae22f58 | ||
|
|
14e71e190c | ||
|
|
2333e6eef5 | ||
|
|
d748c04b28 | ||
|
|
8d1a0446a2 | ||
|
|
0887e01828 | ||
|
|
f88621f2e1 | ||
|
|
ec3f7ec16e | ||
|
|
880cb1db43 | ||
|
|
f336c45177 | ||
|
|
b12d392e02 | ||
|
|
49a1d2bb1b | ||
|
|
e327a7f52f | ||
|
|
90e838d327 | ||
|
|
f91aa7aa13 | ||
|
|
be3b27f378 | ||
|
|
3439a08b68 | ||
|
|
dedca344f9 | ||
|
|
c4975e1870 | ||
|
|
5a3c2f4d11 | ||
|
|
e700b01406 | ||
|
|
d53d4c5b58 | ||
|
|
3c34fc950e | ||
|
|
920a8b761b | ||
|
|
5d2372937e | ||
|
|
6c401d406f | ||
|
|
b0489e21b2 | ||
|
|
78180a40c4 | ||
|
|
63fae2c36a | ||
|
|
878d8a2f6a | ||
|
|
e056e99fd4 | ||
|
|
3b915cf16b | ||
|
|
d1150d148d | ||
|
|
b5b426492a | ||
|
|
724df7b11e | ||
|
|
26db22546d | ||
|
|
a3ab7961e0 | ||
|
|
54987d7d23 | ||
|
|
053ce39fc6 | ||
|
|
b295b8d5a2 | ||
|
|
4d3d06959b | ||
|
|
a5583dcba6 | ||
|
|
e2b62bcd00 | ||
|
|
4275343231 | ||
|
|
4ebe49b141 | ||
|
|
d3adb6fb81 | ||
|
|
00d2d55e09 | ||
|
|
a833685a73 | ||
|
|
2753ab1f45 | ||
|
|
b6b5b61640 | ||
|
|
0a1065da75 | ||
|
|
db9a09d05a | ||
|
|
6fadf353b1 | ||
|
|
8da377ab43 | ||
|
|
1da8900ffc | ||
|
|
519ce38b79 | ||
|
|
989797be53 | ||
|
|
583adc9e69 | ||
|
|
af9a0404d9 | ||
|
|
f38e7f2de2 | ||
|
|
3e75020007 | ||
|
|
ca77cc585c | ||
|
|
f1e4726f4e | ||
|
|
49bbceb1da | ||
|
|
65d6c028ce | ||
|
|
5e3e2f82c9 | ||
|
|
d78cb09b17 | ||
|
|
bcfb438a81 | ||
|
|
a8dfd00cc6 | ||
|
|
4a82b10783 | ||
|
|
07460ebee4 | ||
|
|
cdc42e5e55 | ||
|
|
feb53a209b | ||
|
|
c607f64288 | ||
|
|
b3e03868b3 | ||
|
|
7ee7a26cad | ||
|
|
0ff11674ef | ||
|
|
2a67e5c599 | ||
|
|
a9591d7aac | ||
|
|
bcac3ebe2b | ||
|
|
27b598c5aa | ||
|
|
9ba2cbbfdd | ||
|
|
a4650a2345 | ||
|
|
1e5cfe6b0f | ||
|
|
665e788cc9 | ||
|
|
d89b4c0b59 | ||
|
|
5afe418a8e | ||
|
|
312f6f2049 | ||
|
|
eb0848d538 | ||
|
|
1a573919d1 | ||
|
|
692cf7abd1 | ||
|
|
f0afd9e660 | ||
|
|
262ee421f6 | ||
|
|
050a006459 | ||
|
|
8518d0633b | ||
|
|
284fe85271 | ||
|
|
020a6a8fd1 | ||
|
|
196b979df8 | ||
|
|
434cffb745 | ||
|
|
2b3027a7fd | ||
|
|
e27a14ec70 | ||
|
|
91c42381f6 | ||
|
|
a39607476a | ||
|
|
b098571a6f | ||
|
|
614fd88829 | ||
|
|
c0636e2bf7 | ||
|
|
65353006c8 | ||
|
|
35a99f478e | ||
|
|
b6a4dd237e | ||
|
|
0ef85783dc | ||
|
|
5020179210 | ||
|
|
54529412dc | ||
|
|
e6525b5d93 | ||
|
|
9f62cf9243 | ||
|
|
478bca026c | ||
|
|
e1719cd85d | ||
|
|
83aaed71e1 | ||
|
|
88ca0d44e0 | ||
|
|
7a1e0bbbec | ||
|
|
b2226b91ff | ||
|
|
ba84f2ec55 | ||
|
|
4b19de0c1a | ||
|
|
4292e7988e | ||
|
|
9f21fd0b55 | ||
|
|
9327d011fc | ||
|
|
6c4640f4f1 | ||
|
|
435dde67d0 | ||
|
|
19e89aa7f2 | ||
|
|
9c92ab63fb | ||
|
|
acc8381cbb | ||
|
|
0a8a7e22d2 | ||
|
|
f1622ac96a | ||
|
|
c66f4d667e | ||
|
|
3bfbaaebf3 | ||
|
|
98b79b132a | ||
|
|
f6221f0007 | ||
|
|
263b375088 | ||
|
|
a1f819e9b8 | ||
|
|
ca7f50f37f | ||
|
|
7b92fe95af | ||
|
|
42ddf0870c | ||
|
|
0512dd2339 | ||
|
|
8ccf0dbd37 | ||
|
|
7c130fc970 | ||
|
|
640e0973ac | ||
|
|
9088875491 | ||
|
|
99ec2cc00a | ||
|
|
99ff7e198a | ||
|
|
d4bb20d34b | ||
|
|
2db001710c | ||
|
|
dd0d0942f5 | ||
|
|
26f48ee9ea | ||
|
|
84f4fbeaa8 | ||
|
|
ce46895270 | ||
|
|
e7933a5387 | ||
|
|
b787281388 | ||
|
|
bba3a9657b | ||
|
|
2f5465c0e8 | ||
|
|
9c40ed5d71 | ||
|
|
93bc02b308 | ||
|
|
b8af90750f | ||
|
|
65ef05dd1f | ||
|
|
731486d9b0 | ||
|
|
731e9fda03 | ||
|
|
35c7496cd7 | ||
|
|
71b746b6fa | ||
|
|
1913818b1c | ||
|
|
b4ad0eb86b | ||
|
|
7f0e826c32 | ||
|
|
76ee3fe887 | ||
|
|
af4a6e7ec3 | ||
|
|
40c3a4320f | ||
|
|
3c2dad67f4 | ||
|
|
f823a23ea4 | ||
|
|
9aed1d2700 | ||
|
|
9857d508d9 | ||
|
|
ad7c980c4b | ||
|
|
e8d37e5bd3 | ||
|
|
a36c3063b2 | ||
|
|
902cf6a679 | ||
|
|
a10e005bb4 | ||
|
|
d9998eff20 | ||
|
|
2dd7ac090f | ||
|
|
c4689989c7 | ||
|
|
ebff49072e | ||
|
|
0b311962b5 | ||
|
|
b22def399f | ||
|
|
cdd334284e | ||
|
|
9caf431740 | ||
|
|
468b32b986 | ||
|
|
39ad583b39 | ||
|
|
90403576c4 | ||
|
|
51fa25856d | ||
|
|
27d4b63a8a | ||
|
|
104c2f86f6 | ||
|
|
e64a79f431 | ||
|
|
b79db76099 | ||
|
|
66ab807596 | ||
|
|
2067da4ff5 | ||
|
|
cfdd6d383f | ||
|
|
bdd51bd476 | ||
|
|
af7b6e3589 | ||
|
|
fd86417bf5 | ||
|
|
e095c457ff | ||
|
|
39c0f9cc84 | ||
|
|
3f94e6dcbb | ||
|
|
b9588a8984 | ||
|
|
105d1362a2 | ||
|
|
bec1158775 | ||
|
|
e7671dd854 | ||
|
|
f6c260a2c5 | ||
|
|
c720186170 | ||
|
|
8ab19c0242 | ||
|
|
2c24e82ba3 | ||
|
|
fcfaa43e1e | ||
|
|
4c8e45a222 | ||
|
|
7878052533 | ||
|
|
e94fcda8b7 | ||
|
|
5738c60206 | ||
|
|
a035d84474 | ||
|
|
a8fd26ef6d | ||
|
|
7ad43358c2 | ||
|
|
187cd51867 | ||
|
|
1310fe685a | ||
|
|
fd16eec416 | ||
|
|
ea14a93e67 | ||
|
|
c456fc98ab | ||
|
|
711d8bb0ad | ||
|
|
c3233739c5 | ||
|
|
5fe36a45ed | ||
|
|
0725157bf5 | ||
|
|
9f589ec4fc | ||
|
|
5417a16f9d | ||
|
|
f447c88b4c | ||
|
|
0b48ca0027 | ||
|
|
bef6773281 | ||
|
|
4f0968f1df | ||
|
|
648f5e1b06 | ||
|
|
65fc10d627 | ||
|
|
41cf842d5d | ||
|
|
792dfbcbbf | ||
|
|
06c7ac37d1 | ||
|
|
0666cb8635 | ||
|
|
5b8bf51022 | ||
|
|
4a7c58d5ae | ||
|
|
7522780c67 | ||
|
|
e4a68c2da3 | ||
|
|
0427d20c5b | ||
|
|
d25d160686 | ||
|
|
0e260e5cbb | ||
|
|
58780cd765 | ||
|
|
1fc73731a8 | ||
|
|
e47feace11 | ||
|
|
4472d3b61b | ||
|
|
06c5bcbc66 | ||
|
|
6c44775bf5 | ||
|
|
b127363fa0 | ||
|
|
782b58bbeb | ||
|
|
5de508ad8c | ||
|
|
054c8ba485 | ||
|
|
3b2be021b2 | ||
|
|
848defedd8 | ||
|
|
4c9ed129cf | ||
|
|
c8b37fb1c8 | ||
|
|
1b556f5d0c | ||
|
|
8a3d0aeafb | ||
|
|
9468ad628c | ||
|
|
990431aa5b | ||
|
|
b79cbcbde7 | ||
|
|
afb5922f37 | ||
|
|
3035355c0a | ||
|
|
43b3aa0712 | ||
|
|
12b78e7c47 | ||
|
|
fb5d8b5d4c | ||
|
|
9e72027b64 | ||
|
|
faa5f72500 | ||
|
|
359c6a7265 | ||
|
|
a031da351d | ||
|
|
091b6a0ba0 | ||
|
|
cf9d97d5ae | ||
|
|
3e1a6b4e11 | ||
|
|
5c7245dfcd | ||
|
|
1611345c55 | ||
|
|
b36e20d60b | ||
|
|
93eb6f78e0 | ||
|
|
e308075e3f | ||
|
|
405607e9d2 | ||
|
|
797ed23765 | ||
|
|
8780c7e0ac | ||
|
|
136e343988 | ||
|
|
b8690c8e31 | ||
|
|
2778c62d07 | ||
|
|
7984349c36 | ||
|
|
0cbbd64577 | ||
|
|
a618ee11b4 | ||
|
|
6251c26cc6 | ||
|
|
4fbe0d87ec | ||
|
|
24d8e99c50 | ||
|
|
14b6a0ede1 | ||
|
|
1793ca5eac | ||
|
|
62e19bcb27 | ||
|
|
84d1c34c28 | ||
|
|
f78cbf9200 | ||
|
|
eb711381d3 | ||
|
|
9a1daa5247 | ||
|
|
f3d61ca3e1 | ||
|
|
29dfd820c6 | ||
|
|
8bc5b97298 | ||
|
|
7a64163c4f | ||
|
|
dfe14f9c3a | ||
|
|
26cf373ae7 | ||
|
|
94ccddf176 | ||
|
|
2ec24505d0 | ||
|
|
4f7faa7712 | ||
|
|
b3dcaac262 | ||
|
|
ee118d384a | ||
|
|
8d36167e11 | ||
|
|
6d559a32df | ||
|
|
bf0d31b3ab | ||
|
|
d8ffb732b7 | ||
|
|
9e8261056c | ||
|
|
5f311d281e | ||
|
|
a336ad6732 | ||
|
|
8434d54cc7 | ||
|
|
b62f7a41c9 | ||
|
|
4ebecf514a | ||
|
|
2f32c88ae5 | ||
|
|
95449068e7 | ||
|
|
556def78aa | ||
|
|
b7803cf9b5 | ||
|
|
40820287f1 | ||
|
|
6210dfb4d0 | ||
|
|
45c712b17b | ||
|
|
8e23065b86 | ||
|
|
d3892b8399 | ||
|
|
bda789052d | ||
|
|
0fb100f2d1 | ||
|
|
fd7fc6b8e8 | ||
|
|
690e8c3fb9 | ||
|
|
a0742a9535 | ||
|
|
ccc57475b7 | ||
|
|
24ec65e68e | ||
|
|
436938b931 | ||
|
|
063c4371de | ||
|
|
7ad4cbf46f | ||
|
|
88875daf52 | ||
|
|
7811781a96 | ||
|
|
da5e11d196 | ||
|
|
75cdabee3d | ||
|
|
f9f28a6a00 | ||
|
|
be8523a73c | ||
|
|
f49bed47de | ||
|
|
239211e521 | ||
|
|
cc646b2213 | ||
|
|
ddf075a2dd | ||
|
|
e304c318fb | ||
|
|
e27390bddb | ||
|
|
6f36ce6429 | ||
|
|
1d5490fd91 | ||
|
|
18c7f60410 | ||
|
|
4f70f6b2a6 | ||
|
|
29a6105730 | ||
|
|
46cf052ec5 | ||
|
|
e5d8c22b47 | ||
|
|
64acb358a5 | ||
|
|
d511f0614d | ||
|
|
eb9ca517e3 | ||
|
|
f769457bb0 | ||
|
|
0fcf512148 | ||
|
|
8c68e95568 | ||
|
|
d8eee03556 | ||
|
|
23933c1b58 | ||
|
|
8c9f4fba8f | ||
|
|
92554f4e67 | ||
|
|
1dfafce06a | ||
|
|
abbcfd213f | ||
|
|
23aff5202b | ||
|
|
91e515ca7c | ||
|
|
538f554bdb | ||
|
|
e96717f6cd | ||
|
|
aff5e248ed | ||
|
|
155439be74 | ||
|
|
fa34c67950 | ||
|
|
b688474444 | ||
|
|
72b663fc40 | ||
|
|
f471329d60 | ||
|
|
e8acd24fd9 | ||
|
|
18f29f8d2b | ||
|
|
cbcf23426d | ||
|
|
e6455c5b44 | ||
|
|
2b8916fc4e | ||
|
|
dae078fc56 | ||
|
|
72686bd4af | ||
|
|
344da7c4f4 | ||
|
|
84de814e6f | ||
|
|
1d32b6c9e0 | ||
|
|
49536693b7 | ||
|
|
a24a63e61b | ||
|
|
e3b644c2a0 | ||
|
|
7763ce958d | ||
|
|
ff001b9e45 | ||
|
|
c2d79c972c | ||
|
|
e0377bbbd3 | ||
|
|
eae65a6b88 | ||
|
|
225d426d95 | ||
|
|
9614bc20a0 | ||
|
|
41747c2de7 | ||
|
|
c70d56807f | ||
|
|
69d700727d | ||
|
|
5fcf656806 | ||
|
|
1ace836744 | ||
|
|
3a4831bd7e | ||
|
|
6c1724874b | ||
|
|
2483576909 | ||
|
|
0df1db6205 | ||
|
|
614eae7a3b | ||
|
|
2fe67a6eda | ||
|
|
e88f69b5f8 | ||
|
|
c69cfd44be | ||
|
|
9f5ed6fdfd | ||
|
|
0891473280 | ||
|
|
2982d7c834 | ||
|
|
026bac4cd1 | ||
|
|
7fcc890e84 |
@@ -1,4 +1,4 @@
|
||||
ARG BUILD_BASE_VERSION=2025.04.0
|
||||
ARG BUILD_BASE_VERSION=2026.06.1
|
||||
|
||||
|
||||
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// uncomment and edit the path in order to pass through local USB serial to the container
|
||||
// , "--device=/dev/ttyACM0"
|
||||
],
|
||||
"appPort": 6052,
|
||||
// if you are using avahi in the host device, uncomment these to allow the
|
||||
// devcontainer to find devices via mdns
|
||||
//"mounts": [
|
||||
@@ -41,7 +40,11 @@
|
||||
],
|
||||
"settings": {
|
||||
"python.languageServer": "Pylance",
|
||||
"python.pythonPath": "/usr/bin/python3",
|
||||
// Use the container's pre-provisioned venv (built by the Dockerfile, outside the
|
||||
// bind-mounted workspace) rather than a ./venv that may leak in from the host and
|
||||
// mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv).
|
||||
"python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"pylint.args": [
|
||||
"--rcfile=${workspaceFolder}/pyproject.toml"
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
@@ -20,6 +21,10 @@
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
|
||||
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
|
||||
|
||||
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
- [ ] ESP32
|
||||
|
||||
@@ -42,7 +42,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
@@ -3,8 +3,10 @@ description: >
|
||||
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
|
||||
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
|
||||
natively (clang-tidy for IDF/Arduino and the component test batches) shares
|
||||
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", so all toolchains are present regardless of the chip).
|
||||
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
|
||||
skips per-variant narrowing whenever CI is set, so all toolchains are
|
||||
present regardless of the chip a job builds.
|
||||
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
|
||||
Python venv already restored.
|
||||
inputs:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Cache nRF Connect SDK
|
||||
description: >
|
||||
Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
|
||||
(west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
|
||||
Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and,
|
||||
once the component tests build natively, their batches) shares one cache.
|
||||
Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have
|
||||
the Python venv already restored.
|
||||
inputs:
|
||||
restore-only:
|
||||
description: >
|
||||
When "true", only restore -- never save the cache, even on dev. Use from
|
||||
jobs that may not produce a complete install (e.g. a component batch
|
||||
that fails mid-install), so a partial install is never written.
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve sdk-nrf and toolchain versions for cache key
|
||||
# Both versions are pinned in code, not in any file that feeds the
|
||||
# other cache keys, so resolve them explicitly. Keying on them means
|
||||
# the cache invalidates when either is bumped (actions/cache never
|
||||
# overwrites a key).
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
version=$(python -c '
|
||||
from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION
|
||||
from esphome.components.nrf52.framework import TOOLCHAIN_VERSION
|
||||
print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
# Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
|
||||
# lives in the default-branch scope readable by all PRs); PRs are
|
||||
# restore-only and never push multi-GB artifacts into their own scope.
|
||||
- name: Cache nRF Connect SDK install (write on dev)
|
||||
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.esphome-sdk-nrf
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
|
||||
- name: Cache nRF Connect SDK install (restore-only off dev)
|
||||
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.esphome-sdk-nrf
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
|
||||
@@ -17,12 +17,12 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
@@ -32,9 +32,12 @@ 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
'merging-to-release',
|
||||
'merging-to-beta',
|
||||
'chained-pr',
|
||||
'stacked-pr',
|
||||
'core',
|
||||
'small-pr',
|
||||
'medium-pr',
|
||||
@@ -22,11 +23,13 @@ module.exports = {
|
||||
'has-tests',
|
||||
'needs-tests',
|
||||
'needs-docs',
|
||||
'needs-developer-docs',
|
||||
'needs-codeowners',
|
||||
'too-big',
|
||||
'labeller-recheck',
|
||||
'bugfix',
|
||||
'new-feature',
|
||||
'new-feature-developer',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
@@ -40,5 +43,17 @@ module.exports = {
|
||||
// Keep matching the old esphome-docs name during the transition period
|
||||
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
|
||||
/esphome\/esphome-docs#\d+/
|
||||
],
|
||||
|
||||
DEVELOPER_DOCS_PR_PATTERNS: [
|
||||
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
|
||||
/esphome\/developers\.esphome\.io#\d+/
|
||||
],
|
||||
|
||||
// Files whose developer-facing changes are documented via Python docstrings
|
||||
// only - developers.esphome.io has no reference page for them yet, so PRs
|
||||
// touching nothing but these files (and tests/) skip needs-developer-docs.
|
||||
DEV_DOCS_EXEMPT_FILES: [
|
||||
'esphome/config_validation.py'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DOCS_PR_PATTERNS } = require('./constants');
|
||||
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
|
||||
const {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
@@ -33,8 +33,41 @@ async function fetchPrFileContent(github, context, path) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a pull request is part of a GitHub stack.
|
||||
//
|
||||
// GitHub's stacked pull request feature adds a `stack` object to the pull
|
||||
// request resource. It is present on every pull request in the stack -
|
||||
// including the bottom one, whose base is already `dev` - and is absent
|
||||
// entirely on standalone pull requests.
|
||||
//
|
||||
// The `pull_request_target` webhook payload is not guaranteed to carry this
|
||||
// field, so fall back to asking the API when it is missing. Guessing wrong
|
||||
// here is costly: a stacked pull request mistaken for a manually chained one
|
||||
// gets a label that blocks merging.
|
||||
async function isStackedPr(github, context) {
|
||||
const pr = context.payload.pull_request;
|
||||
if (pr.stack != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
return data.stack != null;
|
||||
} catch (error) {
|
||||
// Treat an API failure as "not stacked" so a chained pull request still
|
||||
// gets its blocking label rather than silently slipping through.
|
||||
console.log('Failed to check stack membership:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy: Merge branch detection
|
||||
async function detectMergeBranch(context) {
|
||||
async function detectMergeBranch(github, context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
|
||||
@@ -42,7 +75,11 @@ async function detectMergeBranch(context) {
|
||||
labels.add('merging-to-release');
|
||||
} else if (baseRef === 'beta') {
|
||||
labels.add('merging-to-beta');
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== 'dev') {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -147,19 +184,9 @@ async function detectCoreChanges(changedFiles) {
|
||||
}
|
||||
|
||||
// Strategy: PR size detection
|
||||
async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
|
||||
async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
|
||||
const labels = new Set();
|
||||
|
||||
if (totalChanges <= SMALL_PR_THRESHOLD) {
|
||||
labels.add('small-pr');
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (totalChanges <= MEDIUM_PR_THRESHOLD) {
|
||||
labels.add('medium-pr');
|
||||
return labels;
|
||||
}
|
||||
|
||||
const testAdditions = prFiles
|
||||
.filter(file => file.filename.startsWith('tests/'))
|
||||
.reduce((sum, file) => sum + (file.additions || 0), 0);
|
||||
@@ -167,7 +194,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
|
||||
.filter(file => file.filename.startsWith('tests/'))
|
||||
.reduce((sum, file) => sum + (file.deletions || 0), 0);
|
||||
|
||||
const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions);
|
||||
const nonTestAdditions = totalAdditions - testAdditions;
|
||||
const nonTestDeletions = totalDeletions - testDeletions;
|
||||
|
||||
// small/medium count churn (additions + deletions) so a balanced refactor isn't undersized.
|
||||
const nonTestChurn = nonTestAdditions + nonTestDeletions;
|
||||
|
||||
if (nonTestChurn <= SMALL_PR_THRESHOLD) {
|
||||
labels.add('small-pr');
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (nonTestChurn <= MEDIUM_PR_THRESHOLD) {
|
||||
labels.add('medium-pr');
|
||||
return labels;
|
||||
}
|
||||
|
||||
// too-big uses net line delta (additions - deletions), matching the review message in reviews.js.
|
||||
const nonTestChanges = nonTestAdditions - nonTestDeletions;
|
||||
|
||||
// Don't add too-big if mega-pr label is already present
|
||||
if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) {
|
||||
@@ -238,6 +282,7 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
const checkboxPatterns = [
|
||||
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
@@ -348,12 +393,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
const labels = new Set();
|
||||
|
||||
// Check for missing tests
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
|
||||
labels.add('needs-tests');
|
||||
}
|
||||
|
||||
// Check for missing docs.
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
|
||||
// deliberately excluded here: its docs live on developers.esphome.io and are
|
||||
// checked separately below. `new-component` / `new-platform`
|
||||
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
|
||||
// i.e. the new component/platform is actually loadable from YAML.
|
||||
const docsEligible =
|
||||
@@ -369,6 +416,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing developer docs. `new-feature-developer` requires a
|
||||
// developers.esphome.io PR link, unless every changed file outside tests/ is
|
||||
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
|
||||
if (allLabels.has('new-feature-developer')) {
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
const nonTestFiles = prFiles
|
||||
.map(file => file.filename)
|
||||
.filter(file => !file.startsWith('tests/'));
|
||||
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
|
||||
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
|
||||
|
||||
if (!onlyExemptFiles && !hasDevDocsLink) {
|
||||
labels.add('needs-developer-docs');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing CODEOWNERS
|
||||
if (allLabels.has('new-component')) {
|
||||
const codeownersModified = prFiles.some(file =>
|
||||
|
||||
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
|
||||
|
||||
// Early exit for release and beta branches only
|
||||
if (baseRef === 'release' || baseRef === 'beta') {
|
||||
const branchLabels = await detectMergeBranch(context);
|
||||
const branchLabels = await detectMergeBranch(github, context);
|
||||
const finalLabels = Array.from(branchLabels);
|
||||
|
||||
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
|
||||
@@ -118,12 +118,12 @@ module.exports = async ({ github, context }) => {
|
||||
deprecatedResult,
|
||||
maintainerAccess
|
||||
] = await Promise.all([
|
||||
detectMergeBranch(context),
|
||||
detectMergeBranch(github, context),
|
||||
detectComponentPlatforms(changedFiles, apiData),
|
||||
detectNewComponents(github, context, prFiles),
|
||||
detectNewPlatforms(github, context, prFiles, apiData),
|
||||
detectCoreChanges(changedFiles),
|
||||
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
|
||||
detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
|
||||
detectDashboardChanges(changedFiles),
|
||||
detectGitHubActionsChanges(changedFiles),
|
||||
detectCodeOwner(github, context, changedFiles),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { detectNewPlatforms, detectNewComponents } = require('../detectors');
|
||||
const {
|
||||
detectMergeBranch,
|
||||
detectNewPlatforms,
|
||||
detectNewComponents,
|
||||
detectPRSize,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectRequirements,
|
||||
} = require('../detectors');
|
||||
const { MANAGED_LABELS } = require('../constants');
|
||||
|
||||
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
|
||||
// to check for CONFIG_SCHEMA in newly added files.
|
||||
@@ -29,6 +37,107 @@ const API_DATA = {
|
||||
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
|
||||
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectMergeBranch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request }
|
||||
};
|
||||
}
|
||||
|
||||
// A GitHub API mock exposing only rest.pulls.get, with a call counter so
|
||||
// tests can assert whether the API fallback was actually invoked.
|
||||
function makeStackGithub({ stack = null, error = null } = {}) {
|
||||
const state = { calls: 0 };
|
||||
const github = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => {
|
||||
state.calls++;
|
||||
if (error) throw error;
|
||||
return { data: { stack } };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return { github, state };
|
||||
}
|
||||
|
||||
const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
|
||||
|
||||
describe('detectMergeBranch', () => {
|
||||
it('base ref release adds merging-to-release only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('release', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('beta', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('dev', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('not stacked, base ref not dev adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
it('not stacked, base ref dev adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectNewPlatforms
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,3 +254,198 @@ describe('detectNewComponents', () => {
|
||||
assert.equal(result.labels.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRTemplateCheckboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
|
||||
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
|
||||
function makeBodyContext(body) {
|
||||
return { payload: { pull_request: { body } } };
|
||||
}
|
||||
|
||||
describe('detectPRTemplateCheckboxes', () => {
|
||||
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature-developer'));
|
||||
assert.ok(!labels.has('new-feature'));
|
||||
});
|
||||
|
||||
it('unticked developer-facing feature checkbox adds no label', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('ticked new feature checkbox does not add new-feature-developer', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature'));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectRequirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectRequirements', () => {
|
||||
// PR body without any docs-PR link.
|
||||
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
|
||||
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
|
||||
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
|
||||
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
|
||||
|
||||
// File sets: a normal source change vs. one confined to the exempt core validators.
|
||||
const SOURCE_FILES = [
|
||||
{ filename: 'esphome/components/foo/foo.py' },
|
||||
{ filename: 'tests/components/foo/common.yaml' },
|
||||
];
|
||||
const VALIDATOR_FILES = [
|
||||
{ filename: 'esphome/config_validation.py' },
|
||||
{ filename: 'tests/unit_tests/test_config_validation.py' },
|
||||
];
|
||||
|
||||
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-tests'));
|
||||
assert.ok(!labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer with has-tests does not add needs-tests', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-tests'));
|
||||
});
|
||||
|
||||
it('new-feature without a docs link still adds needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('validator changes mixed with other source files are not exempt', async () => {
|
||||
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGED_LABELS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MANAGED_LABELS', () => {
|
||||
it('includes new-feature-developer so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('includes needs-developer-docs so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectPRSize', () => {
|
||||
const SMALL = 30;
|
||||
const MEDIUM = 100;
|
||||
const TOO_BIG = 1000;
|
||||
|
||||
function size(prFiles, isMegaPR = false) {
|
||||
const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0);
|
||||
const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0);
|
||||
return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG);
|
||||
}
|
||||
|
||||
it('counts only non-test changes toward small-pr', async () => {
|
||||
// 10 source + 5000 test lines -> non-test churn of 10 is still small.
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 },
|
||||
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
|
||||
]);
|
||||
assert.ok(labels.has('small-pr'));
|
||||
assert.equal(labels.size, 1);
|
||||
});
|
||||
|
||||
it('counts additions and deletions as churn (not net delta)', async () => {
|
||||
// A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small.
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 },
|
||||
]);
|
||||
assert.ok(labels.has('medium-pr'));
|
||||
assert.equal(labels.size, 1);
|
||||
});
|
||||
|
||||
it('labels medium-pr when non-test changes exceed small threshold', async () => {
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 },
|
||||
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
|
||||
]);
|
||||
assert.ok(labels.has('medium-pr'));
|
||||
assert.equal(labels.size, 1);
|
||||
});
|
||||
|
||||
it('uses net delta (not churn) for too-big', async () => {
|
||||
// 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big.
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 },
|
||||
]);
|
||||
assert.equal(labels.size, 0);
|
||||
});
|
||||
|
||||
it('labels too-big when non-test changes exceed the big threshold', async () => {
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
|
||||
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
|
||||
]);
|
||||
assert.ok(labels.has('too-big'));
|
||||
assert.equal(labels.size, 1);
|
||||
});
|
||||
|
||||
it('does not label too-big when mega-pr is set', async () => {
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
|
||||
], true);
|
||||
assert.equal(labels.size, 0);
|
||||
});
|
||||
|
||||
it('produces no size label for a large mega-pr in the gap above medium', async () => {
|
||||
// Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big.
|
||||
const labels = await size([
|
||||
{ filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 },
|
||||
], true);
|
||||
assert.equal(labels.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
|
||||
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
|
||||
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
|
||||
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
|
||||
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
|
||||
|
||||
- name: Auto Label PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
@@ -21,17 +21,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
# would only consume quota.
|
||||
save-cache: "false"
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
|
||||
@@ -61,21 +61,23 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Determine tag and whether to push
|
||||
id: tag
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
# Sanitize the branch name into a valid docker tag: replace invalid
|
||||
# characters, ensure the first character is valid (tags must start
|
||||
# with [A-Za-z0-9_]), and cap the length at 128 characters.
|
||||
branch="${{ github.head_ref || github.ref_name }}"
|
||||
branch="$HEAD_REF"
|
||||
tag="${branch//[^a-zA-Z0-9_.-]/-}"
|
||||
case "$tag" in
|
||||
[a-zA-Z0-9_]*) ;;
|
||||
@@ -96,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -145,16 +147,16 @@ jobs:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -202,7 +204,7 @@ jobs:
|
||||
- nrf52
|
||||
- host
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Run tests
|
||||
working-directory: .github/scripts/auto-label-pr
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Check out code from base repository
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Always check out from the base repository (esphome/esphome), never from forks
|
||||
# Use the PR's target branch to ensure we run trusted code from the main repo
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache-key: ${{ hashFiles('.cache-key') }}
|
||||
|
||||
- name: Download memory analysis artifacts
|
||||
|
||||
+428
-267
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
# yamllint disable rule:line-length
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -92,22 +92,22 @@ jobs:
|
||||
os: "ubuntu-24.04-arm"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
- ghcr
|
||||
- dockerhub
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -178,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+35
-50
@@ -6,61 +6,46 @@ on:
|
||||
- cron: "30 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write # actions/stale labels, comments on, and closes stale issues
|
||||
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
|
||||
# needs no permissions at all.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository_owner == 'esphome'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 400
|
||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||
# GitHub App token so the labels, comments and closures come from
|
||||
# esphome[bot] instead of github-actions[bot].
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
# Live only on dev: a workflow_dispatch from any other branch is a dry run
|
||||
dry-run: ${{ github.ref != 'refs/heads/dev' }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
stale-label: stale
|
||||
exempt-label: not-stale
|
||||
ignored-users: esphbot,codecov-commenter
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
# The 90 day stale policy for PRs
|
||||
# - PRs
|
||||
# - No PRs marked as "not-stale"
|
||||
# - No Issues (see below)
|
||||
days-before-pr-stale: 90
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "not-stale"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
Thank you for your contribution!
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Thank you for your contribution!
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
# The 90 day stale policy for Issues
|
||||
# - Issues
|
||||
# - No Issues marked as "not-stale"
|
||||
# - No PRs (see above)
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "not-stale"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -28,16 +28,16 @@ jobs:
|
||||
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Checkout Home Assistant
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: home-assistant/core
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
@@ -96,8 +96,8 @@ jobs:
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphomebot <esphome@openhomefoundation.org>
|
||||
author: esphomebot <esphome@openhomefoundation.org>
|
||||
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
branch: sync/device-classes
|
||||
delete-branch: true
|
||||
title: "Synchronise Device Classes from Home Assistant"
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.15
|
||||
rev: v0.16.0
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -40,7 +40,7 @@ repos:
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py311-plus]
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
|
||||
@@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
## 2. Core Technologies & Stack
|
||||
|
||||
* **Languages:** Python (>=3.11), C++ (gnu++20)
|
||||
* **Languages:** Python (>=3.12), C++ (gnu++20)
|
||||
* **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
|
||||
* **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
|
||||
* **Configuration:** YAML.
|
||||
@@ -191,11 +191,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -229,7 +232,12 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
@@ -238,7 +246,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
@@ -246,7 +257,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
@@ -263,10 +277,13 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -316,11 +333,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
})
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
@@ -368,7 +388,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
@@ -381,6 +404,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -427,13 +451,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — use packages for buses
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml — component config only, NO bus definitions
|
||||
@@ -470,7 +495,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
4. **Lint:** Run `pre-commit` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
@@ -616,6 +641,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
@@ -635,20 +661,24 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
@@ -703,9 +733,27 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
|
||||
* **Deprecation Pattern (Python):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({ ... }),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
|
||||
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
|
||||
audience, including non-native English speakers.
|
||||
|
||||
+17
-1
@@ -69,11 +69,14 @@ esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
esphome/components/bk72xx/* @kuba2k2
|
||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
@@ -122,7 +125,9 @@ esphome/components/cover/* @esphome/core
|
||||
esphome/components/cs5460a/* @balrog-kun
|
||||
esphome/components/cse7761/* @berfenger
|
||||
esphome/components/cst226/* @clydebarrow
|
||||
esphome/components/cst328/* @latonita
|
||||
esphome/components/cst816/* @clydebarrow
|
||||
esphome/components/cst9220/* @clydebarrow
|
||||
esphome/components/ct_clamp/* @jesserockz
|
||||
esphome/components/current_based/* @djwmarcx
|
||||
esphome/components/dac7678/* @NickB1
|
||||
@@ -143,6 +148,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||
esphome/components/dps310/* @kbx81
|
||||
esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -185,6 +191,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
@@ -206,6 +213,7 @@ esphome/components/gree/switch/* @nagyrobi
|
||||
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
|
||||
esphome/components/grove_tb6612fng/* @max246
|
||||
esphome/components/growatt_solar/* @leeuwte
|
||||
esphome/components/gsl3670/* @clydebarrow
|
||||
esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
@@ -266,6 +274,7 @@ esphome/components/integration/* @OttoWinter
|
||||
esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
esphome/components/json/* @esphome/core
|
||||
esphome/components/kamstrup_kmp/* @cfeenstra1024
|
||||
@@ -286,6 +295,7 @@ esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
@@ -385,6 +395,7 @@ esphome/components/pcm5122/* @remcom
|
||||
esphome/components/pi4ioe5v6408/* @jesserockz
|
||||
esphome/components/pid/* @OttoWinter
|
||||
esphome/components/pipsolar/* @andreashergert1984
|
||||
esphome/components/pixoo/* @jesserockz
|
||||
esphome/components/pm1006/* @habbie
|
||||
esphome/components/pm2005/* @andrewjswan
|
||||
esphome/components/pmsa003i/* @sjtrny
|
||||
@@ -400,10 +411,12 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_spi/* @jesserockz @kbx81
|
||||
esphome/components/power_supply/* @esphome/core
|
||||
esphome/components/preferences/* @esphome/core
|
||||
esphome/components/provisioning/* @esphome/core
|
||||
esphome/components/psram/* @esphome/core
|
||||
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
|
||||
esphome/components/pvvx_mithermometer/* @pasiz
|
||||
esphome/components/pylontech/* @functionpointer
|
||||
esphome/components/qmi8658/* @clydebarrow
|
||||
esphome/components/qmp6988/* @andrewpc
|
||||
esphome/components/qr_code/* @wjtje
|
||||
esphome/components/qspi_dbi/* @clydebarrow
|
||||
@@ -421,7 +434,7 @@ esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/ring_buffer/* @kahrendt
|
||||
esphome/components/router/speaker/* @kahrendt
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
@@ -497,6 +510,7 @@ esphome/components/ssd1331_base/* @kbx81
|
||||
esphome/components/ssd1331_spi/* @kbx81
|
||||
esphome/components/ssd1351_base/* @kbx81
|
||||
esphome/components/ssd1351_spi/* @kbx81
|
||||
esphome/components/st7123/* @miniskipper
|
||||
esphome/components/st7567_base/* @latonita
|
||||
esphome/components/st7567_i2c/* @latonita
|
||||
esphome/components/st7567_spi/* @latonita
|
||||
@@ -578,6 +592,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
|
||||
esphome/components/watchdog/* @oarcher
|
||||
esphome/components/water_heater/* @dhoeben
|
||||
esphome/components/waveshare_epaper/* @clydebarrow
|
||||
esphome/components/waveshare_io_ch32v003/* @latonita
|
||||
esphome/components/web_server/ota/* @esphome/core
|
||||
esphome/components/web_server_base/* @esphome/core
|
||||
esphome/components/web_server_idf/* @dentra
|
||||
@@ -613,6 +628,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.7.0-dev
|
||||
PROJECT_NUMBER = 2026.8.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
|
||||
recursive-include esphome *.py.script
|
||||
recursive-include esphome *.jinja
|
||||
recursive-include esphome LICENSE.txt
|
||||
recursive-include esphome requirements.txt
|
||||
|
||||
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
||||
below their documented guarantees.
|
||||
|
||||
## The web server is an open HTTP API by design
|
||||
|
||||
The `web_server` component exposes a plain HTTP interface for viewing and
|
||||
controlling entities, and, when the `web_server` OTA platform is enabled, for
|
||||
uploading firmware at `/update`. Its only access controls are the optional
|
||||
`web_server` `auth:` credentials and the network the device sits on.
|
||||
|
||||
When `auth:` is not configured, every endpoint is reachable by any client that
|
||||
can reach the device. This is intentional; enabling `web_server` without `auth:`
|
||||
is choosing an open control surface, in the same way that running native OTA
|
||||
without a password leaves OTA open. The API is documented and is meant to be
|
||||
called by other devices, scripts, and pages.
|
||||
|
||||
As defense-in-depth, the web server checks the `Origin` header on browser requests
|
||||
to its entity control and state endpoints: a request whose `Origin` does not match
|
||||
the address the device is served on is rejected, and the `allowed_origins` option
|
||||
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
|
||||
the operator visits drives the device through their browser. It is **not** an
|
||||
authentication boundary: it only constrains browsers. Any client that omits the
|
||||
`Origin` header — `curl`, scripts, or other non-browser callers on the same
|
||||
network — reaches every endpoint exactly as before. The check also does not cover
|
||||
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
|
||||
validation. The following are therefore **not** vulnerabilities in this repository:
|
||||
|
||||
- Requests without an `Origin` header (for example `curl`) reaching the control
|
||||
endpoints, whether or not `web_server` `auth:` is set.
|
||||
- Requests from an origin the operator added to `allowed_origins`.
|
||||
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
|
||||
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
|
||||
covered by the `Origin` check; this is the same exposure as running OTA without a
|
||||
password.
|
||||
|
||||
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
|
||||
a native OTA password), and keeping devices on a trusted, segmented network. See
|
||||
the security best practices guide linked above.
|
||||
|
||||
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
|
||||
and any memory-safety or protocol bug in the server reachable without credentials.
|
||||
|
||||
This section documents the current design and scope; it is not a judgment that the
|
||||
design is optimal or that it will not change.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
|
||||
- Attacks that require an already-authenticated device peer (someone who already
|
||||
holds the API key / OTA / web credentials).
|
||||
- Access to the device web server or its web OTA endpoint by non-browser clients
|
||||
(those that send no `Origin` header). The web server is an open HTTP API by
|
||||
design (see above); browser cross-origin requests are blocked by default, but the
|
||||
real controls are `web_server` `auth:` and network isolation.
|
||||
- Anything in the dashboard / device-builder — report that in its own repository
|
||||
(linked at the top).
|
||||
- Deployments where the operator removed protections or exposed credentials. See
|
||||
|
||||
+2
-12
@@ -1,5 +1,5 @@
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_BASE_VERSION=2026.06.0
|
||||
ARG BUILD_BASE_VERSION=2026.06.1
|
||||
ARG BUILD_TYPE=docker
|
||||
|
||||
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker
|
||||
@@ -11,16 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base
|
||||
RUN git config --system --add safe.directory "*" \
|
||||
&& git config --system advice.detachedHead false
|
||||
|
||||
# Install build tools for Python packages that require compilation
|
||||
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
|
||||
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
|
||||
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
|
||||
# it idf_tools.py rejects the openocd install with exit 127 and aborts
|
||||
# the whole framework setup.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN pip install --no-cache-dir -U pip uv==0.10.1
|
||||
@@ -32,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -21,6 +21,11 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent cache root, not the
|
||||
# container's ephemeral user cache dir (re-downloaded on every restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
|
||||
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
|
||||
|
||||
# If /build is mounted, use that as the build path
|
||||
# otherwise use path in /config (so that builds aren't lost on container restart)
|
||||
if [[ -d /build ]]; then
|
||||
|
||||
@@ -15,18 +15,15 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent /data volume, not the
|
||||
# container's ephemeral user cache dir (wiped on every add-on update/restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
|
||||
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
|
||||
|
||||
if bashio::config.true 'leave_front_door_open'; then
|
||||
export DISABLE_HA_AUTHENTICATION=true
|
||||
fi
|
||||
|
||||
if bashio::config.true 'streamer_mode'; then
|
||||
export ESPHOME_STREAMER_MODE=true
|
||||
fi
|
||||
|
||||
if bashio::config.has_value 'relative_url'; then
|
||||
export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url')
|
||||
fi
|
||||
|
||||
if bashio::config.has_value 'default_compile_process_limit'; then
|
||||
export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit')
|
||||
else
|
||||
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: docker-test-ln882x-arduino
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882hki
|
||||
board: generic-ln882h
|
||||
|
||||
logger:
|
||||
|
||||
+72
-32
@@ -16,14 +16,10 @@ import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
@@ -52,11 +48,6 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_RP2040,
|
||||
SECRETS_FILES,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -230,8 +221,9 @@ def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
|
||||
Returns:
|
||||
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
|
||||
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
|
||||
then fall back to whatever default OTA address they normally use.
|
||||
mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address).
|
||||
Callers should then fall back to whatever default OTA address they
|
||||
normally use.
|
||||
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
|
||||
back to the base name: with ``name_add_mac_suffix`` enabled, the base
|
||||
name by definition doesn't exist on the network.
|
||||
@@ -241,7 +233,7 @@ def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
|
||||
already have without opening a second Zeroconf client.
|
||||
"""
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()):
|
||||
return None
|
||||
from esphome.zeroconf import discover_mdns_devices
|
||||
|
||||
@@ -359,7 +351,7 @@ def choose_upload_log_host(
|
||||
bootsel_permission_error = False
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
and CORE.is_rp2
|
||||
and (picotool := _find_picotool()) is not None
|
||||
):
|
||||
bootsel = detect_rp2040_bootsel(picotool)
|
||||
@@ -406,7 +398,7 @@ def choose_upload_log_host(
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
and CORE.is_rp2
|
||||
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
|
||||
):
|
||||
if bootsel_permission_error:
|
||||
@@ -508,17 +500,22 @@ def has_mdns() -> bool:
|
||||
|
||||
|
||||
def has_non_ip_address() -> bool:
|
||||
"""Check if CORE.address is set and is not an IP address."""
|
||||
"""Check if ``CORE.address`` is set and is not an IP address."""
|
||||
return CORE.address is not None and not is_ip_address(CORE.address)
|
||||
|
||||
|
||||
def has_mdns_address() -> bool:
|
||||
"""Check if ``CORE.address`` is a ``.local`` mDNS hostname."""
|
||||
return CORE.address is not None and CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def has_ip_address() -> bool:
|
||||
"""Check if CORE.address is a valid IP address."""
|
||||
"""Check if ``CORE.address`` is a valid IP address."""
|
||||
return CORE.address is not None and is_ip_address(CORE.address)
|
||||
|
||||
|
||||
def has_resolvable_address() -> bool:
|
||||
"""Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address)."""
|
||||
"""Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address)."""
|
||||
# Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable
|
||||
# The resolve_ip_address() function in helpers.py handles all types via AsyncResolver
|
||||
if CORE.address is None:
|
||||
@@ -537,7 +534,7 @@ def has_resolvable_address() -> bool:
|
||||
return True
|
||||
|
||||
# .local mDNS hostnames are only resolvable if mDNS is enabled
|
||||
return not CORE.address.endswith(".local")
|
||||
return not has_mdns_address()
|
||||
|
||||
|
||||
def has_name_add_mac_suffix() -> bool:
|
||||
@@ -703,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
|
||||
def _wrap_to_code(name, comp, yaml_util):
|
||||
import esphome.codegen as cg
|
||||
|
||||
coro = coroutine(comp.to_code)
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
@@ -738,6 +737,7 @@ def write_cpp(config: ConfigType) -> int:
|
||||
|
||||
def generate_cpp_contents(config: ConfigType) -> None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import iter_component_configs
|
||||
|
||||
_LOGGER.info("Generating C++ source...")
|
||||
|
||||
@@ -775,6 +775,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -984,7 +991,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||
# Create it here so the upload doesn't fail.
|
||||
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040:
|
||||
if CORE.is_rp2:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
build_dir = Path(idedata.firmware_elf_path).parent
|
||||
firmware_bin = build_dir / "firmware.bin"
|
||||
@@ -1169,10 +1176,10 @@ def upload_program(
|
||||
check_permissions(host)
|
||||
|
||||
exit_code = 1
|
||||
if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
|
||||
if CORE.is_esp32 or CORE.is_esp8266:
|
||||
file = getattr(args, "file", None)
|
||||
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
|
||||
elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
|
||||
elif CORE.is_rp2 or CORE.is_libretiny:
|
||||
exit_code = upload_using_platformio(config, host)
|
||||
# else: Unknown target platform, exit_code remains 1
|
||||
|
||||
@@ -1456,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
|
||||
|
||||
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import strip_default_ids
|
||||
|
||||
if getattr(args, "no_defaults", False):
|
||||
user_config = getattr(config, "user_config", None)
|
||||
@@ -1493,12 +1501,37 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
|
||||
|
||||
def _redact_with_legacy_fallback(output: str) -> str:
|
||||
unmarked: set[str] = set()
|
||||
# Track the top-level ``substitutions:`` block. Its keys are arbitrary
|
||||
# user-chosen names with no schema validator, so the ``cv.sensitive(...)``
|
||||
# migration named in the warning can't be applied to them. Their values are
|
||||
# still redacted, but emitting the (unactionable) deprecation warning would
|
||||
# only confuse users.
|
||||
in_substitutions = False
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
unmarked.add(m.group("key"))
|
||||
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
|
||||
|
||||
output = _LEGACY_REDACTION_RE.sub(_replace, output)
|
||||
lines = output.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# A non-indented, non-blank line is a top-level key that opens or
|
||||
# closes the substitutions block.
|
||||
if line and not line[0].isspace():
|
||||
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
for key in sorted(unmarked):
|
||||
_LOGGER.warning(
|
||||
"Field '%s' is being redacted by a legacy substring heuristic. "
|
||||
@@ -1629,10 +1662,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
# After BOOTSEL upload, wait for a new serial port to appear
|
||||
# so it shows up in the log chooser
|
||||
if (
|
||||
successful_device is None
|
||||
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
|
||||
):
|
||||
if successful_device is None and CORE.is_rp2:
|
||||
_wait_for_serial_port(known_ports=pre_upload_ports)
|
||||
# If exactly one new serial port appeared, use it directly
|
||||
serial_ports = get_serial_ports()
|
||||
@@ -2377,7 +2407,10 @@ def parse_args(argv):
|
||||
)
|
||||
|
||||
parser_clean_all = subparsers.add_parser(
|
||||
"clean-all", help="Clean all build and platform files."
|
||||
"clean-all",
|
||||
help="Clean all build and platform files, including machine-global "
|
||||
"toolchain caches shared by all configurations, so other projects will "
|
||||
"re-download them on next build.",
|
||||
)
|
||||
parser_clean_all.add_argument(
|
||||
"configuration", help="Your YAML file or configuration directory.", nargs="*"
|
||||
@@ -2465,7 +2498,12 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
# argcomplete only does anything when the shell-completion machinery
|
||||
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
|
||||
if "_ARGCOMPLETE" in os.environ:
|
||||
import argcomplete
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
|
||||
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
@@ -2564,6 +2602,8 @@ def run_esphome(argv):
|
||||
)
|
||||
|
||||
if config is None:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
|
||||
@@ -20,6 +20,7 @@ from . import (
|
||||
RAM_SECTIONS,
|
||||
MemoryAnalyzer,
|
||||
)
|
||||
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ComponentMemory
|
||||
@@ -759,45 +760,25 @@ def main():
|
||||
print(f"Error: {build_path} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find firmware.elf
|
||||
elf_file = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_file = str(elf_candidate)
|
||||
break
|
||||
|
||||
if not elf_file:
|
||||
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
|
||||
elf_path = find_elf_path(build_path)
|
||||
if not elf_path:
|
||||
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find idedata.json - check current directory first, then home
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
elf_file = str(elf_path)
|
||||
|
||||
idedata = None
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
if idedata_path := find_idedata_path(build_path):
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
|
||||
|
||||
if not idedata:
|
||||
print(
|
||||
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
|
||||
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
|
||||
|
||||
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
|
||||
analyzer.analyze()
|
||||
|
||||
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -65,6 +65,7 @@ class RamSymbol:
|
||||
size: int
|
||||
section: str
|
||||
demangled: str = "" # Demangled name, set after batch demangling
|
||||
aliases: list[str] = field(default_factory=list) # Other names at same address
|
||||
|
||||
|
||||
class RamStringsAnalyzer:
|
||||
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return
|
||||
|
||||
# Track symbols by address so aliases (multiple names for the same
|
||||
# object, e.g. the newlib __lock___* mutexes that all alias one
|
||||
# StaticSemaphore_t) are reported once instead of once per name.
|
||||
symbols_by_addr: dict[int, RamSymbol] = {}
|
||||
|
||||
for line in output.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
|
||||
if sym_type not in DATA_SYMBOL_TYPES:
|
||||
continue
|
||||
|
||||
if (existing := symbols_by_addr.get(addr)) is not None:
|
||||
# Prefer a global (uppercase type) name as the primary so
|
||||
# nm output order can't hide it behind a local alias.
|
||||
if sym_type.isupper() and existing.sym_type.islower():
|
||||
existing.aliases.append(existing.name)
|
||||
existing.name = name
|
||||
existing.sym_type = sym_type
|
||||
else:
|
||||
existing.aliases.append(name)
|
||||
existing.size = max(existing.size, size)
|
||||
continue
|
||||
|
||||
# Check if symbol is in a RAM section
|
||||
for section_name in self.ram_sections:
|
||||
if section_name not in self.sections:
|
||||
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
|
||||
|
||||
section = self.sections[section_name]
|
||||
if section.address <= addr < section.address + section.size:
|
||||
self.ram_symbols.append(
|
||||
RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
)
|
||||
symbol = RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
)
|
||||
symbols_by_addr[addr] = symbol
|
||||
self.ram_symbols.append(symbol)
|
||||
break
|
||||
|
||||
def _demangle_symbols(self) -> None:
|
||||
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
|
||||
for symbol in largest_symbols:
|
||||
# Use demangled name if available, otherwise raw name
|
||||
display_name = symbol.demangled or symbol.name
|
||||
name_display = display_name[:49] if len(display_name) > 49 else display_name
|
||||
# Truncate the name, not the alias note, so merged aliases stay
|
||||
# visible even for long demangled C++ names.
|
||||
alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
|
||||
max_name_len = 49 - len(alias_note)
|
||||
if len(display_name) > max_name_len:
|
||||
display_name = display_name[:max_name_len]
|
||||
name_display = display_name + alias_note
|
||||
lines.append(
|
||||
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
|
||||
)
|
||||
|
||||
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
|
||||
]
|
||||
|
||||
|
||||
def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""Locate the firmware ELF inside an ESPHome build directory.
|
||||
|
||||
The layout depends on the toolchain that produced the build, so try each
|
||||
known one in turn.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the ELF file, or None if no known layout matches
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / name / "firmware.elf",
|
||||
# LibreTiny uses raw_firmware.elf
|
||||
build_path / "raw_firmware.elf",
|
||||
build_path / ".pioenvs" / name / "raw_firmware.elf",
|
||||
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def idedata_candidates(build_path: Path) -> list[Path]:
|
||||
"""Return the idedata locations searched for a build directory, in order.
|
||||
|
||||
Exposed so a caller reporting "not found" can name the paths it tried
|
||||
without keeping its own copy of the list.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
The candidate idedata JSON paths, most specific first
|
||||
"""
|
||||
name = build_path.name
|
||||
return [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / name / "idedata.json",
|
||||
# Both toolchains cache it in the data dir, which holds this build dir:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
build_path.parent.parent / "idedata" / f"{name}.json",
|
||||
# Regular builds, invoked from the config dir or from anywhere
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{name}.json",
|
||||
]
|
||||
|
||||
|
||||
def find_idedata_path(build_path: Path) -> Path | None:
|
||||
"""Locate the idedata JSON belonging to an ESPHome build directory.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the idedata JSON, or None if it was not found
|
||||
"""
|
||||
for candidate in idedata_candidates(build_path):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
"""Search for a tool in PlatformIO package directories.
|
||||
|
||||
|
||||
@@ -12,12 +12,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import threading
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
class AsyncThreadRunner[T](threading.Thread):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
@@ -35,10 +32,10 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
result = runner.result
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self._coro_factory = coro_factory
|
||||
self.result: _T | None = None
|
||||
self.result: T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.event = threading.Event()
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from pathlib import Path
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags
|
||||
from esphome.framework_helpers import (
|
||||
get_project_compile_flags,
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
|
||||
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
||||
@@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
|
||||
if CORE.cpp_standard
|
||||
@@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
{managed_components_property}
|
||||
@@ -196,15 +210,27 @@ def get_component_cmakelists() -> str:
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -108,7 +108,6 @@ Import("env")
|
||||
def write_cxx_flags_script() -> None:
|
||||
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
|
||||
contents = CXX_FLAGS_FILE_CONTENTS
|
||||
if not CORE.is_host:
|
||||
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
|
||||
contents += "\n"
|
||||
for flag in sorted(CORE.cxx_build_flags):
|
||||
contents += f'env.Append(CXXFLAGS=["{flag}"])\n'
|
||||
write_file_if_changed(path, contents)
|
||||
|
||||
+135
-3
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
@@ -49,6 +51,7 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -120,6 +123,126 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = BundleData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def add_bundle_file(path: Path) -> None:
|
||||
"""Register a file that a bundle must include.
|
||||
|
||||
Bundle discovery walks the validated config, so it only finds files the config
|
||||
names. Components call this during validation for files it cannot see, such as a
|
||||
file that is referenced from inside another file.
|
||||
|
||||
A relative path is taken as relative to the config directory. Files outside the
|
||||
config directory are skipped when the bundle is built.
|
||||
"""
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -146,6 +269,7 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -286,13 +410,18 @@ class ConfigBundleCreator:
|
||||
with known file extensions are also resolved and checked.
|
||||
|
||||
Core ESPHome concepts that use relative paths or directories
|
||||
are handled explicitly.
|
||||
are handled explicitly. Files the config does not name at all are
|
||||
registered by their component with add_bundle_file().
|
||||
"""
|
||||
config = self._config
|
||||
|
||||
# Generic walk: find all file paths in the validated config
|
||||
self._walk_config_for_files(config)
|
||||
|
||||
# Files registered by components during validation
|
||||
for extra_file in _get_data().extra_files:
|
||||
self._add_file(extra_file)
|
||||
|
||||
# --- Core ESPHome concepts needing explicit handling ---
|
||||
|
||||
# esphome.includes / includes_c - can be relative paths and directories
|
||||
@@ -405,6 +534,7 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -489,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add,
|
||||
add_build_flag,
|
||||
add_build_unflag,
|
||||
add_cxx_build_flag,
|
||||
add_define,
|
||||
add_global,
|
||||
add_library,
|
||||
@@ -52,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Importing `esphome.loader` here installs the component-alias
|
||||
# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
|
||||
# `from esphome.components import <legacy_alias>` from a fresh interpreter
|
||||
# can race the finder install and raise ImportError, since the legacy
|
||||
# alias dir no longer exists on disk.
|
||||
from esphome import loader as _loader # noqa: F401
|
||||
|
||||
@@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() {
|
||||
if (this->buffer_[3] == checksum) {
|
||||
float distance = (this->buffer_[1] << 8) + this->buffer_[2];
|
||||
if (distance > 280) {
|
||||
float meters = distance / 1000.0;
|
||||
float meters = distance / 1000.0f;
|
||||
ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters);
|
||||
this->publish_state(meters);
|
||||
} else {
|
||||
|
||||
@@ -216,7 +216,7 @@ void AcDimmer::setup() {
|
||||
}
|
||||
|
||||
void AcDimmer::write_state(float state) {
|
||||
state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation
|
||||
state = std::acos(1 - (2 * state)) / std::numbers::pi_v<float>; // RMS power compensation
|
||||
auto new_value = static_cast<uint16_t>(roundf(state * 65535));
|
||||
if (new_value != 0 && this->store_.value == 0)
|
||||
this->store_.init_cycle = this->init_with_half_cycle_;
|
||||
|
||||
@@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
|
||||
|
||||
def validate_adc_pin(value):
|
||||
if str(value).upper() == "VCC":
|
||||
if CORE.is_rp2040:
|
||||
if CORE.is_rp2:
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2040("TEMPERATURE")
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
if CORE.is_esp32:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
@@ -261,11 +261,11 @@ def validate_adc_pin(value):
|
||||
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_rp2040:
|
||||
if CORE.is_rp2:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
number = conf[CONF_NUMBER]
|
||||
if number not in (26, 27, 28, 29):
|
||||
raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
|
||||
raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_libretiny:
|
||||
|
||||
@@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
void set_autorange(bool autorange) { this->autorange_ = autorange; }
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
void set_is_temperature() { this->is_temperature_ = true; }
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
|
||||
protected:
|
||||
uint8_t sample_count_{1};
|
||||
@@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
static adc_oneshot_unit_handle_t shared_adc_handles[2];
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
bool is_temperature_{false};
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
const struct adc_dt_spec *channel_ = nullptr;
|
||||
|
||||
+11
-8
@@ -1,4 +1,4 @@
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
|
||||
#include "adc_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2040";
|
||||
static const char *const TAG = "adc.rp2";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
@@ -66,15 +66,18 @@ float ADCSensor::sample() {
|
||||
}
|
||||
|
||||
uint8_t pin = this->pin_->get_pin();
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
// Measuring VSYS on Raspberry Pico W needs to be wrapped with
|
||||
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
|
||||
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and
|
||||
// VSYS ADC both share GPIO29
|
||||
// VSYS ADC both share GPIO29.
|
||||
// The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined
|
||||
// transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43
|
||||
// driver is never initialized; calling cyw43_thread_enter() there hard-faults.
|
||||
cyw43_thread_enter();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
adc_gpio_init(pin);
|
||||
adc_select_input(pin - 26);
|
||||
@@ -84,11 +87,11 @@ float ADCSensor::sample() {
|
||||
aggr.add_sample(raw);
|
||||
}
|
||||
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
cyw43_thread_exit();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
if (this->output_raw_) {
|
||||
return aggr.aggregate();
|
||||
@@ -99,4 +102,4 @@ float ADCSensor::sample() {
|
||||
|
||||
} // namespace esphome::adc
|
||||
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
zephyr_add_overlay_builder,
|
||||
zephyr_add_prj_conf,
|
||||
zephyr_add_user,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
|
||||
def _overlay_io_channels():
|
||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||
return f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
io-channels = {entries};
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -173,9 +185,8 @@ async def to_code(config):
|
||||
if isinstance(pin_number, int):
|
||||
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
|
||||
pin_number = GPIO_TO_AIN[pin_number]
|
||||
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
zephyr_add_overlay_builder(_overlay_io_channels)
|
||||
zephyr_add_overlay(f"""
|
||||
&adc {{
|
||||
#address-cells = <1>;
|
||||
#size-cells = <0>;
|
||||
@@ -190,8 +201,7 @@ async def to_code(config):
|
||||
zephyr,oversampling = <8>;
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
@@ -201,7 +211,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
|
||||
"adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
|
||||
"adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
|
||||
"adc_sensor_libretiny.cpp": {
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
|
||||
@@ -114,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
this->decoder_->decode(param->notify.value, param->notify.value_len);
|
||||
|
||||
if (this->decoder_->has_position()) {
|
||||
this->position = ((float) this->decoder_->position_ / 100.0);
|
||||
this->position = ((float) this->decoder_->position_ / 100.0f);
|
||||
if (!this->invert_position_)
|
||||
this->position = 1 - this->position;
|
||||
if (this->position > 0.97)
|
||||
this->position = 1.0;
|
||||
if (this->position < 0.02)
|
||||
this->position = 0.0;
|
||||
if (this->position > 0.97f)
|
||||
this->position = 1.0f;
|
||||
if (this->position < 0.02f)
|
||||
this->position = 0.0f;
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +1,36 @@
|
||||
import logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
|
||||
# 2027.1.0.
|
||||
#
|
||||
# Animations are now a platform of the `image:` component (`platform:
|
||||
# animation`); the real schema, actions and codegen live in `image.py`. This
|
||||
# module only keeps the deprecated top-level `animation:` key working during the
|
||||
# deprecation window: it reuses that schema/codegen and adds a one-shot
|
||||
# deprecation warning (with a pasteable migrated `image:` block) at validation
|
||||
# time. Deleting this file drops the top-level form entirely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
AUTO_LOAD = ["image"]
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
MULTI_CONF = True
|
||||
MULTI_CONF_NO_DEFAULT = True
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
DOMAIN = "animation"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
LEGACY_REMOVAL_VERSION = "2027.1.0"
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", espImage.Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
_capture_legacy_entry, _warn_legacy_animation = (
|
||||
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
espImage.IMAGE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Animation_),
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
espImage.validate_settings,
|
||||
)
|
||||
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await espImage.write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
to_code = setup_animation
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA = image_schema(Animation_).extend(
|
||||
{
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Shared schema used by both the (deprecated) top-level `animation:` key and the
|
||||
# `image:` `platform: animation` entry.
|
||||
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
|
||||
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def setup_animation(config: ConfigType) -> None:
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
|
||||
|
||||
to_code = setup_animation
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
namespace esphome::anova {
|
||||
|
||||
float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); }
|
||||
float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); }
|
||||
|
||||
float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; }
|
||||
float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; }
|
||||
|
||||
AnovaPacket *AnovaCodec::clean_packet_() {
|
||||
this->packet_.length = strlen((char *) this->packet_.data);
|
||||
|
||||
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
|
||||
def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
"""Register the API as a provisioning source when encryption is enabled.
|
||||
|
||||
With no ``key`` the device boots unprovisioned and is set up on first
|
||||
connection; a YAML ``key`` means it is born provisioned. Either way the API
|
||||
drives the provisioning manager, so it counts as a source for `provisioning:`.
|
||||
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
|
||||
"""
|
||||
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
|
||||
from esphome.components import provisioning
|
||||
|
||||
provisioning.register_source("api")
|
||||
if CONF_KEY in encryption:
|
||||
provisioning.report_hardcoded_credentials("api")
|
||||
return config
|
||||
|
||||
|
||||
def validate_encryption_key(value):
|
||||
value = cv.string_strict(value)
|
||||
try:
|
||||
@@ -300,21 +317,23 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_LISTEN_BACKLOG,
|
||||
esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets
|
||||
esp32=4, # More RAM (520KB), BSD sockets
|
||||
rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
|
||||
rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
|
||||
bk72xx=4, # Moderate RAM, BSD-style sockets
|
||||
rtl87xx=4, # Moderate RAM, BSD-style sockets
|
||||
host=4, # Abundant resources
|
||||
ln882x=4, # Moderate RAM
|
||||
nrf52=4, # ~256KB RAM, BSD sockets
|
||||
): cv.int_range(min=1, max=10),
|
||||
cv.SplitDefault(
|
||||
CONF_MAX_CONNECTIONS,
|
||||
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
|
||||
esp32=5, # 520KB RAM available
|
||||
rp2040=4, # 264KB RAM but LWIP constraints
|
||||
rp2=4, # 264KB RAM but LWIP constraints
|
||||
bk72xx=5, # Moderate RAM
|
||||
rtl87xx=5, # Moderate RAM
|
||||
host=8, # Abundant resources
|
||||
ln882x=5, # Moderate RAM
|
||||
nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller)
|
||||
): cv.int_range(min=1, max=20),
|
||||
# Maximum queued send buffers per connection before dropping connection
|
||||
# Each buffer uses ~8-12 bytes overhead plus actual message size
|
||||
@@ -324,7 +343,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_MAX_SEND_QUEUE,
|
||||
esp8266=4, # Limited RAM, need to fail fast
|
||||
esp32=8, # More RAM, can buffer more
|
||||
rp2040=8, # Moderate RAM
|
||||
rp2=8, # Moderate RAM
|
||||
bk72xx=8, # Moderate RAM
|
||||
nrf52=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
@@ -335,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -468,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
# This will allow a plaintext client to provide a noise key,
|
||||
# send it to the device, and then switch to noise.
|
||||
# Until a key is set, the device accepts both Noise connections
|
||||
# using the well-known all-zeros PSK (preferred: the key travels
|
||||
# encrypted, protecting against passive sniffing) and plaintext
|
||||
# connections (deprecated, remove after 2027.2.0) so a client can
|
||||
# provide a noise key and the device then switches to noise only.
|
||||
# The key will be saved in flash and used for future connections
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
@@ -538,17 +561,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
# synchronous=False: when on_success/on_error is configured, play() stores the
|
||||
# trigger args until the HomeassistantActionResponse arrives, so non-owning args
|
||||
# (StringRef into the API receive buffer) must not be used.
|
||||
@automation.register_action(
|
||||
"homeassistant.action",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
synchronous=False,
|
||||
)
|
||||
@automation.register_action(
|
||||
"homeassistant.service",
|
||||
HomeAssistantServiceCallAction,
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
synchronous=False,
|
||||
)
|
||||
async def homeassistant_service_to_code(
|
||||
config: ConfigType,
|
||||
@@ -642,6 +668,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
# synchronous=True is safe here: the event schema has no on_success/on_error,
|
||||
# so play() never stores the trigger args.
|
||||
@automation.register_action(
|
||||
"homeassistant.event",
|
||||
HomeAssistantServiceCallAction,
|
||||
|
||||
@@ -158,6 +158,16 @@ message AuthenticationResponse {
|
||||
bool invalid_password = 1;
|
||||
}
|
||||
|
||||
// Reason a party is requesting the connection be closed.
|
||||
enum DisconnectReason {
|
||||
// No specific reason / not provided (default for older peers).
|
||||
DISCONNECT_REASON_UNSPECIFIED = 0;
|
||||
// The device's provisioning window has expired. The device must be reset
|
||||
// (power-cycled) to reopen the provisioning window before it will accept a
|
||||
// connection again.
|
||||
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
|
||||
}
|
||||
|
||||
// Request to close the connection.
|
||||
// Can be sent by both the client and server
|
||||
message DisconnectRequest {
|
||||
@@ -166,6 +176,10 @@ message DisconnectRequest {
|
||||
option (no_delay) = true;
|
||||
|
||||
// Do not close the connection before the acknowledgement arrives
|
||||
|
||||
// Optional reason the connection is being closed. Older peers that do not
|
||||
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
|
||||
DisconnectReason reason = 1;
|
||||
}
|
||||
|
||||
message DisconnectResponse {
|
||||
@@ -296,6 +310,11 @@ message DeviceInfoResponse {
|
||||
|
||||
// Serial proxy instance metadata
|
||||
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
|
||||
// Device is unprovisioned and accepts Noise handshakes with the well-known
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
#include "esphome/components/deep_sleep/deep_sleep_component.h"
|
||||
@@ -195,6 +198,29 @@ APIConnection::~APIConnection() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
void APIConnection::upgrade_helper_to_noise_() {
|
||||
// The client opened with a Noise hello while this device has no encryption
|
||||
// key set. Replace the plaintext helper with a Noise helper so the key can
|
||||
// be provisioned over an encrypted channel: the noise context PSK is all
|
||||
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
|
||||
// exchange, so a passive listener cannot read the session. A publicly known
|
||||
// PSK authenticates nobody; this protects against sniffing only.
|
||||
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
|
||||
uint8_t header[3];
|
||||
uint8_t header_len = plaintext->get_consumed_header(header);
|
||||
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
|
||||
// Carry over the peername-based client name (Hello has not arrived yet)
|
||||
const char *name = plaintext->get_client_name();
|
||||
noise->set_client_name(name, strlen(name));
|
||||
this->helper_.reset(noise); // destroys the plaintext helper
|
||||
APIError err = noise->init_from_handoff(header, header_len);
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
|
||||
}
|
||||
}
|
||||
#endif // USE_API_NOISE && USE_API_PLAINTEXT
|
||||
|
||||
void APIConnection::destroy_active_iterator_() {
|
||||
switch (this->active_iterator_) {
|
||||
case ActiveIterator::LIST_ENTITIES:
|
||||
@@ -253,6 +279,15 @@ void APIConnection::loop() {
|
||||
// No more data available
|
||||
break;
|
||||
} else if (err != APIError::OK) {
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Checked inside the error branch to keep the hot err == OK path
|
||||
// free of it; this can only fire on the first bytes of a plaintext
|
||||
// helper on an unprovisioned device
|
||||
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
|
||||
this->upgrade_helper_to_noise_();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
|
||||
return;
|
||||
} else {
|
||||
@@ -759,6 +794,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
|
||||
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
|
||||
// Current feature flags and other supported parameters
|
||||
msg.feature_flags = traits.get_feature_flags();
|
||||
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
|
||||
msg.supported_modes = &traits.get_supported_modes();
|
||||
msg.visual_min_temperature = traits.get_visual_min_temperature();
|
||||
msg.visual_max_temperature = traits.get_visual_max_temperature();
|
||||
@@ -1293,7 +1329,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
|
||||
}
|
||||
}
|
||||
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(
|
||||
const VoiceAssistantConfigurationRequest & /*msg*/) {
|
||||
VoiceAssistantConfigurationResponse resp;
|
||||
if (!this->check_voice_assistant_api_connection_()) {
|
||||
// send_message encodes synchronously, so this stack local outlives the encode
|
||||
@@ -1313,22 +1350,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
|
||||
}
|
||||
}
|
||||
|
||||
// Filter external wake words
|
||||
for (auto &wake_word : msg.external_wake_words) {
|
||||
if (wake_word.model_type != "micro") {
|
||||
// microWakeWord only
|
||||
continue;
|
||||
}
|
||||
|
||||
resp.available_wake_words.emplace_back();
|
||||
auto &resp_wake_word = resp.available_wake_words.back();
|
||||
resp_wake_word.id = StringRef(wake_word.id);
|
||||
resp_wake_word.wake_word = StringRef(wake_word.wake_word);
|
||||
for (const auto &lang : wake_word.trained_languages) {
|
||||
resp_wake_word.trained_languages.push_back(lang);
|
||||
}
|
||||
}
|
||||
|
||||
resp.active_wake_words = &config.active_wake_words;
|
||||
resp.max_active_wake_words = config.max_active_wake_words;
|
||||
return this->send_message(resp);
|
||||
@@ -1348,7 +1369,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
|
||||
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
|
||||
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
|
||||
zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
|
||||
}
|
||||
|
||||
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
@@ -1433,6 +1454,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
|
||||
msg.target_temperature_step = traits.get_target_temperature_step();
|
||||
msg.supported_modes = &traits.get_supported_modes();
|
||||
msg.supported_features = traits.get_feature_flags();
|
||||
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
|
||||
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
@@ -1521,8 +1543,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
|
||||
static_cast<uint32_t>(proxies.size()));
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits,
|
||||
msg.data_size);
|
||||
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
|
||||
msg.stop_bits, msg.data_size);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
|
||||
@@ -1531,7 +1553,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
|
||||
proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
|
||||
@@ -1540,7 +1562,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
return;
|
||||
}
|
||||
proxies[msg.instance]->set_modem_pins(msg.line_states);
|
||||
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
|
||||
@@ -1711,12 +1733,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
|
||||
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
||||
|
||||
// TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
|
||||
if (!this->client_supports_api_version(1, 14)) {
|
||||
ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
|
||||
this->client_api_version_major_, this->client_api_version_minor_);
|
||||
}
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 14;
|
||||
@@ -1724,6 +1740,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
|
||||
// The provisioning window has closed without the device being provisioned.
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
@@ -1759,7 +1788,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
// Manufacturer string - define once, handle ESP8266 PROGMEM separately
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
#define ESPHOME_MANUFACTURER "Espressif"
|
||||
#elif defined(USE_RP2040)
|
||||
#elif defined(USE_RP2)
|
||||
#define ESPHOME_MANUFACTURER "Raspberry Pi"
|
||||
#elif defined(USE_BK72XX)
|
||||
#define ESPHOME_MANUFACTURER "Beken"
|
||||
@@ -1844,6 +1873,12 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
resp.api_encryption_supported = true;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// No key from YAML: while no key is set, the key can be provisioned over a
|
||||
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
@@ -1874,7 +1909,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
void APIConnection::on_disconnect_request() {
|
||||
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
|
||||
// The reason is informational when a client disconnects us; we always ack and close.
|
||||
if (!this->send_disconnect_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
@@ -2002,6 +2038,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
NoiseEncryptionSetKeyResponse resp;
|
||||
resp.success = false;
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
// Refuse to set a key once the provisioning window has closed (defense in depth;
|
||||
// such connections are already rejected at hello).
|
||||
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
|
||||
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
|
||||
return this->send_message(resp);
|
||||
}
|
||||
#endif
|
||||
|
||||
psk_t psk{};
|
||||
if (msg.key_len == 0) {
|
||||
if (this->parent_->clear_noise_psk(true)) {
|
||||
@@ -2011,10 +2056,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
|
||||
ESP_LOGW(TAG, "Invalid encryption key length");
|
||||
} else if (APINoiseContext::is_all_zeros(psk)) {
|
||||
// Accepting the reserved provisioning PSK would report success without
|
||||
// enabling encryption (or silently clear an existing key)
|
||||
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
|
||||
} else if (!this->parent_->save_noise_psk(psk, true)) {
|
||||
ESP_LOGW(TAG, "Failed to save encryption key");
|
||||
} else {
|
||||
resp.success = true;
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
if (this->helper_->frame_footer_size() == 0) {
|
||||
// Plaintext transport has no frame footer; Noise always has the MAC footer.
|
||||
// Remove after 2027.2.0 together with plaintext support on keyless devices.
|
||||
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
return this->send_message(resp);
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
#include "esphome/components/esp32/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
#include "esphome/components/rp2040/crash_handler.h"
|
||||
#ifdef USE_RP2_CRASH_HANDLER
|
||||
#include "esphome/components/rp2/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
@@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return;
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
@@ -259,7 +263,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_get_time_response(const GetTimeResponse &value);
|
||||
#endif
|
||||
void on_hello_request(const HelloRequest &msg);
|
||||
void on_disconnect_request();
|
||||
void on_disconnect_request(const DisconnectRequest &msg);
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
@@ -279,8 +283,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
esp32::crash_handler_log();
|
||||
esp32::crash_handler_clear();
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
rp2040::crash_handler_log();
|
||||
#ifdef USE_RP2_CRASH_HANDLER
|
||||
rp2::crash_handler_log();
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
esp8266::crash_handler_log();
|
||||
@@ -626,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void destroy_active_iterator_();
|
||||
void begin_iterator_(ActiveIterator type);
|
||||
void finalize_iterator_sync_();
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Swap the plaintext helper for a Noise helper after the client opened
|
||||
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
|
||||
void upgrade_helper_to_noise_();
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
std::unique_ptr<camera::CameraImageReader> image_reader_;
|
||||
#endif
|
||||
|
||||
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
|
||||
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
|
||||
}
|
||||
#endif
|
||||
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
|
||||
// any logging can happen, so it intentionally has no entry here.
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
|
||||
HANDSHAKESTATE_SPLIT_FAILED = 1020,
|
||||
BAD_HANDSHAKE_ERROR_BYTE = 1021,
|
||||
#endif
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Not an error: an unprovisioned device received a Noise client hello on a
|
||||
// plaintext connection; the caller must hand the socket off to a Noise helper.
|
||||
PROTOCOL_SWITCH_TO_NOISE = 1023,
|
||||
#endif
|
||||
};
|
||||
|
||||
const LogString *api_error_to_logstr(APIError err);
|
||||
@@ -200,6 +205,12 @@ class APIFrameHelper {
|
||||
// or track that they stopped early and retry without this check.
|
||||
// See Socket::ready() for details.
|
||||
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Move the socket out of this helper so a replacement helper can take it
|
||||
// over (plaintext to Noise handoff on unprovisioned devices). The drained
|
||||
// helper must be destroyed right after.
|
||||
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
|
||||
#endif
|
||||
// Release excess memory from internal buffers after initial sync
|
||||
void release_buffers() {
|
||||
// rx_buf_: Safe to clear only if no partial read in progress.
|
||||
|
||||
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
// Seed the header bytes the plaintext helper consumed before detecting the
|
||||
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
|
||||
std::memcpy(this->rx_header_buf_, header, header_len);
|
||||
this->rx_header_buf_len_ = header_len;
|
||||
// Pump the handshake without gating on socket_->ready(): on LWIP the
|
||||
// plaintext helper's partial read can drain rcvevent while the rest of the
|
||||
// client hello sits in the lastdata cache, so ready() may report false even
|
||||
// though data is available.
|
||||
return this->pump_handshake_();
|
||||
}
|
||||
#endif // USE_API_PLAINTEXT
|
||||
|
||||
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
|
||||
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
|
||||
/// and resume on the next loop().
|
||||
APIError APINoiseFrameHelper::pump_handshake_() {
|
||||
while (this->state_ != State::DATA) {
|
||||
APIError err = this->state_action_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
break;
|
||||
}
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
// Helper for handling handshake frame errors
|
||||
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
|
||||
if (aerr == APIError::BAD_INDICATOR) {
|
||||
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
|
||||
|
||||
/// Run through handshake messages (if in that phase)
|
||||
APIError APINoiseFrameHelper::loop() {
|
||||
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
|
||||
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
|
||||
// that must follow reads, deadlocking the handshake. state_action() will return
|
||||
// WOULD_BLOCK when no more data is available to read.
|
||||
bool socket_ready = this->socket_->ready();
|
||||
while (state_ != State::DATA && socket_ready) {
|
||||
APIError err = state_action_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
break;
|
||||
}
|
||||
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
|
||||
// ready() returns false once the rx buffer is consumed. Re-checking each
|
||||
// iteration would block handshake writes that must follow reads,
|
||||
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
|
||||
// no more data is available to read.
|
||||
if (state_ != State::DATA && this->socket_->ready()) {
|
||||
APIError err = this->pump_handshake_();
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
}
|
||||
~APINoiseFrameHelper() override;
|
||||
APIError init() override;
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
// Take over a connection whose first bytes were consumed by a plaintext
|
||||
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
|
||||
protected:
|
||||
APIError pump_handshake_();
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
|
||||
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
// If this was the first read, validate the indicator byte
|
||||
if (rx_header_buf_pos_ == 0 && received > 0) {
|
||||
if (rx_header_buf_[0] != 0x00) {
|
||||
#ifdef USE_API_NOISE
|
||||
// Dual build (encryption supported but no key set): a 0x01 first byte
|
||||
// is a Noise client hello. Hand the connection off to a Noise helper
|
||||
// running the all-zeros provisioning PSK so the encryption key can be
|
||||
// set without crossing the wire in plaintext. Preserve the bytes we
|
||||
// already consumed; they are the start of the Noise 3-byte header.
|
||||
if (rx_header_buf_[0] == 0x01) {
|
||||
rx_header_buf_pos_ = static_cast<uint8_t>(received);
|
||||
return APIError::PROTOCOL_SWITCH_TO_NOISE;
|
||||
}
|
||||
#endif
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
|
||||
return APIError::BAD_INDICATOR;
|
||||
|
||||
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
#ifdef USE_API_NOISE
|
||||
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
|
||||
// header bytes already consumed from the socket (at most 3, the size of the
|
||||
// Noise fixed header) so the replacement Noise helper can be seeded with them.
|
||||
uint8_t get_consumed_header(uint8_t out[3]) const {
|
||||
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
|
||||
return this->rx_header_buf_pos_;
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
APIError try_read_frame_();
|
||||
|
||||
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
|
||||
|
||||
class APINoiseContext {
|
||||
public:
|
||||
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
|
||||
// doubles as the well-known provisioning PSK that unprovisioned devices
|
||||
// accept for Noise handshakes (passive-sniffing protection only, no
|
||||
// authentication). It is never a valid real key.
|
||||
static bool is_all_zeros(const psk_t &psk) {
|
||||
uint8_t acc = 0;
|
||||
for (uint8_t b : psk) {
|
||||
acc |= b;
|
||||
}
|
||||
return acc == 0;
|
||||
}
|
||||
void set_psk(psk_t psk) {
|
||||
this->psk_ = psk;
|
||||
bool has_psk = false;
|
||||
for (auto i : psk) {
|
||||
has_psk |= i;
|
||||
}
|
||||
this->has_psk_ = has_psk;
|
||||
this->has_psk_ = !is_all_zeros(psk);
|
||||
}
|
||||
const psk_t &get_psk() const { return this->psk_; }
|
||||
bool has_psk() const { return this->has_psk_; }
|
||||
|
||||
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
|
||||
}
|
||||
|
||||
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
|
||||
// socket->write() can re-enter this function: a log message emitted from an
|
||||
// lwip callback during the write goes out over the API and lands back in the
|
||||
// frame helper's write/drain path. If a nested drain ran here it would send
|
||||
// and free the entry the outer drain is still holding, causing a double free.
|
||||
// Report "no progress" instead; the outer drain keeps draining, and the
|
||||
// nested send is enqueued behind the existing backlog.
|
||||
if (this->draining_)
|
||||
return 0;
|
||||
|
||||
// RAII so the flag is cleared on every return path
|
||||
struct DrainGuard {
|
||||
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
|
||||
~DrainGuard() { this->flag_ = false; }
|
||||
bool &flag_;
|
||||
} guard(this->draining_);
|
||||
|
||||
while (this->count_ > 0) {
|
||||
Entry *front = this->queue_[this->head_];
|
||||
|
||||
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
// Entry fully sent — free it and advance
|
||||
Entry::destroy(front);
|
||||
// Entry fully sent — unlink it before freeing so a freed pointer is never
|
||||
// reachable from the queue
|
||||
this->queue_[this->head_] = nullptr;
|
||||
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_--;
|
||||
Entry::destroy(front);
|
||||
}
|
||||
|
||||
return 0; // All drained
|
||||
|
||||
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
|
||||
uint8_t head_{0};
|
||||
uint8_t tail_{0};
|
||||
uint8_t count_{0};
|
||||
// Guards against re-entrant drains: socket->write() can re-enter the API
|
||||
// send path (e.g. a log message emitted from an lwip callback), and a nested
|
||||
// drain would free the entry the outer drain is still holding.
|
||||
bool draining_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
|
||||
size += 2 + this->name.size();
|
||||
return size;
|
||||
}
|
||||
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->reason = static_cast<enums::DisconnectReason>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
|
||||
return pos;
|
||||
}
|
||||
uint32_t DisconnectRequest::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += this->reason ? 2 : 0;
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_AREAS
|
||||
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
@@ -150,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
@@ -212,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
size += ProtoSize::calc_message_force(2, it.calculate_size());
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace esphome::api {
|
||||
|
||||
namespace enums {
|
||||
|
||||
enum DisconnectReason : uint32_t {
|
||||
DISCONNECT_REASON_UNSPECIFIED = 0,
|
||||
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
|
||||
};
|
||||
enum SerialProxyPortType : uint32_t {
|
||||
SERIAL_PROXY_PORT_TYPE_TTL = 0,
|
||||
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
|
||||
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class DisconnectRequest final : public ProtoMessage {
|
||||
class DisconnectRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 5;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 0;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 2;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
|
||||
#endif
|
||||
enums::DisconnectReason reason{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
class DisconnectResponse final : public ProtoMessage {
|
||||
public:
|
||||
@@ -525,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 309;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -580,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
|
||||
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
|
||||
switch (value) {
|
||||
case enums::DISCONNECT_REASON_UNSPECIFIED:
|
||||
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
|
||||
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
|
||||
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
|
||||
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -971,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
#if defined(LOG_LEVEL_NONE)
|
||||
// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of
|
||||
// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so
|
||||
// the enum parses; nothing below needs Zephyr's logging macro.
|
||||
#undef LOG_LEVEL_NONE
|
||||
#endif
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// This file only provides includes, no actual code
|
||||
|
||||
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
case DisconnectRequest::MESSAGE_TYPE: {
|
||||
DisconnectRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_disconnect_request"));
|
||||
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
|
||||
#endif
|
||||
this->on_disconnect_request();
|
||||
this->on_disconnect_request(msg);
|
||||
break;
|
||||
}
|
||||
case DisconnectResponse::MESSAGE_TYPE: {
|
||||
|
||||
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
|
||||
|
||||
void on_hello_request(const HelloRequest &value){};
|
||||
|
||||
void on_disconnect_request(){};
|
||||
void on_disconnect_request(const DisconnectRequest &value){};
|
||||
void on_disconnect_response(){};
|
||||
void on_ping_request(){};
|
||||
void on_ping_response(){};
|
||||
|
||||
@@ -107,8 +107,30 @@ void APIServer::setup() {
|
||||
|
||||
// Initialize last_connected_ for reboot timeout tracking
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
// Set warning status if reboot timeout is enabled
|
||||
if (this->reboot_timeout_ != 0) {
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Register with the provisioning manager (provisioning:) as a source and
|
||||
// report our current state (provisioned == an encryption key is set). When the
|
||||
// window closes, disconnect any client still attempting to provision so it learns
|
||||
// the reason. The manager owns the timeout, window state and on_timeout automation.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
|
||||
this->noise_ctx_.has_psk());
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
// Best-effort: if the send buffer is full the reason is dropped, but the
|
||||
// client still learns the window is closed when it reconnects (rejected at
|
||||
// hello) or via the socket close.
|
||||
c->send_message(req);
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
// Set warning status if reboot timeout is enabled (suppressed while provisioning
|
||||
// is pending so the device waits to be onboarded instead of rebooting).
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
}
|
||||
@@ -121,8 +143,10 @@ void APIServer::loop() {
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time)
|
||||
if (this->reboot_timeout_ != 0) {
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
// Suppressed while a provisioning window is pending so the device waits to be
|
||||
// onboarded / reset instead of rebooting itself; resumes once provisioned.
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_connected_ > this->reboot_timeout_) {
|
||||
ESP_LOGE(TAG, "No clients; rebooting");
|
||||
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
this->clients_[last_index].reset();
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -240,12 +265,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
}
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Server:\n"
|
||||
" Address: %s:%u\n"
|
||||
" Listen backlog: %u\n"
|
||||
" Max connections: %u",
|
||||
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
|
||||
network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
|
||||
#ifdef USE_API_NOISE
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
|
||||
if (!this->noise_ctx_.has_psk()) {
|
||||
@@ -400,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
bool has_subscriber = false;
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->send_homeassistant_action(call);
|
||||
has_subscriber |= client->send_homeassistant_action(call);
|
||||
}
|
||||
if (!has_subscriber) {
|
||||
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
|
||||
// fired right at connection time (on_client_connected, on_time_sync, ...) can
|
||||
// arrive before the subscription and are lost - warn instead of failing silently.
|
||||
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
|
||||
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -571,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
|
||||
}
|
||||
|
||||
SavedNoisePsk new_saved_psk{psk};
|
||||
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
|
||||
make_active);
|
||||
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
|
||||
make_active);
|
||||
#ifdef USE_PROVISIONING
|
||||
// The device now has a key; report provisioned so the provisioning window is
|
||||
// satisfied and the reboot timeout resumes normal operation.
|
||||
if (result && provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
bool APIServer::clear_noise_psk(bool make_active) {
|
||||
@@ -583,8 +625,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
|
||||
return false;
|
||||
#else
|
||||
SavedNoisePsk empty_psk{};
|
||||
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||
make_active);
|
||||
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||
make_active);
|
||||
#ifdef USE_PROVISIONING
|
||||
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
|
||||
// provisioning window.
|
||||
if (result && provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
#include "esphome/core/controller.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#endif
|
||||
@@ -255,6 +258,19 @@ class APIServer final : public Component,
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
// True while a configured provisioning window is still pending (the device is
|
||||
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
|
||||
// not auto-rebooted while waiting to be provisioned. False when no provisioning
|
||||
// window is configured.
|
||||
bool provisioning_pending_() const {
|
||||
return provisioning::global_provisioning_manager != nullptr &&
|
||||
provisioning::global_provisioning_manager->window_pending();
|
||||
}
|
||||
#else
|
||||
bool provisioning_pending_() const { return false; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
||||
bool make_active);
|
||||
@@ -332,7 +348,10 @@ class APIServer final : public Component,
|
||||
uint8_t listen_backlog_{4};
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
// 7 bytes used, 1 byte padding
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
APINoiseContext noise_ctx_;
|
||||
|
||||
@@ -18,7 +18,7 @@ with warnings.catch_warnings():
|
||||
import contextlib
|
||||
|
||||
from esphome.const import CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.core import CORE
|
||||
from esphome.util import safe_print
|
||||
|
||||
from . import CONF_ENCRYPTION
|
||||
@@ -36,15 +36,17 @@ class _LogLineProcessor:
|
||||
"""Feeds incoming log lines to the stack-trace decoder.
|
||||
|
||||
Two responsibilities beyond just calling the decoder:
|
||||
1. Catch EsphomeError. on_log runs inside an asyncio protocol
|
||||
callback; if an exception escapes, the loop tears the transport
|
||||
down with "Fatal error: protocol.data_received() call failed."
|
||||
and ReconnectLogic immediately reconnects, the device replays
|
||||
the same crash trace, and we loop forever.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out
|
||||
to PlatformIO via _run_idedata, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to
|
||||
retry the failing subprocess for each one.
|
||||
1. Catch everything the decoder can raise. aioesphomeapi isolates
|
||||
exceptions raised by log handlers, so an escaping one no longer
|
||||
kills the session, but it does log a full traceback per line. A
|
||||
crash dump carries a PC line plus one per backtrace frame, so the
|
||||
tracebacks bury the dump the user is trying to read. Decoding is a
|
||||
diagnostic nicety; nothing it raises is worth that noise.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out to
|
||||
the toolchain to resolve addr2line, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to retry
|
||||
the failing subprocess for each one. This only works if every
|
||||
failure is caught, which is why 1 is not narrowed to EsphomeError.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
|
||||
@@ -61,12 +63,13 @@ class _LogLineProcessor:
|
||||
self.backtrace_state = self._platform_handler(
|
||||
self._config, raw_line, self.backtrace_state
|
||||
)
|
||||
except EsphomeError as exc:
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
self._decode_enabled = False
|
||||
self.backtrace_state = False
|
||||
# _run_idedata raises EsphomeError with no message; fall back
|
||||
# to a generic explanation when str(exc) is empty.
|
||||
detail = str(exc) or "build artifacts not found locally"
|
||||
_LOGGER.debug("Stack-trace decoding failed", exc_info=True)
|
||||
_LOGGER.warning(
|
||||
"Crash trace decoding unavailable: %s. "
|
||||
"Run 'esphome compile' for this device to enable PC decoding.",
|
||||
@@ -152,6 +155,9 @@ async def async_run_logs(
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType")
|
||||
|
||||
CONF_AQI = "aqi"
|
||||
CONF_CALCULATION_TYPE = "calculation_type"
|
||||
CONF_EXTENDED_RANGE = "extended_range"
|
||||
|
||||
AQI_CALCULATION_TYPE = {
|
||||
"CAQI": AQICalculatorType.CAQI_TYPE,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace esphome::aqi {
|
||||
|
||||
class AbstractAQICalculator {
|
||||
public:
|
||||
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0;
|
||||
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0;
|
||||
};
|
||||
|
||||
} // namespace esphome::aqi
|
||||
|
||||
@@ -11,10 +11,12 @@ namespace esphome::aqi {
|
||||
|
||||
class AQICalculator : public AbstractAQICalculator {
|
||||
public:
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range);
|
||||
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
|
||||
// extended_range lets the index run past the standard maximum, so clamp to the sensor's range.
|
||||
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
|
||||
return static_cast<uint16_t>(std::lround(aqi));
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
{35.5f, 55.5f},
|
||||
{55.5f, 125.5f},
|
||||
{125.5f, 225.5f},
|
||||
{225.5f, std::numeric_limits<float>::max()}
|
||||
{225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
{155.0f, 255.0f},
|
||||
{255.0f, 355.0f},
|
||||
{355.0f, 425.0f},
|
||||
{425.0f, std::numeric_limits<float>::max()}
|
||||
{425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band)
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) {
|
||||
int grid_index = get_grid_index(value, array);
|
||||
if (grid_index == -1) {
|
||||
return -1.0f;
|
||||
@@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
float conc_lo = array[grid_index][0];
|
||||
float conc_hi = array[grid_index][1];
|
||||
|
||||
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
|
||||
// Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we
|
||||
// clamp to the standard maximum; with extended_range we keep the extrapolated "over-range"
|
||||
// value so heavy pollution reports numbers beyond what the standard defines.
|
||||
if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) {
|
||||
return aqi_hi;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
for (int i = 0; i < NUM_LEVELS; i++) {
|
||||
const bool in_range =
|
||||
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
|
||||
: (value < array[i][1])); // others exclusive on hi
|
||||
// The top band is open-ended: any value at or above its lower breakpoint falls into it,
|
||||
// and calculate_index() decides whether to clamp or extrapolate.
|
||||
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
|
||||
if (in_range) {
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ void AQISensor::setup() {
|
||||
void AQISensor::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "AQI Sensor:");
|
||||
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
|
||||
ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled");
|
||||
if (this->pm_2_5_sensor_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
|
||||
}
|
||||
@@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() {
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_);
|
||||
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_);
|
||||
this->publish_state(aqi);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component {
|
||||
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
|
||||
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
|
||||
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
|
||||
void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; }
|
||||
|
||||
protected:
|
||||
void calculate_aqi_();
|
||||
@@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component {
|
||||
sensor::Sensor *pm_2_5_sensor_{nullptr};
|
||||
sensor::Sensor *pm_10_0_sensor_{nullptr};
|
||||
AQICalculatorType aqi_calc_type_{AQI_TYPE};
|
||||
bool extended_range_{false};
|
||||
AQICalculatorFactory aqi_calculator_factory_;
|
||||
|
||||
float pm_2_5_value_{NAN};
|
||||
|
||||
@@ -9,25 +9,28 @@ namespace esphome::aqi {
|
||||
|
||||
class CAQICalculator : public AbstractAQICalculator {
|
||||
public:
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
|
||||
// The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We
|
||||
// therefore always extrapolate the top band past 100 without limit, so the extended_range flag
|
||||
// (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored.
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
|
||||
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
|
||||
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
|
||||
return static_cast<uint16_t>(std::lround(aqi));
|
||||
}
|
||||
|
||||
protected:
|
||||
static constexpr int NUM_LEVELS = 5;
|
||||
static constexpr int NUM_LEVELS = 4;
|
||||
|
||||
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
|
||||
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}};
|
||||
|
||||
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
|
||||
// clang-format off
|
||||
{0.0f, 15.1f},
|
||||
{15.1f, 30.1f},
|
||||
{30.1f, 55.1f},
|
||||
{55.1f, 110.1f},
|
||||
{110.1f, std::numeric_limits<float>::max()}
|
||||
{55.1f, 110.1f}
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator {
|
||||
{0.0f, 25.1f},
|
||||
{25.1f, 50.1f},
|
||||
{50.1f, 90.1f},
|
||||
{90.1f, 180.1f},
|
||||
{180.1f, std::numeric_limits<float>::max()}
|
||||
{90.1f, 180.1f}
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator {
|
||||
float conc_lo = array[grid_index][0];
|
||||
float conc_hi = array[grid_index][1];
|
||||
|
||||
// The top band is open-ended (see get_grid_index), so for concentrations above the last
|
||||
// breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class.
|
||||
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
}
|
||||
|
||||
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
for (int i = 0; i < NUM_LEVELS; i++) {
|
||||
const bool in_range =
|
||||
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
|
||||
: (value < array[i][1])); // others exclusive on hi
|
||||
// The top band is open-ended: any value at or above its lower breakpoint falls into it.
|
||||
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
|
||||
if (in_range) {
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,25 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
|
||||
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns
|
||||
|
||||
CODEOWNERS = ["@jasstrong"]
|
||||
DEPENDENCIES = ["sensor"]
|
||||
|
||||
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
|
||||
def _validate_extended_range(config):
|
||||
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
|
||||
"CAQI has no maximum value by specification, so it is always reported unbounded.",
|
||||
[CONF_EXTENDED_RANGE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
AQISensor,
|
||||
accuracy_decimals=0,
|
||||
@@ -29,9 +40,11 @@ CONFIG_SCHEMA = (
|
||||
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
|
||||
AQI_CALCULATION_TYPE, upper=True
|
||||
),
|
||||
cv.Optional(CONF_EXTENDED_RANGE): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_extended_range,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,3 +59,4 @@ async def to_code(config):
|
||||
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
|
||||
|
||||
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))
|
||||
cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False)))
|
||||
|
||||
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
|
||||
|
||||
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
|
||||
uint8_t value;
|
||||
if (write(®, 1) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Writing register failed!");
|
||||
return 0;
|
||||
}
|
||||
if (read(&value, 1) != i2c::ERROR_OK) {
|
||||
if (!this->read_byte(reg, &value)) {
|
||||
ESP_LOGW(TAG, "Reading register failed!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from esphome.const import (
|
||||
CONF_CLEAR,
|
||||
CONF_GAIN,
|
||||
CONF_ID,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
ICON_BRIGHTNESS_5,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_BRIGHTNESS_5,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]:
|
||||
if (
|
||||
not CORE.is_esp32
|
||||
and not CORE.is_esp8266
|
||||
and not CORE.is_rp2040
|
||||
and not CORE.is_rp2
|
||||
and not CORE.is_libretiny
|
||||
):
|
||||
return ["socket"]
|
||||
@@ -37,7 +37,7 @@ async def to_code(config):
|
||||
elif CORE.is_esp8266:
|
||||
# https://github.com/ESP32Async/ESPAsyncTCP
|
||||
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")
|
||||
elif CORE.is_rp2040:
|
||||
elif CORE.is_rp2:
|
||||
# https://github.com/ayushsharma82/RPAsyncTCP
|
||||
# RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better
|
||||
# ESPAsyncWebServer compatibility
|
||||
@@ -47,6 +47,6 @@ async def to_code(config):
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
# Exclude socket implementation for platforms that use AsyncTCP libraries
|
||||
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny:
|
||||
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny:
|
||||
return ["async_tcp_socket.cpp"]
|
||||
return []
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#elif defined(USE_ESP8266)
|
||||
// Use ESPAsyncTCP library for ESP8266 (always Arduino)
|
||||
#include <ESPAsyncTCP.h>
|
||||
#elif defined(USE_RP2040)
|
||||
#elif defined(USE_RP2)
|
||||
// Use RPAsyncTCP library for RP2040
|
||||
#include <RPAsyncTCP.h>
|
||||
#else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "async_tcp_socket.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
|
||||
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
|
||||
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
|
||||
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
|
||||
return {};
|
||||
}
|
||||
|
||||
static uint8_t last_frame_count = 0;
|
||||
if (last_frame_count == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
|
||||
if (this->last_frame_count_ == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
|
||||
return {};
|
||||
}
|
||||
last_frame_count = raw[12];
|
||||
this->last_frame_count_ = raw[12];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
|
||||
sensor::Sensor *battery_voltage_{nullptr};
|
||||
sensor::Sensor *signal_strength_{nullptr};
|
||||
|
||||
uint8_t last_frame_count_{0};
|
||||
|
||||
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
|
||||
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
|
||||
bool report_results_(const optional<ParseResult> &result, const char *address);
|
||||
|
||||
@@ -395,7 +395,7 @@ async def to_code(config):
|
||||
)
|
||||
if data.mp3_support:
|
||||
cg.add_define("USE_AUDIO_MP3_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.3.0")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.4.0")
|
||||
_emit_memory_pair(
|
||||
data.mp3.buffer_memory,
|
||||
"CONFIG_MICRO_MP3_PREFER_PSRAM",
|
||||
|
||||
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
|
||||
if file_type == "wav":
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
|
||||
elif file_type in ("mp3", "mpeg", "mpga"):
|
||||
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
|
||||
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
|
||||
# Treat those labels as MP3 so we still pick the MP3 decoder.
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
|
||||
elif file_type == "flac":
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
|
||||
|
||||
@@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip {
|
||||
|
||||
static const char *const TAG = "beken_spi_led_strip";
|
||||
|
||||
struct spi_data_t {
|
||||
struct SpiData {
|
||||
SemaphoreHandle_t dma_tx_semaphore;
|
||||
volatile bool tx_in_progress;
|
||||
bool first_run;
|
||||
};
|
||||
|
||||
static spi_data_t *spi_data = nullptr;
|
||||
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void set_spi_ctrl_register(unsigned long bit, bool val) {
|
||||
static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CTRL);
|
||||
if (val == 0) {
|
||||
value &= ~bit;
|
||||
@@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) {
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
}
|
||||
|
||||
static void set_spi_config_register(unsigned long bit, bool val) {
|
||||
static void set_spi_config_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CONFIG);
|
||||
if (val == 0) {
|
||||
value &= ~bit;
|
||||
@@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) {
|
||||
|
||||
void spi_dma_tx_enable(bool enable) {
|
||||
GDMA_CFG_ST en_cfg;
|
||||
set_spi_config_register(SPI_TX_EN, enable ? 1 : 0);
|
||||
set_spi_config_register(SPI_TX_EN, enable);
|
||||
en_cfg.channel = SPI_TX_DMA_CHANNEL;
|
||||
en_cfg.param = enable ? 1 : 0;
|
||||
sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg);
|
||||
@@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) {
|
||||
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
|
||||
param |= (div << SPI_CKR_POSI);
|
||||
REG_WRITE(SPI_CTRL, param);
|
||||
ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div);
|
||||
ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div);
|
||||
}
|
||||
|
||||
void spi_dma_tx_finish_callback(unsigned int param) {
|
||||
spi_data->tx_in_progress = false;
|
||||
xSemaphoreGive(spi_data->dma_tx_semaphore);
|
||||
spi_dma_tx_enable(0);
|
||||
spi_dma_tx_enable(false);
|
||||
}
|
||||
|
||||
void BekenSPILEDStripLightOutput::setup() {
|
||||
@@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t));
|
||||
spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc)
|
||||
if (spi_data == nullptr) {
|
||||
ESP_LOGE(TAG, "Cannot allocate spi_data!");
|
||||
this->mark_failed();
|
||||
@@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
|
||||
spi_data->first_run = true;
|
||||
|
||||
set_spi_ctrl_register(MSTEN, 0);
|
||||
set_spi_ctrl_register(BIT_WDTH, 0);
|
||||
set_spi_ctrl_register(MSTEN, false);
|
||||
set_spi_ctrl_register(BIT_WDTH, false);
|
||||
spi_set_clock(this->spi_frequency_);
|
||||
set_spi_ctrl_register(CKPOL, 0);
|
||||
set_spi_ctrl_register(CKPHA, 0);
|
||||
set_spi_ctrl_register(MSTEN, 1);
|
||||
set_spi_ctrl_register(SPIEN, 1);
|
||||
set_spi_ctrl_register(CKPOL, false);
|
||||
set_spi_ctrl_register(CKPHA, false);
|
||||
set_spi_ctrl_register(MSTEN, true);
|
||||
set_spi_ctrl_register(SPIEN, true);
|
||||
|
||||
set_spi_ctrl_register(TXINT_EN, 0);
|
||||
set_spi_ctrl_register(RXINT_EN, 0);
|
||||
set_spi_config_register(SPI_TX_FINISH_EN, 1);
|
||||
set_spi_config_register(SPI_RX_FINISH_EN, 1);
|
||||
set_spi_ctrl_register(RXOVR_EN, 0);
|
||||
set_spi_ctrl_register(TXOVR_EN, 0);
|
||||
set_spi_ctrl_register(TXINT_EN, false);
|
||||
set_spi_ctrl_register(RXINT_EN, false);
|
||||
set_spi_config_register(SPI_TX_FINISH_EN, true);
|
||||
set_spi_config_register(SPI_RX_FINISH_EN, true);
|
||||
set_spi_ctrl_register(RXOVR_EN, false);
|
||||
set_spi_ctrl_register(TXOVR_EN, false);
|
||||
|
||||
value = REG_READ(SPI_CTRL);
|
||||
value &= ~CTRL_NSSMD_3;
|
||||
@@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
|
||||
value = GFUNC_MODE_SPI_DMA;
|
||||
sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value);
|
||||
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0);
|
||||
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false);
|
||||
|
||||
GDMA_CFG_ST en_cfg;
|
||||
GDMACFG_TPYES_ST init_cfg;
|
||||
@@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
init_cfg.dstptr_incr = 0;
|
||||
init_cfg.srcptr_incr = 1;
|
||||
init_cfg.src_start_addr = this->dma_buf_;
|
||||
init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO
|
||||
init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO
|
||||
init_cfg.channel = SPI_TX_DMA_CHANNEL;
|
||||
init_cfg.prio = 0; // 10
|
||||
init_cfg.u.type4.src_loop_start_addr = this->dma_buf_;
|
||||
@@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
en_cfg.param = 0;
|
||||
sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg);
|
||||
|
||||
spi_dma_tx_enable(0);
|
||||
spi_dma_tx_enable(false);
|
||||
|
||||
value = REG_READ(SPI_CONFIG);
|
||||
value &= ~(0xFFF << 8);
|
||||
@@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
|
||||
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
// protect from refreshing too often
|
||||
uint32_t now = micros();
|
||||
if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) {
|
||||
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
|
||||
(now - this->last_refresh_) < *this->max_refresh_rate_) {
|
||||
// try again next loop iteration, so that this change won't get lost
|
||||
this->schedule_show();
|
||||
return;
|
||||
@@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
}
|
||||
|
||||
spi_data->first_run = false;
|
||||
spi_dma_tx_enable(1);
|
||||
spi_dma_tx_enable(true);
|
||||
|
||||
this->status_clear_warning();
|
||||
}
|
||||
@@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
|
||||
" RGB Order: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
rgb_order, *this->max_refresh_rate_, this->num_leds_);
|
||||
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
}
|
||||
|
||||
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
cv.Exclusive(
|
||||
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
cv.Optional(CONF_FILTERS): validate_filters,
|
||||
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
|
||||
|
||||
@@ -112,7 +112,7 @@ float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float
|
||||
prob_state_source_false = 1 - prob_given_false;
|
||||
}
|
||||
|
||||
return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false);
|
||||
return prob_state_source_true / (prior * prob_state_source_true + (1.0f - prior) * prob_state_source_false);
|
||||
}
|
||||
|
||||
void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) {
|
||||
|
||||
+1127
-1051
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user