mirror of
https://github.com/home-assistant/core.git
synced 2026-02-23 18:51:08 +01:00
Compare commits
1479 Commits
claude/ext
...
matter_cle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d97424a22 | ||
|
|
76114c9ded | ||
|
|
0334dad2f8 | ||
|
|
2dd65172b0 | ||
|
|
4532fd379e | ||
|
|
578b2b3d43 | ||
|
|
9bb2f56fbe | ||
|
|
a7d209f1f5 | ||
|
|
83d73dce5c | ||
|
|
d84f81daf2 | ||
|
|
79d4f5c8cf | ||
|
|
e9e1abb604 | ||
|
|
9a97541253 | ||
|
|
fd39f3c431 | ||
|
|
2cc4a77746 | ||
|
|
e8885de8c2 | ||
|
|
03d9c2cf7b | ||
|
|
7f3583587d | ||
|
|
e009440bf9 | ||
|
|
43dccf15ba | ||
|
|
c647ab1877 | ||
|
|
6b395b2703 | ||
|
|
882a44a1c2 | ||
|
|
3c9a505fc3 | ||
|
|
b2679ddc42 | ||
|
|
2055082993 | ||
|
|
6f49f9a12a | ||
|
|
36c560b7bf | ||
|
|
05abe7efe0 | ||
|
|
865ec96429 | ||
|
|
e6dbed0a87 | ||
|
|
a3fd2f692e | ||
|
|
eb7e00346d | ||
|
|
77159e612e | ||
|
|
05f9e25f29 | ||
|
|
7fa51117a9 | ||
|
|
9e87fa75f8 | ||
|
|
0188f2ffec | ||
|
|
c144aec03e | ||
|
|
1cb44aef64 | ||
|
|
900f2300ad | ||
|
|
b075fba594 | ||
|
|
c2ba97fb79 | ||
|
|
d0a373aecc | ||
|
|
758225edad | ||
|
|
8ab1a527a4 | ||
|
|
c7582b2f25 | ||
|
|
91b8a67ce2 | ||
|
|
2b13ff98da | ||
|
|
fd2d9c2ee2 | ||
|
|
61b5466dcc | ||
|
|
bc4af64bea | ||
|
|
3323f84c22 | ||
|
|
b1f48a5886 | ||
|
|
a14b1db886 | ||
|
|
9de89b923e | ||
|
|
21cf5dc321 | ||
|
|
fe32582233 | ||
|
|
6ebf19c4ba | ||
|
|
5794189f8d | ||
|
|
c336e58afc | ||
|
|
cdad602af0 | ||
|
|
520046cd82 | ||
|
|
e0b2ff0b2a | ||
|
|
6164198bde | ||
|
|
dd41b4cefd | ||
|
|
ccb8d6af44 | ||
|
|
6e8c064474 | ||
|
|
7079eda8d9 | ||
|
|
4e3832758b | ||
|
|
773c3c4f07 | ||
|
|
b73beba152 | ||
|
|
82589b613d | ||
|
|
c9b5f5f2c1 | ||
|
|
725b45db7f | ||
|
|
b194741a13 | ||
|
|
4615b4d104 | ||
|
|
2c7d9cb62e | ||
|
|
e229ba591a | ||
|
|
7914ebe54e | ||
|
|
3abaa99706 | ||
|
|
86d7fdfe1e | ||
|
|
676c42d578 | ||
|
|
39909b7493 | ||
|
|
6aef9a99e6 | ||
|
|
ff036f38a0 | ||
|
|
53e3b4caf0 | ||
|
|
dbdc030b74 | ||
|
|
ee0b24f808 | ||
|
|
c0fd8ff342 | ||
|
|
84d2ec484d | ||
|
|
844b20e2fc | ||
|
|
2bd07e6626 | ||
|
|
b91c07b2af | ||
|
|
37f0f1869f | ||
|
|
2fcbd77c95 | ||
|
|
b398197c07 | ||
|
|
cd5775ca35 | ||
|
|
fafa193549 | ||
|
|
ca4d537529 | ||
|
|
e9be363f29 | ||
|
|
0f874f7f03 | ||
|
|
14b147b3f7 | ||
|
|
8a1909e5d8 | ||
|
|
1fd873869f | ||
|
|
3b7b3454d8 | ||
|
|
c7276621eb | ||
|
|
6be1e4065f | ||
|
|
ba547c6bdb | ||
|
|
be25603b76 | ||
|
|
2e0f727981 | ||
|
|
122bc32f30 | ||
|
|
723825b579 | ||
|
|
5f6b446195 | ||
|
|
f59f14fe40 | ||
|
|
ab9b13302c | ||
|
|
f74fdd7605 | ||
|
|
f7628b87c8 | ||
|
|
3e31fbfee0 | ||
|
|
477797271a | ||
|
|
9f2677ddd8 | ||
|
|
558a49cb66 | ||
|
|
a9b64a15e6 | ||
|
|
0a734b7426 | ||
|
|
8df41dc73f | ||
|
|
e9039cec24 | ||
|
|
d7ef65e562 | ||
|
|
e765c1652c | ||
|
|
15cb102c39 | ||
|
|
30314ec88e | ||
|
|
428aa31749 | ||
|
|
0170d56893 | ||
|
|
eb7d973252 | ||
|
|
e3c98dcd09 | ||
|
|
9c71aea622 | ||
|
|
21978917b9 | ||
|
|
3b6a5b2c79 | ||
|
|
68792f02d4 | ||
|
|
bfea04b482 | ||
|
|
dc553f20e6 | ||
|
|
5631170900 | ||
|
|
60d4b050ac | ||
|
|
c5e261495f | ||
|
|
d1a1183b9a | ||
|
|
4dcfd5fb91 | ||
|
|
680f7fac1c | ||
|
|
7a41ce1fd8 | ||
|
|
937b4866c3 | ||
|
|
151e075e28 | ||
|
|
8094cfc404 | ||
|
|
b26483e09e | ||
|
|
728de32d75 | ||
|
|
8de1e3d27b | ||
|
|
cabf3b7ab9 | ||
|
|
f0e22cca56 | ||
|
|
294a3e5360 | ||
|
|
fdd753e70c | ||
|
|
392fc7ff91 | ||
|
|
d777c1c542 | ||
|
|
fa71fd3992 | ||
|
|
19f6340546 | ||
|
|
479cb7f1e1 | ||
|
|
d50d914928 | ||
|
|
551a71104e | ||
|
|
65cf61571a | ||
|
|
58ac3d2f45 | ||
|
|
654e132440 | ||
|
|
4af60ef3b9 | ||
|
|
2fc9ded6b7 | ||
|
|
9f551f3d5b | ||
|
|
0b8312d942 | ||
|
|
413e297022 | ||
|
|
f7752686df | ||
|
|
1313960893 | ||
|
|
d298eb033a | ||
|
|
398a6222cd | ||
|
|
7168e2df5a | ||
|
|
3b3c081703 | ||
|
|
889467e4c2 | ||
|
|
6a3bace824 | ||
|
|
523b527486 | ||
|
|
b44900532f | ||
|
|
bd45232972 | ||
|
|
e7aa0ae398 | ||
|
|
1d41e24653 | ||
|
|
049a910494 | ||
|
|
f6f52005fe | ||
|
|
b23c402d0a | ||
|
|
91c36fcdf6 | ||
|
|
ff2f0ac320 | ||
|
|
c205785f4f | ||
|
|
59dad4c935 | ||
|
|
d61f7d8170 | ||
|
|
b6e7a55cd1 | ||
|
|
163a6805eb | ||
|
|
637accbfff | ||
|
|
98b8e152e3 | ||
|
|
d12816d297 | ||
|
|
58e4a42a1b | ||
|
|
fdad9873e4 | ||
|
|
0337988be8 | ||
|
|
ba695b5bd9 | ||
|
|
c114ea2666 | ||
|
|
82148e46f5 | ||
|
|
34a78f9251 | ||
|
|
f1c142b3d3 | ||
|
|
68c82c2f90 | ||
|
|
487e2f8ccc | ||
|
|
6322185206 | ||
|
|
7f65db260f | ||
|
|
6c50711e2b | ||
|
|
f0e7d099e6 | ||
|
|
6c0fb12189 | ||
|
|
8e14dc7b5a | ||
|
|
219b982ef5 | ||
|
|
307c6a4ce2 | ||
|
|
9b1812858b | ||
|
|
9c57be215f | ||
|
|
cda6236099 | ||
|
|
e4c7262260 | ||
|
|
0f648a7f9d | ||
|
|
e6b9c2f737 | ||
|
|
e0f39e6392 | ||
|
|
52d645e4bf | ||
|
|
e8f2493ed6 | ||
|
|
ba62d95715 | ||
|
|
73fa9925c4 | ||
|
|
9ec456d28e | ||
|
|
4974439850 | ||
|
|
5cf37afbf6 | ||
|
|
76ebc134f3 | ||
|
|
667a77502d | ||
|
|
8c146624f9 | ||
|
|
2418036798 | ||
|
|
459996b760 | ||
|
|
eec854386a | ||
|
|
47d6e3e938 | ||
|
|
957c6039e9 | ||
|
|
c833cfa395 | ||
|
|
9dc38eda9f | ||
|
|
e49767d37a | ||
|
|
e6c5e72470 | ||
|
|
66dc566d3a | ||
|
|
5bb7699df0 | ||
|
|
168dd36d66 | ||
|
|
66d8a5bc51 | ||
|
|
d85040058f | ||
|
|
a5c1ed593c | ||
|
|
977ee1a9d1 | ||
|
|
6c433d0809 | ||
|
|
d370a730c2 | ||
|
|
19aaaf6cc6 | ||
|
|
9e14a643c0 | ||
|
|
80fccaec56 | ||
|
|
09b122e670 | ||
|
|
2684f4b555 | ||
|
|
cbc2928c4a | ||
|
|
aab4f57580 | ||
|
|
fed9ed615e | ||
|
|
97df38f1da | ||
|
|
be228dbe47 | ||
|
|
0292a8cd7e | ||
|
|
a308b84f15 | ||
|
|
fdc264cf71 | ||
|
|
dfd61f85c2 | ||
|
|
7ab4f2f431 | ||
|
|
be31f01fc2 | ||
|
|
8d228b6e6a | ||
|
|
46a1dda8d8 | ||
|
|
8a5d5a8468 | ||
|
|
6e48172654 | ||
|
|
1e6196c6e8 | ||
|
|
726870b829 | ||
|
|
c5b1b4482d | ||
|
|
e88be6bdeb | ||
|
|
3a0bde5d3e | ||
|
|
8dc9937ba4 | ||
|
|
2d2ea3d31c | ||
|
|
26f852d934 | ||
|
|
9977c58aaa | ||
|
|
b664f2ca9a | ||
|
|
6bbe80da72 | ||
|
|
5f3cb37ee6 | ||
|
|
27d715e26a | ||
|
|
3ee20d5e5c | ||
|
|
75b5248e2a | ||
|
|
37af004a37 | ||
|
|
4510ca7994 | ||
|
|
b8885791f7 | ||
|
|
9477fa4471 | ||
|
|
d464806281 | ||
|
|
3f00403c66 | ||
|
|
63f4653a3b | ||
|
|
e48bd88581 | ||
|
|
5d1cb4df94 | ||
|
|
6a49a25799 | ||
|
|
206c4e38be | ||
|
|
98135a1968 | ||
|
|
eecfa68de6 | ||
|
|
ffbb8c037e | ||
|
|
4386b3d5cc | ||
|
|
3f755f1f0d | ||
|
|
4cc9805a4b | ||
|
|
746461e59e | ||
|
|
ddb13b4ee7 | ||
|
|
68b08a6147 | ||
|
|
2178c98ccc | ||
|
|
ebedb182c8 | ||
|
|
335aa02f14 | ||
|
|
2c6c2d09cc | ||
|
|
c8308ad723 | ||
|
|
c65fa5b377 | ||
|
|
48ceb52ebb | ||
|
|
49bea823f5 | ||
|
|
07dcc2eae0 | ||
|
|
8e1c6c2157 | ||
|
|
f10cb23aab | ||
|
|
7020bec262 | ||
|
|
980507480b | ||
|
|
7a52d71b40 | ||
|
|
32092c73c6 | ||
|
|
4846d51341 | ||
|
|
75ddc3f9a1 | ||
|
|
11fe11cc03 | ||
|
|
40890419bb | ||
|
|
7e22a32dff | ||
|
|
6cc2f835e4 | ||
|
|
b20959d938 | ||
|
|
e456331062 | ||
|
|
e1194167cb | ||
|
|
3a6ca5ec17 | ||
|
|
2850192068 | ||
|
|
49689ad677 | ||
|
|
3408fc7520 | ||
|
|
bf482a6b92 | ||
|
|
7af63460ea | ||
|
|
755a3f82d4 | ||
|
|
71e9d54105 | ||
|
|
2208d7e92c | ||
|
|
ea281e14bf | ||
|
|
fcdeaead6f | ||
|
|
a264571ce3 | ||
|
|
43988bf0f5 | ||
|
|
a9495f61a0 | ||
|
|
1c19ddba55 | ||
|
|
99a07984fb | ||
|
|
6f17621957 | ||
|
|
496f44e007 | ||
|
|
3840f7a767 | ||
|
|
af2d2a857a | ||
|
|
31970255a2 | ||
|
|
f30397a11a | ||
|
|
cbcfc43c5a | ||
|
|
acaa2aeeee | ||
|
|
c67c19413b | ||
|
|
8840d2f0ef | ||
|
|
82fb3c35dc | ||
|
|
4d0d5d6817 | ||
|
|
12584482a2 | ||
|
|
b47dd2f923 | ||
|
|
3d354da104 | ||
|
|
89e900dca1 | ||
|
|
675884ad78 | ||
|
|
efb6cdc17e | ||
|
|
aca7fe530c | ||
|
|
10fa02a36c | ||
|
|
5344a874b0 | ||
|
|
ad2fe0d4d0 | ||
|
|
9c275acca9 | ||
|
|
225ecedc95 | ||
|
|
f246c90073 | ||
|
|
5bf7e83e76 | ||
|
|
3b3f4066c3 | ||
|
|
30e484c292 | ||
|
|
137377b50a | ||
|
|
96b98c9cb9 | ||
|
|
7d3601aa6f | ||
|
|
2ef7f6b317 | ||
|
|
7c8b181e6d | ||
|
|
b5147d8afa | ||
|
|
dc4bc6feea | ||
|
|
4cea3b4aac | ||
|
|
d633a69e07 | ||
|
|
3e8e95f95e | ||
|
|
6d66df9346 | ||
|
|
ed15a01a6a | ||
|
|
462d958b7e | ||
|
|
d888579cbd | ||
|
|
e16a8ed20e | ||
|
|
b11a75d438 | ||
|
|
95df5b9ec9 | ||
|
|
a301a9c4b6 | ||
|
|
e80bb871e4 | ||
|
|
ff4ff98e54 | ||
|
|
88c6cb3877 | ||
|
|
6b3a7e4cd6 | ||
|
|
36ff7506a0 | ||
|
|
a0af35f2dc | ||
|
|
c15da19b84 | ||
|
|
23e88a24f0 | ||
|
|
815c708d19 | ||
|
|
f9f2f39a3c | ||
|
|
490514c274 | ||
|
|
7da339b59c | ||
|
|
1bb31892c2 | ||
|
|
267caf2365 | ||
|
|
4e71a38e31 | ||
|
|
d3d916566a | ||
|
|
fd3258a6d3 | ||
|
|
d1aadb5842 | ||
|
|
d984411911 | ||
|
|
8ed0a4cf29 | ||
|
|
9a407b8668 | ||
|
|
72aa9d8a6a | ||
|
|
dc1c52622e | ||
|
|
44d5ecc926 | ||
|
|
54b0393ebe | ||
|
|
54141ffd3f | ||
|
|
92b823068c | ||
|
|
d4a6377ab3 | ||
|
|
80d07c42ac | ||
|
|
077eeafa69 | ||
|
|
b6ff8c94b1 | ||
|
|
6a1581f2bf | ||
|
|
2dc0d32a29 | ||
|
|
036696f4cd | ||
|
|
89f5b33a5e | ||
|
|
fc52885c21 | ||
|
|
ffa8fc583d | ||
|
|
f18fa07019 | ||
|
|
ce704dd5f7 | ||
|
|
d930755f92 | ||
|
|
196c6d9839 | ||
|
|
cce5358901 | ||
|
|
df7c3d787d | ||
|
|
a6287731f7 | ||
|
|
1667b3f16b | ||
|
|
2aa9d22350 | ||
|
|
3bcb303ef1 | ||
|
|
e6de37cc69 | ||
|
|
d10f5cc9ea | ||
|
|
4921f05189 | ||
|
|
877ad391f0 | ||
|
|
8a5594b9e4 | ||
|
|
a0623d1f97 | ||
|
|
c8f8ef887a | ||
|
|
40ec6d3793 | ||
|
|
0a79d84f9a | ||
|
|
7a7e60ce75 | ||
|
|
6bfaf6b188 | ||
|
|
34a445545c | ||
|
|
3c854a7679 | ||
|
|
b7b6c1a72e | ||
|
|
fdf02cf657 | ||
|
|
acf739df81 | ||
|
|
4801dcaded | ||
|
|
11af0a2d04 | ||
|
|
40b30b94a2 | ||
|
|
902d3f45a2 | ||
|
|
bf887fbc71 | ||
|
|
e5ede7deea | ||
|
|
8b674a44a1 | ||
|
|
e145963d48 | ||
|
|
1bca0ba5f8 | ||
|
|
38531033a1 | ||
|
|
9f1b6a12a5 | ||
|
|
876589f0cd | ||
|
|
bd09ac9030 | ||
|
|
6d143c1ce2 | ||
|
|
f4ceb22d73 | ||
|
|
5839191c37 | ||
|
|
29feccb190 | ||
|
|
a017417849 | ||
|
|
72a7d708b0 | ||
|
|
47be13e6bf | ||
|
|
7d583be8e1 | ||
|
|
ccb3b35694 | ||
|
|
48893d4daa | ||
|
|
0388e5dd7f | ||
|
|
7a68903318 | ||
|
|
64766100fe | ||
|
|
0576dd91b7 | ||
|
|
f4440e992f | ||
|
|
ea83b5a892 | ||
|
|
d148952c99 | ||
|
|
ed9a810908 | ||
|
|
6960cd6853 | ||
|
|
5bd86ba600 | ||
|
|
70bc49479d | ||
|
|
81e0c105d6 | ||
|
|
527e2aec1f | ||
|
|
cd6661260c | ||
|
|
efa522cc73 | ||
|
|
f9bd1b3d30 | ||
|
|
4cfdb14714 | ||
|
|
6fb802e6b9 | ||
|
|
9b30fecb0c | ||
|
|
e77acc1002 | ||
|
|
07e8b780a2 | ||
|
|
e060395786 | ||
|
|
661b14dec5 | ||
|
|
b8e63b7ef6 | ||
|
|
fd78e35a86 | ||
|
|
db55dfe3c7 | ||
|
|
bda3121f98 | ||
|
|
fd4981f3e2 | ||
|
|
ae1bedd94a | ||
|
|
90b67f90fa | ||
|
|
9c821fb5f5 | ||
|
|
1f9691ace1 | ||
|
|
5331cd99c6 | ||
|
|
1c3f24c78f | ||
|
|
e179e74df3 | ||
|
|
98602bd311 | ||
|
|
5f01124c74 | ||
|
|
4b5368be8e | ||
|
|
6379014f13 | ||
|
|
aa640020be | ||
|
|
92f4e600d1 | ||
|
|
25a6b6fa65 | ||
|
|
3cbe1295f9 | ||
|
|
72581fb2b1 | ||
|
|
97c89590e0 | ||
|
|
b6ba86f3c1 | ||
|
|
cedc291872 | ||
|
|
1d30486f82 | ||
|
|
9f1b4c9035 | ||
|
|
80ebb34ad1 | ||
|
|
e0e11fd99d | ||
|
|
578a933f30 | ||
|
|
57493a1f69 | ||
|
|
3a4100fa94 | ||
|
|
0c1af1d613 | ||
|
|
4e46431798 | ||
|
|
bec66f49a2 | ||
|
|
4019768fa1 | ||
|
|
25d902fd3e | ||
|
|
30f006538d | ||
|
|
15b1fee42d | ||
|
|
d69b816459 | ||
|
|
bf79721e97 | ||
|
|
66a0b44284 | ||
|
|
8693294ea6 | ||
|
|
14ac7927f1 | ||
|
|
b4674473d7 | ||
|
|
f01ece1d3d | ||
|
|
08160a41a6 | ||
|
|
e617698770 | ||
|
|
ee31bdf18b | ||
|
|
305b911c0d | ||
|
|
842abf78d2 | ||
|
|
134e8d1c1b | ||
|
|
733e90f747 | ||
|
|
6c92f7a864 | ||
|
|
f69b5b6e8f | ||
|
|
59e53ee7b7 | ||
|
|
62e1b0118c | ||
|
|
b7e9066b9d | ||
|
|
2d6532b8ee | ||
|
|
ebd1f1b00f | ||
|
|
95a1ceb080 | ||
|
|
3f9e7d1dba | ||
|
|
eab80f78d9 | ||
|
|
aa9fdd56ec | ||
|
|
c727261f67 | ||
|
|
703c62aa74 | ||
|
|
6e1f90228b | ||
|
|
3be089d2a5 | ||
|
|
692d3d35cc | ||
|
|
c52cb8362e | ||
|
|
93ac215ab4 | ||
|
|
f9eb86b50a | ||
|
|
a7f9992a4e | ||
|
|
13fde0d135 | ||
|
|
5105c6c50f | ||
|
|
af152ebe50 | ||
|
|
dea4452e42 | ||
|
|
af07631d83 | ||
|
|
d2ca00ca53 | ||
|
|
bb2f7bdfc4 | ||
|
|
b1379d9153 | ||
|
|
ea4b286659 | ||
|
|
2d00cb9a29 | ||
|
|
2ef1a20ae4 | ||
|
|
95defddfff | ||
|
|
009bdd91cc | ||
|
|
63bbead41e | ||
|
|
2c9a96b62a | ||
|
|
ace7fad62a | ||
|
|
3c73cc8bad | ||
|
|
83c41c265d | ||
|
|
c8bc5618dc | ||
|
|
60d770f265 | ||
|
|
6f4b9dcad7 | ||
|
|
1bba31f7af | ||
|
|
4705e584b0 | ||
|
|
80bbe5df6a | ||
|
|
88c4d88e06 | ||
|
|
718f459026 | ||
|
|
5c3ddcff3e | ||
|
|
08acececb2 | ||
|
|
27d6ae2881 | ||
|
|
5c4d9f4ca4 | ||
|
|
9ece327881 | ||
|
|
1b0ef3f358 | ||
|
|
a5eca0614a | ||
|
|
7b2509fadb | ||
|
|
f6e0bc28f4 | ||
|
|
e87056408e | ||
|
|
c945f32989 | ||
|
|
8d37917d8b | ||
|
|
68cc2dff53 | ||
|
|
45babbca92 | ||
|
|
b56dcfb7e9 | ||
|
|
a56114d84a | ||
|
|
de8a26c5b0 | ||
|
|
48f39524c4 | ||
|
|
2b4ef312c3 | ||
|
|
b4d175b811 | ||
|
|
7ff6c2a421 | ||
|
|
cf0a438f32 | ||
|
|
9e1bfa3564 | ||
|
|
3c266183e1 | ||
|
|
5c5f5d064a | ||
|
|
fc18ec4588 | ||
|
|
3fd2fa27e7 | ||
|
|
cf637f8c2f | ||
|
|
228fca9f0c | ||
|
|
c5ce8998e2 | ||
|
|
a4204bf11e | ||
|
|
3e44d15fc1 | ||
|
|
4f07d8688c | ||
|
|
89fda1a4ae | ||
|
|
f678e7ef34 | ||
|
|
24e8208deb | ||
|
|
3c66a1b35d | ||
|
|
5a2299e8b6 | ||
|
|
8087953b90 | ||
|
|
77a15b44c9 | ||
|
|
2177b494b9 | ||
|
|
10497c2bf4 | ||
|
|
e7fd744941 | ||
|
|
b9bfbc9e98 | ||
|
|
ba6f1343cc | ||
|
|
0d07d4bc69 | ||
|
|
94931a21fb | ||
|
|
ce295605ad | ||
|
|
9e371fd083 | ||
|
|
9fa5a843cb | ||
|
|
8b5fb407e5 | ||
|
|
8ef1e25f8c | ||
|
|
ce3dd2b6db | ||
|
|
a98010d0c1 | ||
|
|
a915a69886 | ||
|
|
bb4ffd8c6e | ||
|
|
403710354b | ||
|
|
9b3743a8bc | ||
|
|
9642ff63ca | ||
|
|
5a1862431e | ||
|
|
efed2b75a5 | ||
|
|
5a87a8805e | ||
|
|
8c48084b3f | ||
|
|
60fd442ed7 | ||
|
|
1d6c5a283e | ||
|
|
a53f876e09 | ||
|
|
88d894212b | ||
|
|
f2d4319366 | ||
|
|
3eb8d64381 | ||
|
|
818ce549d9 | ||
|
|
db7800d170 | ||
|
|
bc1c24efb1 | ||
|
|
5ad632c34a | ||
|
|
65f95e5c4b | ||
|
|
bb406594d1 | ||
|
|
b7a7b7bc63 | ||
|
|
1c59d846e3 | ||
|
|
3b40bb7d28 | ||
|
|
a171e17097 | ||
|
|
c881d96d2f | ||
|
|
f1a99a2d65 | ||
|
|
d02adabe5d | ||
|
|
286730165d | ||
|
|
95a58252cf | ||
|
|
bf6643643b | ||
|
|
8d780d6712 | ||
|
|
576c7227c6 | ||
|
|
915d375f0a | ||
|
|
e9487a81a7 | ||
|
|
0a2fe01b66 | ||
|
|
9de9bde7d8 | ||
|
|
fbc91d3d3d | ||
|
|
47672614df | ||
|
|
8c01c4a155 | ||
|
|
5dc7f8bfe3 | ||
|
|
cc01d15d74 | ||
|
|
5c980e8d97 | ||
|
|
c01e3beb2e | ||
|
|
a5b16e3694 | ||
|
|
866cd52ada | ||
|
|
2d308aaa20 | ||
|
|
0456eb54ee | ||
|
|
ce6fced6a4 | ||
|
|
fc56f52c74 | ||
|
|
f7e65eeece | ||
|
|
8c94de4a9c | ||
|
|
46971c1c82 | ||
|
|
fb5c3c7eb6 | ||
|
|
ea42237444 | ||
|
|
2a76c2678e | ||
|
|
72b6e5fabe | ||
|
|
f739fc1f55 | ||
|
|
aecfca5020 | ||
|
|
f024ae442f | ||
|
|
07a9aad4a4 | ||
|
|
22ab58077e | ||
|
|
4b666688c9 | ||
|
|
d118332366 | ||
|
|
9f32e0da14 | ||
|
|
1cef223a06 | ||
|
|
29da1233f3 | ||
|
|
a5b3d22058 | ||
|
|
d37e958a0b | ||
|
|
0498ac7364 | ||
|
|
67bdeb9945 | ||
|
|
a227307387 | ||
|
|
0e0309cabf | ||
|
|
fd2dfc83c6 | ||
|
|
9e736891c4 | ||
|
|
fbabf0dcb8 | ||
|
|
7128791152 | ||
|
|
94456b5bc3 | ||
|
|
2105c6b177 | ||
|
|
34156f79e8 | ||
|
|
bb1a2530f5 | ||
|
|
06613746f9 | ||
|
|
98ca948afe | ||
|
|
fa58fe5f4e | ||
|
|
46f230c487 | ||
|
|
13a987aba3 | ||
|
|
9cef323581 | ||
|
|
7ea7576188 | ||
|
|
f8abbfd42b | ||
|
|
5cd1821bc9 | ||
|
|
2ef7f26ffb | ||
|
|
184bea49e2 | ||
|
|
c853fb2068 | ||
|
|
79e0a93e48 | ||
|
|
3867c1d7d1 | ||
|
|
b9b6b050cc | ||
|
|
d960736b3d | ||
|
|
3e8923f105 | ||
|
|
17cca3e69d | ||
|
|
12714c489f | ||
|
|
f788d61b4a | ||
|
|
5c726af00b | ||
|
|
d1d207fbb2 | ||
|
|
6c7f8df7f7 | ||
|
|
6f8c9b1504 | ||
|
|
4f9aedbc84 | ||
|
|
afa0f572ce | ||
|
|
a6a1b9ddbd | ||
|
|
c1f5b4593f | ||
|
|
f1de4dc1cc | ||
|
|
4ae0d9a9c6 | ||
|
|
fcd0b579cf | ||
|
|
52fb0343e4 | ||
|
|
1050b4580a | ||
|
|
344c42172e | ||
|
|
93cc0fd7f1 | ||
|
|
05fe636b55 | ||
|
|
f22467d099 | ||
|
|
4bc3899b32 | ||
|
|
fc4d6bf5f1 | ||
|
|
8ed0672a8f | ||
|
|
282e347a1b | ||
|
|
1bfb02b440 | ||
|
|
71b03bd9ae | ||
|
|
cbd69822eb | ||
|
|
db900f4dd2 | ||
|
|
a707e695bc | ||
|
|
4feceac205 | ||
|
|
10c20faaca | ||
|
|
abcd512401 | ||
|
|
dee7a237ee | ||
|
|
3975eba12c | ||
|
|
ade91ebdab | ||
|
|
1bf194dd0f | ||
|
|
2eca8db8aa | ||
|
|
78415bc1ff | ||
|
|
e2469bcd0f | ||
|
|
54d64b7da2 | ||
|
|
d548f3d12f | ||
|
|
668995da73 | ||
|
|
9eeae8eac6 | ||
|
|
7e7056aa94 | ||
|
|
b633b8d271 | ||
|
|
45c7b9ccb8 | ||
|
|
3ad1a57dfc | ||
|
|
3cbe236a36 | ||
|
|
39816c1e8a | ||
|
|
5587dd43b9 | ||
|
|
715d1e4eb8 | ||
|
|
af172fb70d | ||
|
|
8c8bc104eb | ||
|
|
51b20fb5db | ||
|
|
445ba26667 | ||
|
|
886448f4ba | ||
|
|
ede4341ef3 | ||
|
|
fe363f32ec | ||
|
|
31562e7571 | ||
|
|
0bdb51e4ca | ||
|
|
67a5d7ac21 | ||
|
|
5e7f06c476 | ||
|
|
9a69852296 | ||
|
|
fdf8edf474 | ||
|
|
47e1a98bee | ||
|
|
2d8572b943 | ||
|
|
660cfdbd50 | ||
|
|
4208595da6 | ||
|
|
b6b2d2fc6f | ||
|
|
6c4c632848 | ||
|
|
78cf62176f | ||
|
|
df971c7a42 | ||
|
|
1fcabb7f2d | ||
|
|
9fb60c9ea2 | ||
|
|
9c11a4646f | ||
|
|
b036a78776 | ||
|
|
60bb3cb704 | ||
|
|
a722925b8e | ||
|
|
419c5de50e | ||
|
|
37faed565e | ||
|
|
622953e61f | ||
|
|
17926c3f6a | ||
|
|
48d85170c2 | ||
|
|
08d179c520 | ||
|
|
5752387da8 | ||
|
|
1ebde65f03 | ||
|
|
89f536e332 | ||
|
|
8784329333 | ||
|
|
d73538722d | ||
|
|
d49d3f0a2f | ||
|
|
8466dd4c2b | ||
|
|
6bb1e688c6 | ||
|
|
9bc1c4c4f3 | ||
|
|
a554cb8211 | ||
|
|
145d38403e | ||
|
|
10d4af5674 | ||
|
|
ed3b4d2de3 | ||
|
|
e66d324877 | ||
|
|
f7f18627a2 | ||
|
|
d18630020f | ||
|
|
a715ec318c | ||
|
|
0ef5a77dc9 | ||
|
|
b43abf83b8 | ||
|
|
84d28db3a7 | ||
|
|
74d99fa0be | ||
|
|
3ff0320ed8 | ||
|
|
16cb9e9785 | ||
|
|
d92279dfcb | ||
|
|
4b9d28d0e5 | ||
|
|
e6a60dfe50 | ||
|
|
d219056e9d | ||
|
|
6ff6b099b5 | ||
|
|
c5b9699098 | ||
|
|
6937bfdf67 | ||
|
|
39ee3fcfaa | ||
|
|
16cdfd05a0 | ||
|
|
f49d4787be | ||
|
|
2076700dc4 | ||
|
|
76c135913e | ||
|
|
c3534d5445 | ||
|
|
fc60b16d65 | ||
|
|
0443c93f77 | ||
|
|
0e770958ac | ||
|
|
2a54c71b6c | ||
|
|
50463291ab | ||
|
|
43cc34042a | ||
|
|
a02244ccda | ||
|
|
a739619121 | ||
|
|
5db97a5f1c | ||
|
|
804ba9c9cc | ||
|
|
5ecbcea946 | ||
|
|
11be2b6289 | ||
|
|
eefae0307b | ||
|
|
d397ee28ea | ||
|
|
02c821128e | ||
|
|
71dc15d45f | ||
|
|
1078387b22 | ||
|
|
35fab27d15 | ||
|
|
915dc7a908 | ||
|
|
e5a9738983 | ||
|
|
2ff73219a2 | ||
|
|
5dc1270ed1 | ||
|
|
9e95ad5a85 | ||
|
|
f97cf0e446 | ||
|
|
bd4fa0d5c2 | ||
|
|
f60d367184 | ||
|
|
6e231f2ec5 | ||
|
|
13ba2d2e47 | ||
|
|
ba4a163e24 | ||
|
|
b7db8684db | ||
|
|
a7595dc468 | ||
|
|
d2c8c3565b | ||
|
|
422d1031f4 | ||
|
|
c9a79cf100 | ||
|
|
c42d47a619 | ||
|
|
a26f871d32 | ||
|
|
d481c1bcc5 | ||
|
|
379e3596b4 | ||
|
|
423a7cdbba | ||
|
|
841fa48186 | ||
|
|
61e35157e3 | ||
|
|
87f655f56d | ||
|
|
692b8d0722 | ||
|
|
5f9f623c3f | ||
|
|
e595b6cd90 | ||
|
|
a748eebf3e | ||
|
|
6bdd544867 | ||
|
|
705eadf8ce | ||
|
|
b7c6e4eafc | ||
|
|
f4aba286fe | ||
|
|
5fa4f6de11 | ||
|
|
db1f045c42 | ||
|
|
eaba4817bd | ||
|
|
96cb2247df | ||
|
|
99fa7a1f52 | ||
|
|
e0ba928296 | ||
|
|
16fd5e8f1f | ||
|
|
201e95a417 | ||
|
|
dc01592991 | ||
|
|
c5fb2bd566 | ||
|
|
d03d996155 | ||
|
|
9618412a44 | ||
|
|
967e97661f | ||
|
|
b757312fe0 | ||
|
|
2ed8ec0bdf | ||
|
|
97f6e3741a | ||
|
|
c2d3244d26 | ||
|
|
eafeba792d | ||
|
|
c9318b6fbf | ||
|
|
99be382abf | ||
|
|
7cfcfca210 | ||
|
|
f29daccb19 | ||
|
|
be869fce6c | ||
|
|
7bb0414a39 | ||
|
|
3f8807d063 | ||
|
|
67642e6246 | ||
|
|
0d215597f3 | ||
|
|
f41bd2b582 | ||
|
|
5c9ec1911b | ||
|
|
1a0b7fe984 | ||
|
|
26ee25d7bb | ||
|
|
aabf52d3cf | ||
|
|
99fcb46a7e | ||
|
|
6580c5e5bf | ||
|
|
63e7d4dc08 | ||
|
|
cc6900d846 | ||
|
|
ca2ad22884 | ||
|
|
40944f0f2d | ||
|
|
91a3e488b1 | ||
|
|
9a1f517e6e | ||
|
|
9a5d4610f7 | ||
|
|
41c524fce4 | ||
|
|
5f9fa95554 | ||
|
|
6950be8ea9 | ||
|
|
c5a8bf64d0 | ||
|
|
a2b9a6e9df | ||
|
|
a0c567f0da | ||
|
|
c7feafdde6 | ||
|
|
e1e74b0aeb | ||
|
|
673411ef97 | ||
|
|
f7e5af7cb1 | ||
|
|
0ee56ce708 | ||
|
|
f93a176398 | ||
|
|
cd2394bc12 | ||
|
|
5c20b8eaff | ||
|
|
4bd499d3a6 | ||
|
|
8a53b94c5a | ||
|
|
d5aff326e3 | ||
|
|
22f66abbe7 | ||
|
|
f635228b1f | ||
|
|
4c708c143d | ||
|
|
c82c614bb9 | ||
|
|
20914dce67 | ||
|
|
5fc407d2f3 | ||
|
|
c7444d38a1 | ||
|
|
81f6136bda | ||
|
|
862d0ea49e | ||
|
|
f2fdfed241 | ||
|
|
15640049cb | ||
|
|
5c163434f8 | ||
|
|
e54c2ea55e | ||
|
|
1ec42693ab | ||
|
|
672864ae4f | ||
|
|
e54d7e42cb | ||
|
|
5d63fce015 | ||
|
|
190fe10eed | ||
|
|
ef410c1e2a | ||
|
|
5a712398e7 | ||
|
|
b1be3fe0da | ||
|
|
97a7ab011b | ||
|
|
694a3050b9 | ||
|
|
8164e65188 | ||
|
|
9af0d1eed4 | ||
|
|
72e6ca55ba | ||
|
|
0fb62a7e97 | ||
|
|
930eb70a8b | ||
|
|
462104fa68 | ||
|
|
d0c77d8a7e | ||
|
|
606780b20f | ||
|
|
8f465cf2ca | ||
|
|
4e29476dd9 | ||
|
|
b4328083be | ||
|
|
72ba59f559 | ||
|
|
826168b601 | ||
|
|
66f181992c | ||
|
|
336ef4c37b | ||
|
|
72e7bf7f9c | ||
|
|
acbdbc9be7 | ||
|
|
3551382f8d | ||
|
|
95014d7e6d | ||
|
|
dfe1990484 | ||
|
|
15ff5d0f74 | ||
|
|
1407f61a9c | ||
|
|
6107b794d6 | ||
|
|
7ab8ceab7e | ||
|
|
a4db6a9ebc | ||
|
|
12a2650b6b | ||
|
|
23da7ecedd | ||
|
|
8d9e7b0b26 | ||
|
|
9664047345 | ||
|
|
804fbf9cef | ||
|
|
e10fe074c9 | ||
|
|
7b0e21da74 | ||
|
|
29e142cf1e | ||
|
|
6b765ebabb | ||
|
|
899aa62697 | ||
|
|
a11efba405 | ||
|
|
78280dfc5a | ||
|
|
4220bab08a | ||
|
|
f7dcf8de15 | ||
|
|
7e32b50fee | ||
|
|
c875b75272 | ||
|
|
7368b9ca1d | ||
|
|
493e8c1a22 | ||
|
|
1b16b24550 | ||
|
|
7637300632 | ||
|
|
3369459d41 | ||
|
|
bdbce57217 | ||
|
|
8536472fe9 | ||
|
|
ad4fda7bb4 | ||
|
|
36e1b86952 | ||
|
|
0c9834e4ca | ||
|
|
360af74519 | ||
|
|
d099ac457d | ||
|
|
fc330ce165 | ||
|
|
b52dd5fc05 | ||
|
|
b517ce132f | ||
|
|
acec35846c | ||
|
|
af661898c2 | ||
|
|
e2f5a4849c | ||
|
|
399b7f6223 | ||
|
|
782f7af332 | ||
|
|
66af6565bf | ||
|
|
8a00aa8550 | ||
|
|
b07adc03d2 | ||
|
|
a978e3c199 | ||
|
|
bb3c977448 | ||
|
|
8057de408e | ||
|
|
0be4ee71e7 | ||
|
|
7ff5f14748 | ||
|
|
d5e58c817d | ||
|
|
8a08016fb9 | ||
|
|
d45ddd3762 | ||
|
|
0e98e8c893 | ||
|
|
84a09bec0e | ||
|
|
6fd27ec7ec | ||
|
|
91e2a318a5 | ||
|
|
1221c5bcad | ||
|
|
8e3befc301 | ||
|
|
2df62385f1 | ||
|
|
9f3b13dfa1 | ||
|
|
9c27e1233e | ||
|
|
825da95550 | ||
|
|
18bda2dbbe | ||
|
|
630a9b4896 | ||
|
|
e6399d2bfe | ||
|
|
4bae0d15ec | ||
|
|
760a75d1f1 | ||
|
|
c08912fc78 | ||
|
|
316d804336 | ||
|
|
d3658a52dd | ||
|
|
b3e42a1f07 | ||
|
|
dee07b25a2 | ||
|
|
f460bf36fe | ||
|
|
020d122799 | ||
|
|
699b4b12da | ||
|
|
3ec96f21d1 | ||
|
|
c6c5970864 | ||
|
|
570146c4a6 | ||
|
|
75b7f80f6c | ||
|
|
1c1a99e5ae | ||
|
|
0203f6e6f1 | ||
|
|
66612f97ec | ||
|
|
6d215c284c | ||
|
|
8e9e406341 | ||
|
|
b6772c4104 | ||
|
|
d6a830da1a | ||
|
|
2f7a895e28 | ||
|
|
5cb5b0eb45 | ||
|
|
33ae951030 | ||
|
|
1cb56216ba | ||
|
|
6409574ecf | ||
|
|
a94d39e493 | ||
|
|
fec008c589 | ||
|
|
358e58ea85 | ||
|
|
e8bbc9598f | ||
|
|
49e0c8e0bd | ||
|
|
0623da8aa9 | ||
|
|
8356524cf2 | ||
|
|
420123f1ff | ||
|
|
3ea3d88889 | ||
|
|
f88876a3c7 | ||
|
|
de834f9988 | ||
|
|
83ace00e14 | ||
|
|
1f163dfcbd | ||
|
|
057d24a227 | ||
|
|
9418217d38 | ||
|
|
181f89446f | ||
|
|
37b4bfc9fc | ||
|
|
b84022f88b | ||
|
|
a88ceada60 | ||
|
|
f72a70a549 | ||
|
|
70e84526cc | ||
|
|
1bb4c9d213 | ||
|
|
0b96aa8871 | ||
|
|
8645ef60ec | ||
|
|
a2e4980364 | ||
|
|
39ff57ecd2 | ||
|
|
f3025daa1f | ||
|
|
364ecc191e | ||
|
|
eaa1798443 | ||
|
|
11f713209d | ||
|
|
d96bc1b32e | ||
|
|
1022f422c8 | ||
|
|
3e21ac02fc | ||
|
|
f2a17a0aca | ||
|
|
0593bca476 | ||
|
|
c361185efb | ||
|
|
9882fe0eda | ||
|
|
bbb5ab448e | ||
|
|
9c07550f40 | ||
|
|
36b9234f26 | ||
|
|
2dc1981932 | ||
|
|
fda817cb1d | ||
|
|
8ccc9e407e | ||
|
|
bf07a79e3a | ||
|
|
6fd2d74539 | ||
|
|
8a91e07b97 | ||
|
|
c53b2d63d7 | ||
|
|
a8b7e1b5d9 | ||
|
|
9d90e3c7ce | ||
|
|
187aa52d92 | ||
|
|
0e08a6a69c | ||
|
|
be88a1f14a | ||
|
|
3794b4e1a1 | ||
|
|
46cc30e1f5 | ||
|
|
c261d39f99 | ||
|
|
f7bc7d3911 | ||
|
|
d880d305f4 | ||
|
|
19fe9c0f5e | ||
|
|
03eddfa142 | ||
|
|
d6a3189651 | ||
|
|
71f17f2cf1 | ||
|
|
6e2092b784 | ||
|
|
499fd131b0 | ||
|
|
9493240e9f | ||
|
|
ed70cacaa6 | ||
|
|
197e5203eb | ||
|
|
1f8a98609c | ||
|
|
69ee3a15b6 | ||
|
|
b13c2e3018 | ||
|
|
27b8274d3e | ||
|
|
cc6c506995 | ||
|
|
216bfeaa4a | ||
|
|
0781ac8450 | ||
|
|
5ec6a40ceb | ||
|
|
ec7a1fa266 | ||
|
|
729e530a6f | ||
|
|
c85c96a70e | ||
|
|
ff1898c334 | ||
|
|
68b4ad722d | ||
|
|
3c1bf41e5a | ||
|
|
9a03005d87 | ||
|
|
7e2878ec83 | ||
|
|
c6064f40d2 | ||
|
|
af5fe8e053 | ||
|
|
0ad692238f | ||
|
|
f220c0b8fe | ||
|
|
c33c834f5a | ||
|
|
0cf224fcb0 | ||
|
|
e50558c8fe | ||
|
|
99b43e74ea | ||
|
|
f4f25b3f96 | ||
|
|
e3c04d6a6b | ||
|
|
015df950f2 | ||
|
|
980c9bd9a0 | ||
|
|
b91abea97e | ||
|
|
6bb222bfc3 | ||
|
|
9ece2217eb | ||
|
|
d1fac72fe0 | ||
|
|
adb728baa1 | ||
|
|
e36432beed | ||
|
|
2830221820 | ||
|
|
4e0cbccc11 | ||
|
|
b341ae886b | ||
|
|
9913e1397e | ||
|
|
b1a048901f | ||
|
|
20c0c5f655 | ||
|
|
9fb1612dac | ||
|
|
6d064dfca0 | ||
|
|
8ee49bb2d3 | ||
|
|
2343ff3454 | ||
|
|
6224afc5d9 | ||
|
|
422dcb442d | ||
|
|
29fa9278a2 | ||
|
|
e478045ce2 | ||
|
|
0103c563d1 | ||
|
|
a265527ea7 | ||
|
|
bb45b23ba9 | ||
|
|
f8797ef06a | ||
|
|
253d0f0bf0 | ||
|
|
65a621e0b9 | ||
|
|
8727be4241 | ||
|
|
21e1ca7203 | ||
|
|
7c6a2d796a | ||
|
|
8cac2f9af2 | ||
|
|
c0d265375a | ||
|
|
98b7ffcbdd | ||
|
|
b18a8791ef | ||
|
|
b71add0769 | ||
|
|
2ee7155764 | ||
|
|
c5e1787e3b | ||
|
|
1bf9d72f7e | ||
|
|
19333a8056 | ||
|
|
664e97194a | ||
|
|
afb5330cfe | ||
|
|
42c816d2d4 | ||
|
|
ba0f3485ba | ||
|
|
a42caebf3e | ||
|
|
fad66d13f3 | ||
|
|
431d311193 | ||
|
|
9fa3f8cfac | ||
|
|
121e1f3b71 | ||
|
|
471f5602b2 | ||
|
|
fb7481dc05 | ||
|
|
0ed458d4a3 | ||
|
|
51a3bd6bea | ||
|
|
ecf5adefa8 | ||
|
|
db3e568248 | ||
|
|
15d6463f09 | ||
|
|
30951f2573 | ||
|
|
8f88c3d4ea | ||
|
|
f38a0c5e6d | ||
|
|
48fff588c9 | ||
|
|
6112fa59f3 | ||
|
|
78e2581528 | ||
|
|
e41757ae2b | ||
|
|
1b83c7705f | ||
|
|
7bc5717357 | ||
|
|
6aaec5c159 | ||
|
|
aca0232e71 | ||
|
|
73bfe308b7 | ||
|
|
d68ac745d4 | ||
|
|
103097b74f | ||
|
|
3facfa3359 | ||
|
|
e852001943 | ||
|
|
374d7478f6 | ||
|
|
6c1b44aae2 | ||
|
|
99827a86b4 | ||
|
|
846b139e05 | ||
|
|
d66c7bf38b | ||
|
|
e77a60df3a | ||
|
|
8ad35e1bff | ||
|
|
7ce4667856 | ||
|
|
3637124881 | ||
|
|
7e3b9e1d0f | ||
|
|
0867807ba6 | ||
|
|
44ca65b1e1 | ||
|
|
fda3a63e82 | ||
|
|
db2fd77c8b | ||
|
|
2f49dedc63 | ||
|
|
594cd572de | ||
|
|
865d2e4b6e | ||
|
|
59319f56bf | ||
|
|
9eb398a953 | ||
|
|
2cb9bbf949 | ||
|
|
22ff086dc1 | ||
|
|
6a42de1e21 | ||
|
|
4ac42ea39f | ||
|
|
9016c5d51d | ||
|
|
fcb4ed6cef | ||
|
|
368a411f01 | ||
|
|
e6772307ba | ||
|
|
6b386bbc8f | ||
|
|
8983d06d05 | ||
|
|
12e9241f71 | ||
|
|
1e1445f393 | ||
|
|
1b08b578a8 | ||
|
|
e469e50f76 | ||
|
|
9046ae1602 | ||
|
|
f1bf2625e6 | ||
|
|
1451af72ff | ||
|
|
26311e9480 | ||
|
|
c208b06c6a | ||
|
|
be373a76a7 | ||
|
|
5721c6c168 | ||
|
|
0843cd761f | ||
|
|
ff43003ce3 | ||
|
|
8e0f905aca | ||
|
|
2b730069d7 | ||
|
|
4d87627091 | ||
|
|
d9eff759dc | ||
|
|
9c3ffda4d2 | ||
|
|
fa30ed1dd8 | ||
|
|
947ed121dc | ||
|
|
9448f52d4a | ||
|
|
54be76f0ab | ||
|
|
32cd649fe4 | ||
|
|
69dc711466 | ||
|
|
78212245dd | ||
|
|
5bbc39bd88 | ||
|
|
6b14eb7ad1 | ||
|
|
ae17b620f6 | ||
|
|
c5b72ac286 | ||
|
|
5f6dce5503 | ||
|
|
6dd8692bb8 | ||
|
|
16f4849e88 | ||
|
|
04809e47f1 | ||
|
|
691cf67f68 | ||
|
|
c2e1646473 | ||
|
|
9ac5560c41 | ||
|
|
68cbdcf3c9 | ||
|
|
6474a1bf63 | ||
|
|
572092d362 | ||
|
|
01ea5a1634 | ||
|
|
b202c8b43e | ||
|
|
79fd98753a | ||
|
|
447da083c0 | ||
|
|
0643b36ed5 | ||
|
|
4ee3ac16af | ||
|
|
6824f38c68 | ||
|
|
b8f23bb388 | ||
|
|
e238d67818 | ||
|
|
992a9bdd3b | ||
|
|
ceaae1c1cc | ||
|
|
1c163c92dc | ||
|
|
a42aa9372c | ||
|
|
013592bd54 | ||
|
|
2101bae095 | ||
|
|
cfa1107135 | ||
|
|
a269ef660a | ||
|
|
c43c4f17e9 | ||
|
|
de25e6af51 | ||
|
|
18d3629b6c | ||
|
|
50c477a408 | ||
|
|
ea9cd7d905 | ||
|
|
2bf4ac20ea | ||
|
|
94ff881897 | ||
|
|
2975b3c1b9 | ||
|
|
0143c4ff85 | ||
|
|
f59566d20b | ||
|
|
395f0ad2a7 | ||
|
|
2af1fc6759 | ||
|
|
c1e7122d1c | ||
|
|
e5624b1224 | ||
|
|
6e380bafca | ||
|
|
bb9fd94430 | ||
|
|
07bc5d5c6b | ||
|
|
651b7116dd | ||
|
|
34438bd039 | ||
|
|
7b53b8691c | ||
|
|
8748d6f200 | ||
|
|
8d95511650 | ||
|
|
9aa5953a86 | ||
|
|
5ccdfda747 | ||
|
|
00ad44cb91 | ||
|
|
b7519cd880 | ||
|
|
ac44769539 | ||
|
|
9e95b80805 | ||
|
|
50086ca5c7 | ||
|
|
49086b2a76 | ||
|
|
1f28fe9933 | ||
|
|
4465aa264c | ||
|
|
2c1bc96161 | ||
|
|
7127159a5b | ||
|
|
9f0eb6f077 | ||
|
|
da19cc06e3 | ||
|
|
fd92377cf2 | ||
|
|
c201938b8b | ||
|
|
b3765204b1 | ||
|
|
786257e051 | ||
|
|
9559634151 | ||
|
|
cf12ed8f08 | ||
|
|
e213f49c75 | ||
|
|
09c7cc113a | ||
|
|
e1e7e039a9 | ||
|
|
05a0f0d23f | ||
|
|
d3853019eb | ||
|
|
ccbaac55b3 | ||
|
|
771292ced9 | ||
|
|
5d4262e8b3 | ||
|
|
d96da9a639 | ||
|
|
288a805d0f | ||
|
|
8e55ceea77 | ||
|
|
14f1d9fbad | ||
|
|
eb6582bc24 | ||
|
|
4afe67f33d | ||
|
|
5d7b10f569 | ||
|
|
340c2e48df | ||
|
|
86257b1865 | ||
|
|
eea1adccfd | ||
|
|
242be14f88 | ||
|
|
7e013b723d | ||
|
|
4d55939f53 | ||
|
|
e5e7546d49 | ||
|
|
e560795d04 | ||
|
|
15b0342bd7 | ||
|
|
8d05a5f3d4 | ||
|
|
358ad29b59 | ||
|
|
5c4f99b828 | ||
|
|
b3f123c715 | ||
|
|
85c2351af2 | ||
|
|
ec19529c99 | ||
|
|
d5ebd02afe | ||
|
|
37d82ab795 | ||
|
|
5d08481137 | ||
|
|
0861b7541d | ||
|
|
abf7078842 | ||
|
|
c4012fae4e | ||
|
|
d6082ab6c3 | ||
|
|
77367e415f | ||
|
|
6c006c68c1 | ||
|
|
026fdeb4ce | ||
|
|
1034218e6e | ||
|
|
a21062f502 | ||
|
|
2e157f1bc6 | ||
|
|
a697e63b8c | ||
|
|
d28d55c7db | ||
|
|
8863488286 | ||
|
|
53cfdef1ac | ||
|
|
42ea7ecbd6 | ||
|
|
d58d08c350 | ||
|
|
65a259b9df | ||
|
|
cbfbfbee13 | ||
|
|
e503b37ddc | ||
|
|
217eef39f3 | ||
|
|
dcdbce9b21 | ||
|
|
71db8fe185 | ||
|
|
9b96cb66d5 | ||
|
|
78bccbbbc2 | ||
|
|
b0a8f9575c | ||
|
|
61104a9970 | ||
|
|
8d13dbdd0c | ||
|
|
9afb41004e | ||
|
|
cdd542f6e6 | ||
|
|
f520686002 | ||
|
|
e4d09bb615 | ||
|
|
10f6ccf6cc | ||
|
|
d9fa67b16f | ||
|
|
cf228ae02b | ||
|
|
cb4d62ab9a | ||
|
|
d2f75aec04 | ||
|
|
a609fbc07b | ||
|
|
1b9c7ae0ac | ||
|
|
492f2117fb | ||
|
|
2346f83635 | ||
|
|
8925bfb182 | ||
|
|
8f2b1f0eff |
1
.agent/skills
Symbolic link
1
.agent/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.claude/skills/
|
||||
@@ -1,238 +0,0 @@
|
||||
# Binary Sensor Platform Reference
|
||||
|
||||
Binary sensors represent on/off states.
|
||||
|
||||
## Basic Binary Sensor
|
||||
|
||||
```python
|
||||
"""Binary sensor platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
DoorSensor(coordinator),
|
||||
MotionSensor(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class DoorSensor(MyEntity, BinarySensorEntity):
|
||||
"""Door open/close sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
_attr_translation_key = "door"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_door"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if door is open."""
|
||||
return self.coordinator.data.door_open
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
Common binary sensor device classes:
|
||||
|
||||
| Device Class | On Means | Off Means |
|
||||
|--------------|----------|-----------|
|
||||
| `BATTERY` | Low | Normal |
|
||||
| `BATTERY_CHARGING` | Charging | Not charging |
|
||||
| `CONNECTIVITY` | Connected | Disconnected |
|
||||
| `DOOR` | Open | Closed |
|
||||
| `GARAGE_DOOR` | Open | Closed |
|
||||
| `LOCK` | Unlocked | Locked |
|
||||
| `MOISTURE` | Wet | Dry |
|
||||
| `MOTION` | Motion detected | Clear |
|
||||
| `OCCUPANCY` | Occupied | Clear |
|
||||
| `OPENING` | Open | Closed |
|
||||
| `PLUG` | Plugged in | Unplugged |
|
||||
| `POWER` | Power detected | No power |
|
||||
| `PRESENCE` | Present | Away |
|
||||
| `PROBLEM` | Problem | OK |
|
||||
| `RUNNING` | Running | Not running |
|
||||
| `SAFETY` | Unsafe | Safe |
|
||||
| `SMOKE` | Smoke detected | Clear |
|
||||
| `SOUND` | Sound detected | Clear |
|
||||
| `TAMPER` | Tampering | Clear |
|
||||
| `UPDATE` | Update available | Up-to-date |
|
||||
| `VIBRATION` | Vibration | Clear |
|
||||
| `WINDOW` | Open | Closed |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe My binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[MyData], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[MyBinarySensorEntityDescription, ...] = (
|
||||
MyBinarySensorEntityDescription(
|
||||
key="door",
|
||||
translation_key="door",
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
is_on_fn=lambda data: data.door_open,
|
||||
),
|
||||
MyBinarySensorEntityDescription(
|
||||
key="motion",
|
||||
translation_key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda data: data.motion_detected,
|
||||
),
|
||||
MyBinarySensorEntityDescription(
|
||||
key="low_battery",
|
||||
translation_key="low_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda data: data.battery_level < 20 if data.battery_level else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MyBinarySensor(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class MyBinarySensor(MyEntity, BinarySensorEntity):
|
||||
"""Binary sensor using entity description."""
|
||||
|
||||
entity_description: MyBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Connectivity Sensor
|
||||
|
||||
```python
|
||||
class ConnectivitySensor(MyEntity, BinarySensorEntity):
|
||||
"""Device connectivity sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "connectivity"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
return self.coordinator.data.is_connected
|
||||
```
|
||||
|
||||
## Problem Sensor
|
||||
|
||||
```python
|
||||
class ProblemSensor(MyEntity, BinarySensorEntity):
|
||||
"""Problem indicator sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "problem"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if there's a problem."""
|
||||
return self.coordinator.data.has_error
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
return {
|
||||
"error_code": self.coordinator.data.error_code,
|
||||
"error_message": self.coordinator.data.error_message,
|
||||
}
|
||||
```
|
||||
|
||||
## Update Available Sensor
|
||||
|
||||
```python
|
||||
class UpdateAvailableSensor(MyEntity, BinarySensorEntity):
|
||||
"""Firmware update available sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.UPDATE
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "update_available"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if an update is available."""
|
||||
return self.coordinator.data.update_available
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"door": {
|
||||
"name": "Door"
|
||||
},
|
||||
"motion": {
|
||||
"name": "Motion"
|
||||
},
|
||||
"low_battery": {
|
||||
"name": "Low battery"
|
||||
},
|
||||
"connectivity": {
|
||||
"name": "Connectivity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
# Button Platform Reference
|
||||
|
||||
Button entities trigger actions when pressed.
|
||||
|
||||
## Basic Button
|
||||
|
||||
```python
|
||||
"""Button platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up buttons from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
RestartButton(coordinator),
|
||||
IdentifyButton(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class RestartButton(MyEntity, ButtonEntity):
|
||||
"""Restart button."""
|
||||
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
_attr_translation_key = "restart"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_restart"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.coordinator.client.restart()
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
| Device Class | Icon | Use Case |
|
||||
|--------------|------|----------|
|
||||
| `IDENTIFY` | mdi:crosshairs-question | Flash light/beep to locate device |
|
||||
| `RESTART` | mdi:restart | Restart the device |
|
||||
| `UPDATE` | mdi:package-up | Trigger firmware update |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describe My button entity."""
|
||||
|
||||
press_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
BUTTONS: tuple[MyButtonEntityDescription, ...] = (
|
||||
MyButtonEntityDescription(
|
||||
key="restart",
|
||||
translation_key="restart",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.restart(),
|
||||
),
|
||||
MyButtonEntityDescription(
|
||||
key="identify",
|
||||
translation_key="identify",
|
||||
device_class=ButtonDeviceClass.IDENTIFY,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.identify(),
|
||||
),
|
||||
MyButtonEntityDescription(
|
||||
key="factory_reset",
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.factory_reset(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up buttons from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MyButton(coordinator, description)
|
||||
for description in BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class MyButton(MyEntity, ButtonEntity):
|
||||
"""Button using entity description."""
|
||||
|
||||
entity_description: MyButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.entity_description.press_fn(self.coordinator.client)
|
||||
```
|
||||
|
||||
## Identify Button
|
||||
|
||||
```python
|
||||
class IdentifyButton(MyEntity, ButtonEntity):
|
||||
"""Identify button to locate the device."""
|
||||
|
||||
_attr_device_class = ButtonDeviceClass.IDENTIFY
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "identify"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Flash the device LED to identify it."""
|
||||
await self.coordinator.client.identify()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class SafeButton(MyEntity, ButtonEntity):
|
||||
"""Button with error handling."""
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press with error handling."""
|
||||
try:
|
||||
await self.coordinator.client.perform_action()
|
||||
except MyDeviceError as err:
|
||||
raise HomeAssistantError(f"Failed to perform action: {err}") from err
|
||||
```
|
||||
|
||||
## Confirmation Buttons
|
||||
|
||||
For dangerous operations, consider using a diagnostic category and clear naming:
|
||||
|
||||
```python
|
||||
class FactoryResetButton(MyEntity, ButtonEntity):
|
||||
"""Factory reset button."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "factory_reset"
|
||||
_attr_entity_registry_enabled_default = False # Disabled by default
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Perform factory reset."""
|
||||
await self.coordinator.client.factory_reset()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"restart": {
|
||||
"name": "Restart"
|
||||
},
|
||||
"identify": {
|
||||
"name": "Identify"
|
||||
},
|
||||
"factory_reset": {
|
||||
"name": "Factory reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,254 +0,0 @@
|
||||
# Config Flow Reference
|
||||
|
||||
Configuration flows allow users to set up integrations via the UI.
|
||||
|
||||
## Basic Config Flow
|
||||
|
||||
```python
|
||||
"""Config flow for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_API_KEY
|
||||
from homeassistant.helpers.selector import TextSelector
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): TextSelector(),
|
||||
vol.Required(CONF_API_KEY): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MyIntegrationConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for My Integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
# Test connection
|
||||
client = MyClient(user_input[CONF_HOST], user_input[CONF_API_KEY])
|
||||
info = await client.get_device_info()
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Set unique ID and abort if already configured
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=info.name,
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Version Control
|
||||
|
||||
Always set version numbers:
|
||||
|
||||
```python
|
||||
VERSION = 1 # Bump for breaking changes requiring migration
|
||||
MINOR_VERSION = 1 # Bump for backward-compatible changes
|
||||
```
|
||||
|
||||
## Unique ID Management
|
||||
|
||||
```python
|
||||
# Set unique ID and abort if exists
|
||||
await self.async_set_unique_id(device_serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Or abort if data matches (when no unique ID available)
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
## Reauthentication Flow
|
||||
|
||||
```python
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
client = MyClient(
|
||||
self._get_reauth_entry().data[CONF_HOST],
|
||||
user_input[CONF_API_KEY]
|
||||
)
|
||||
info = await client.get_device_info()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Reconfiguration Flow
|
||||
|
||||
```python
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
client = MyClient(user_input[CONF_HOST], reconfigure_entry.data[CONF_API_KEY])
|
||||
info = await client.get_device_info()
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_device")
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_HOST: user_input[CONF_HOST]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOST, default=reconfigure_entry.data[CONF_HOST]): str
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Discovery Flows
|
||||
|
||||
### Zeroconf Discovery
|
||||
|
||||
```python
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
serial = discovery_info.properties.get("serialno")
|
||||
if not serial:
|
||||
return self.async_abort(reason="no_serial")
|
||||
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: str(discovery_info.host)}
|
||||
)
|
||||
|
||||
self._discovered_host = str(discovery_info.host)
|
||||
self._discovered_name = discovery_info.name.removesuffix("._mydevice._tcp.local.")
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_name,
|
||||
data={CONF_HOST: self._discovered_host},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"name": self._discovered_name},
|
||||
)
|
||||
```
|
||||
|
||||
## strings.json for Config Flow
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to device",
|
||||
"description": "Enter your device credentials.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"api_key": "API key"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate",
|
||||
"description": "Please enter a new API key for {name}.",
|
||||
"data": {
|
||||
"api_key": "API key"
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"title": "Discovered device",
|
||||
"description": "Do you want to set up {name}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"wrong_account": "Wrong account",
|
||||
"wrong_device": "Wrong device"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **Never allow user-configurable entry names** (except helper integrations)
|
||||
2. **Always test connection** before creating entry
|
||||
3. **Always set unique ID** when possible
|
||||
4. **Handle all exceptions** - bare `except Exception:` is allowed in config flows
|
||||
5. **100% test coverage required** for all flow paths
|
||||
@@ -1,239 +0,0 @@
|
||||
# Data Update Coordinator Reference
|
||||
|
||||
The coordinator pattern centralizes data fetching and provides efficient polling.
|
||||
|
||||
## Basic Coordinator
|
||||
|
||||
```python
|
||||
"""DataUpdateCoordinator for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from my_library import MyClient, MyData, MyError, AuthError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MyIntegrationConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
"""My integration data update coordinator."""
|
||||
|
||||
config_entry: MyIntegrationConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
client: MyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.client.get_data()
|
||||
except AuthError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from err
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
### Always Pass config_entry
|
||||
|
||||
```python
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry, # Always include this
|
||||
)
|
||||
```
|
||||
|
||||
### Generic Type Parameter
|
||||
|
||||
Specify the data type returned by `_async_update_data`:
|
||||
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
...
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
- **`UpdateFailed`**: API communication errors (will retry)
|
||||
- **`ConfigEntryAuthFailed`**: Authentication issues (triggers reauth flow)
|
||||
|
||||
## Polling Intervals
|
||||
|
||||
**Integration determines intervals** - never make them user-configurable.
|
||||
|
||||
```python
|
||||
# Constants (in const.py)
|
||||
SCAN_INTERVAL_LOCAL = timedelta(seconds=30)
|
||||
SCAN_INTERVAL_CLOUD = timedelta(minutes=5)
|
||||
|
||||
# In coordinator
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, entry: MyIntegrationConfigEntry, client: MyClient) -> None:
|
||||
# Determine interval based on connection type
|
||||
interval = SCAN_INTERVAL_LOCAL if client.is_local else SCAN_INTERVAL_CLOUD
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=interval,
|
||||
config_entry=entry,
|
||||
)
|
||||
```
|
||||
|
||||
**Minimum intervals:**
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
|
||||
## Coordinator with Device Info
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
"""Coordinator with device information."""
|
||||
|
||||
config_entry: MyIntegrationConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
client: MyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, client.serial_number)},
|
||||
name=client.name,
|
||||
manufacturer="My Company",
|
||||
model=client.model,
|
||||
sw_version=client.firmware_version,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.client.get_data()
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
```
|
||||
|
||||
## Multiple Data Sources
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyCoordinatorData:
|
||||
"""Data class for coordinator."""
|
||||
|
||||
sensors: dict[str, SensorData]
|
||||
status: DeviceStatus
|
||||
settings: DeviceSettings
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyCoordinatorData]):
|
||||
"""Coordinator for multiple data sources."""
|
||||
|
||||
async def _async_update_data(self) -> MyCoordinatorData:
|
||||
"""Fetch all data sources."""
|
||||
try:
|
||||
# Fetch all data concurrently
|
||||
sensors, status, settings = await asyncio.gather(
|
||||
self.client.get_sensors(),
|
||||
self.client.get_status(),
|
||||
self.client.get_settings(),
|
||||
)
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
return MyCoordinatorData(
|
||||
sensors=sensors,
|
||||
status=status,
|
||||
settings=settings,
|
||||
)
|
||||
```
|
||||
|
||||
## Setup in __init__.py
|
||||
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Set up My Integration from a config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], entry.data[CONF_API_KEY])
|
||||
|
||||
coordinator = MyCoordinator(hass, entry, client)
|
||||
|
||||
# Perform first refresh - raises ConfigEntryNotReady on failure
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Testing Coordinators
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mock_coordinator(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> MyCoordinator:
|
||||
"""Return a mocked coordinator."""
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, MagicMock())
|
||||
coordinator.data = MyData(temperature=21.5, humidity=45)
|
||||
return coordinator
|
||||
|
||||
|
||||
async def test_coordinator_update_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles update failure."""
|
||||
mock_client.get_data.side_effect = MyError("Connection failed")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(UpdateFailed):
|
||||
await coordinator._async_update_data()
|
||||
```
|
||||
@@ -1,248 +0,0 @@
|
||||
# Device Management Reference
|
||||
|
||||
Device management groups entities and provides device information.
|
||||
|
||||
## Device Info
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Base entity with device info."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.client.serial_number)},
|
||||
name=coordinator.client.name,
|
||||
manufacturer="My Company",
|
||||
model=coordinator.client.model,
|
||||
sw_version=coordinator.client.firmware_version,
|
||||
hw_version=coordinator.client.hardware_version,
|
||||
)
|
||||
```
|
||||
|
||||
## DeviceInfo Fields
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `identifiers` | Set of (domain, id) tuples | `{(DOMAIN, "ABC123")}` |
|
||||
| `connections` | Set of (type, id) tuples | `{(CONNECTION_NETWORK_MAC, mac)}` |
|
||||
| `name` | Device name | `"Living Room Thermostat"` |
|
||||
| `manufacturer` | Manufacturer name | `"My Company"` |
|
||||
| `model` | Model name | `"Smart Thermostat v2"` |
|
||||
| `model_id` | Model identifier | `"THM-2000"` |
|
||||
| `sw_version` | Software/firmware version | `"1.2.3"` |
|
||||
| `hw_version` | Hardware version | `"rev2"` |
|
||||
| `serial_number` | Serial number | `"ABC123456"` |
|
||||
| `configuration_url` | Device config URL | `"http://192.168.1.100"` |
|
||||
| `suggested_area` | Suggested room/area | `"Living Room"` |
|
||||
| `entry_type` | Device entry type | `DeviceEntryType.SERVICE` |
|
||||
| `via_device` | Parent device identifiers | `(DOMAIN, "hub_id")` |
|
||||
|
||||
## Device with Connections
|
||||
|
||||
Use connections (like MAC address) for better device merging:
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(coordinator.client.mac))},
|
||||
identifiers={(DOMAIN, coordinator.client.serial_number)},
|
||||
name=coordinator.client.name,
|
||||
manufacturer="My Company",
|
||||
model=coordinator.client.model,
|
||||
)
|
||||
```
|
||||
|
||||
## Hub and Child Devices
|
||||
|
||||
```python
|
||||
# Hub device
|
||||
class HubEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Hub entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the hub entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.hub_id)},
|
||||
name="My Hub",
|
||||
manufacturer="My Company",
|
||||
model="Hub Pro",
|
||||
)
|
||||
|
||||
|
||||
# Child device connected via hub
|
||||
class ChildEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Child device entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator, device: ChildDevice) -> None:
|
||||
"""Initialize the child entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model=device.model,
|
||||
via_device=(DOMAIN, coordinator.hub_id), # Links to parent hub
|
||||
)
|
||||
```
|
||||
|
||||
## Service Entry Type
|
||||
|
||||
For cloud services without physical devices:
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name="My Cloud Service",
|
||||
manufacturer="My Company",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
```
|
||||
|
||||
## Dynamic Device Addition
|
||||
|
||||
Auto-detect new devices after initial setup:
|
||||
|
||||
```python
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _check_devices() -> None:
|
||||
"""Check for new devices."""
|
||||
current_devices = set(coordinator.data.devices.keys())
|
||||
new_devices = current_devices - known_devices
|
||||
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
MySensor(coordinator, device_id)
|
||||
for device_id in new_devices
|
||||
)
|
||||
|
||||
# Initial setup
|
||||
_check_devices()
|
||||
|
||||
# Listen for updates
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
|
||||
```
|
||||
|
||||
## Stale Device Removal
|
||||
|
||||
Remove devices when they disappear:
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data and handle device removal."""
|
||||
data = await self.client.get_data()
|
||||
|
||||
# Check for removed devices
|
||||
device_registry = dr.async_get(self.hass)
|
||||
current_device_ids = set(data.devices.keys())
|
||||
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
):
|
||||
# Get device ID from identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
if device_id and device_id not in current_device_ids:
|
||||
# Device no longer exists, remove it
|
||||
device_registry.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
## Manual Device Removal
|
||||
|
||||
Allow users to manually remove devices:
|
||||
|
||||
```python
|
||||
# In __init__.py
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: MyIntegrationConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
# Get device ID from identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
if device_id is None:
|
||||
return False
|
||||
|
||||
# Check if device is still present (don't allow removal of active devices)
|
||||
coordinator = config_entry.runtime_data
|
||||
if device_id in coordinator.data.devices:
|
||||
return False # Device still exists, can't remove
|
||||
|
||||
return True # Allow removal of stale device
|
||||
```
|
||||
|
||||
## Device Registry Access
|
||||
|
||||
```python
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
|
||||
# Get device registry
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Get device by identifiers
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
)
|
||||
|
||||
# Get all devices for config entry
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
)
|
||||
|
||||
# Update device
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
sw_version="2.0.0",
|
||||
)
|
||||
```
|
||||
|
||||
## Quality Scale Requirements
|
||||
|
||||
- **Bronze**: No specific device requirements
|
||||
- **Gold**: Devices rule - group entities under devices
|
||||
- **Gold**: Stale device removal - auto-remove disconnected devices
|
||||
- **Gold**: Dynamic device addition - detect new devices at runtime
|
||||
@@ -1,278 +0,0 @@
|
||||
# Diagnostics Reference
|
||||
|
||||
Diagnostics provide debug information for troubleshooting integrations.
|
||||
|
||||
## Basic Diagnostics
|
||||
|
||||
```python
|
||||
"""Diagnostics support for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_API_KEY,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
"serial_number",
|
||||
"mac_address",
|
||||
"latitude",
|
||||
"longitude",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
|
||||
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||
"coordinator_data": async_redact_data(
|
||||
coordinator.data.to_dict(), TO_REDACT
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
## What to Include
|
||||
|
||||
**Do include:**
|
||||
- Configuration data (redacted)
|
||||
- Current coordinator data
|
||||
- Device information
|
||||
- Error states and counts
|
||||
- Connection status
|
||||
- Firmware versions
|
||||
- Feature flags
|
||||
|
||||
**Never include (always redact):**
|
||||
- API keys, tokens, passwords
|
||||
- Geographic coordinates (latitude/longitude)
|
||||
- Personal identifiable information
|
||||
- Email addresses
|
||||
- MAC addresses (unless needed for debugging)
|
||||
- Serial numbers (unless needed for debugging)
|
||||
|
||||
## Comprehensive Diagnostics
|
||||
|
||||
```python
|
||||
"""Diagnostics support for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
"serial",
|
||||
"serial_number",
|
||||
"mac",
|
||||
"mac_address",
|
||||
"email",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Get device registry entries
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
devices = []
|
||||
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
|
||||
entities = []
|
||||
for entity in er.async_entries_for_device(
|
||||
entity_registry, device.id, include_disabled_entities=True
|
||||
):
|
||||
entities.append({
|
||||
"entity_id": entity.entity_id,
|
||||
"unique_id": entity.unique_id,
|
||||
"platform": entity.platform,
|
||||
"disabled": entity.disabled,
|
||||
"disabled_by": entity.disabled_by,
|
||||
})
|
||||
|
||||
devices.append({
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"manufacturer": device.manufacturer,
|
||||
"sw_version": device.sw_version,
|
||||
"hw_version": device.hw_version,
|
||||
"identifiers": list(device.identifiers),
|
||||
"connections": list(device.connections),
|
||||
"entities": entities,
|
||||
})
|
||||
|
||||
return {
|
||||
"entry": {
|
||||
"version": entry.version,
|
||||
"minor_version": entry.minor_version,
|
||||
"data": async_redact_data(dict(entry.data), TO_REDACT),
|
||||
"options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||
},
|
||||
"coordinator": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
|
||||
"data": async_redact_data(coordinator.data.to_dict(), TO_REDACT),
|
||||
},
|
||||
"devices": devices,
|
||||
}
|
||||
```
|
||||
|
||||
## Device-Level Diagnostics
|
||||
|
||||
For integrations with multiple devices, you can also provide device-level diagnostics:
|
||||
|
||||
```python
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry, device: dr.DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Find device data based on device identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device.identifiers if domain == DOMAIN), None
|
||||
)
|
||||
|
||||
if device_id is None:
|
||||
return {"error": "Device not found"}
|
||||
|
||||
device_data = coordinator.data.devices.get(device_id)
|
||||
if device_data is None:
|
||||
return {"error": "Device data not found"}
|
||||
|
||||
return {
|
||||
"device_info": {
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"sw_version": device.sw_version,
|
||||
},
|
||||
"device_data": async_redact_data(device_data.to_dict(), TO_REDACT),
|
||||
}
|
||||
```
|
||||
|
||||
## Redaction Patterns
|
||||
|
||||
### Simple Redaction
|
||||
|
||||
```python
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
|
||||
data = {"api_key": "secret123", "temperature": 21.5}
|
||||
redacted = async_redact_data(data, {"api_key"})
|
||||
# Result: {"api_key": "**REDACTED**", "temperature": 21.5}
|
||||
```
|
||||
|
||||
### Nested Redaction
|
||||
|
||||
`async_redact_data` handles nested dictionaries automatically:
|
||||
|
||||
```python
|
||||
data = {
|
||||
"config": {
|
||||
"host": "192.168.1.1",
|
||||
"api_key": "secret123",
|
||||
},
|
||||
"device": {
|
||||
"name": "My Device",
|
||||
"serial_number": "ABC123",
|
||||
}
|
||||
}
|
||||
redacted = async_redact_data(data, {"api_key", "serial_number"})
|
||||
# Result: {"config": {"host": "192.168.1.1", "api_key": "**REDACTED**"},
|
||||
# "device": {"name": "My Device", "serial_number": "**REDACTED**"}}
|
||||
```
|
||||
|
||||
### Custom Redaction
|
||||
|
||||
For complex redaction needs:
|
||||
|
||||
```python
|
||||
def _redact_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Redact sensitive data."""
|
||||
result = dict(data)
|
||||
|
||||
# Redact specific keys
|
||||
for key in ("api_key", "token", "password"):
|
||||
if key in result:
|
||||
result[key] = "**REDACTED**"
|
||||
|
||||
# Redact partial data (e.g., keep last 4 chars)
|
||||
if "serial" in result:
|
||||
result["serial"] = f"****{result['serial'][-4:]}"
|
||||
|
||||
# Redact coordinates to city level
|
||||
if "latitude" in result:
|
||||
result["latitude"] = round(result["latitude"], 1)
|
||||
if "longitude" in result:
|
||||
result["longitude"] = round(result["longitude"], 1)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
## Testing Diagnostics
|
||||
|
||||
```python
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
|
||||
from custom_components.my_integration.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, init_integration)
|
||||
|
||||
assert diagnostics["entry"]["data"]["host"] == "192.168.1.1"
|
||||
assert diagnostics["entry"]["data"]["api_key"] == REDACTED
|
||||
assert "temperature" in diagnostics["coordinator"]["data"]
|
||||
```
|
||||
|
||||
## Quality Scale Requirement
|
||||
|
||||
Diagnostics are required for **Gold** quality scale and above. Ensure your `quality_scale.yaml` includes:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
diagnostics: done
|
||||
```
|
||||
@@ -1,286 +0,0 @@
|
||||
# Entity Development Reference
|
||||
|
||||
Base patterns for entity development in Home Assistant.
|
||||
|
||||
## Base Entity Class
|
||||
|
||||
Create a shared base class to reduce duplication:
|
||||
|
||||
```python
|
||||
"""Base entity for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MyCoordinator
|
||||
|
||||
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Base entity for My Integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
```
|
||||
|
||||
## Unique IDs
|
||||
|
||||
Every entity must have a unique ID:
|
||||
|
||||
```python
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator, sensor_type: str) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
# Unique per platform, don't include domain or platform name
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{sensor_type}"
|
||||
```
|
||||
|
||||
**Acceptable unique ID sources:**
|
||||
- Device serial numbers
|
||||
- MAC addresses (use `format_mac` from device registry)
|
||||
- Physical identifiers
|
||||
|
||||
**Never use:**
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
## Entity Naming
|
||||
|
||||
```python
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor with proper naming."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "temperature" # Translatable name
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
# For the main/primary entity of a device, use None
|
||||
# self._attr_name = None
|
||||
|
||||
# For secondary entities, set the name
|
||||
self._attr_name = "Temperature" # Or use translation_key
|
||||
```
|
||||
|
||||
## Entity Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"battery": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"charging": "Charging",
|
||||
"discharging": "Discharging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Availability
|
||||
|
||||
### Coordinator Pattern
|
||||
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._sensor_key in self.coordinator.data.sensors
|
||||
```
|
||||
|
||||
### Direct Update Pattern
|
||||
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity state."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
## Entity Categories
|
||||
|
||||
```python
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
|
||||
class DiagnosticSensor(MyEntity, SensorEntity):
|
||||
"""Diagnostic sensor (hidden by default in UI)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
|
||||
class ConfigSwitch(MyEntity, SwitchEntity):
|
||||
"""Configuration switch."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
```
|
||||
|
||||
## Disabled by Default
|
||||
|
||||
For noisy or less popular entities:
|
||||
|
||||
```python
|
||||
class SignalStrengthSensor(MyEntity, SensorEntity):
|
||||
"""Signal strength sensor - disabled by default."""
|
||||
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
|
||||
## Event Lifecycle
|
||||
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Entity with event subscriptions."""
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events when added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.client.events.subscribe(
|
||||
"state_changed",
|
||||
self._handle_state_change,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_state_change(self, event: Event) -> None:
|
||||
"""Handle state change event."""
|
||||
self._attr_native_value = event.value
|
||||
self.async_write_ha_state()
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- Subscribe in `async_added_to_hass`
|
||||
- Use `async_on_remove` for automatic cleanup
|
||||
- Never subscribe in `__init__`
|
||||
|
||||
## State Handling
|
||||
|
||||
```python
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state."""
|
||||
value = self.coordinator.data.get(self._key)
|
||||
# Use None for unknown values, never "unknown" or "unavailable" strings
|
||||
if value is None:
|
||||
return None
|
||||
return value
|
||||
```
|
||||
|
||||
## Extra State Attributes
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
data = self.coordinator.data
|
||||
# All keys must always be present, use None for unknown
|
||||
return {
|
||||
"last_updated": data.last_updated,
|
||||
"error_count": data.error_count,
|
||||
"firmware": data.firmware or None, # Never omit keys
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Descriptions Pattern
|
||||
|
||||
For multiple similar entities:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe My sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyData], StateType]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[MySensorEntityDescription, ...] = (
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor using entity description."""
|
||||
|
||||
entity_description: MySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Multiline Lambdas
|
||||
|
||||
When lambdas are too long:
|
||||
|
||||
```python
|
||||
# Good pattern - parentheses on same line as lambda
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
value_fn=lambda data: (
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
@@ -1,229 +0,0 @@
|
||||
# Number Platform Reference
|
||||
|
||||
Number entities represent numeric values that can be set.
|
||||
|
||||
## Basic Number
|
||||
|
||||
```python
|
||||
"""Number platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up numbers from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
TargetTemperatureNumber(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class TargetTemperatureNumber(MyEntity, NumberEntity):
|
||||
"""Target temperature number entity."""
|
||||
|
||||
_attr_native_min_value = 16
|
||||
_attr_native_max_value = 30
|
||||
_attr_native_step = 0.5
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_translation_key = "target_temperature"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the number entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_target_temp"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.coordinator.data.target_temperature
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the target temperature."""
|
||||
await self.coordinator.client.set_target_temperature(value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Number Modes
|
||||
|
||||
```python
|
||||
from homeassistant.components.number import NumberMode
|
||||
|
||||
# Slider display in UI
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
# Input box display in UI
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
# Auto (slider if range <= 256, else box)
|
||||
_attr_mode = NumberMode.AUTO
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
```python
|
||||
from homeassistant.components.number import NumberDeviceClass
|
||||
|
||||
# For temperature settings
|
||||
_attr_device_class = NumberDeviceClass.TEMPERATURE
|
||||
|
||||
# Other device classes
|
||||
NumberDeviceClass.HUMIDITY
|
||||
NumberDeviceClass.POWER
|
||||
NumberDeviceClass.VOLTAGE
|
||||
NumberDeviceClass.CURRENT
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntityDescription, NumberMode
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describe My number entity."""
|
||||
|
||||
value_fn: Callable[[MyData], float | None]
|
||||
set_value_fn: Callable[[MyClient, float], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
NUMBERS: tuple[MyNumberEntityDescription, ...] = (
|
||||
MyNumberEntityDescription(
|
||||
key="target_temperature",
|
||||
translation_key="target_temperature",
|
||||
native_min_value=16,
|
||||
native_max_value=30,
|
||||
native_step=0.5,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
mode=NumberMode.SLIDER,
|
||||
value_fn=lambda data: data.target_temperature,
|
||||
set_value_fn=lambda client, value: client.set_target_temperature(value),
|
||||
),
|
||||
MyNumberEntityDescription(
|
||||
key="brightness",
|
||||
translation_key="brightness",
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda data: data.brightness,
|
||||
set_value_fn=lambda client, value: client.set_brightness(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MyNumber(MyEntity, NumberEntity):
|
||||
"""Number using entity description."""
|
||||
|
||||
entity_description: MyNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
await self.entity_description.set_value_fn(self.coordinator.client, value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Dynamic Min/Max Values
|
||||
|
||||
```python
|
||||
class DynamicRangeNumber(MyEntity, NumberEntity):
|
||||
"""Number with dynamic range based on device capabilities."""
|
||||
|
||||
_attr_translation_key = "fan_speed"
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return minimum value."""
|
||||
return self.coordinator.data.fan_speed_min
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return maximum value."""
|
||||
return self.coordinator.data.fan_speed_max
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
"""Return step value."""
|
||||
return self.coordinator.data.fan_speed_step or 1
|
||||
```
|
||||
|
||||
## Configuration Number
|
||||
|
||||
```python
|
||||
class ConfigNumber(MyEntity, NumberEntity):
|
||||
"""Configuration number entity."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_max_value = 60
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "min"
|
||||
_attr_translation_key = "timeout"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the timeout setting."""
|
||||
return self.coordinator.data.timeout_minutes
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the timeout."""
|
||||
await self.coordinator.client.set_timeout(int(value))
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"number": {
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
},
|
||||
"timeout": {
|
||||
"name": "Timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,252 +0,0 @@
|
||||
# Select Platform Reference
|
||||
|
||||
Select entities allow choosing from a predefined list of options.
|
||||
|
||||
## Basic Select
|
||||
|
||||
```python
|
||||
"""Select platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up selects from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
ModeSelect(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class ModeSelect(MyEntity, SelectEntity):
|
||||
"""Mode select entity."""
|
||||
|
||||
_attr_options = ["auto", "cool", "heat", "fan_only", "dry"]
|
||||
_attr_translation_key = "mode"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self.coordinator.data.mode
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.coordinator.client.set_mode(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySelectEntityDescription(SelectEntityDescription):
|
||||
"""Describe My select entity."""
|
||||
|
||||
current_option_fn: Callable[[MyData], str | None]
|
||||
select_option_fn: Callable[[MyClient, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SELECTS: tuple[MySelectEntityDescription, ...] = (
|
||||
MySelectEntityDescription(
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
options=["auto", "cool", "heat", "fan_only", "dry"],
|
||||
current_option_fn=lambda data: data.mode,
|
||||
select_option_fn=lambda client, option: client.set_mode(option),
|
||||
),
|
||||
MySelectEntityDescription(
|
||||
key="fan_speed",
|
||||
translation_key="fan_speed",
|
||||
options=["low", "medium", "high", "auto"],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
current_option_fn=lambda data: data.fan_speed,
|
||||
select_option_fn=lambda client, option: client.set_fan_speed(option),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySelect(MyEntity, SelectEntity):
|
||||
"""Select using entity description."""
|
||||
|
||||
entity_description: MySelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available options."""
|
||||
return list(self.entity_description.options)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current option."""
|
||||
return self.entity_description.current_option_fn(self.coordinator.data)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select an option."""
|
||||
await self.entity_description.select_option_fn(self.coordinator.client, option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Dynamic Options
|
||||
|
||||
```python
|
||||
class DynamicSelect(MyEntity, SelectEntity):
|
||||
"""Select with options from device."""
|
||||
|
||||
_attr_translation_key = "preset"
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available presets from device."""
|
||||
return self.coordinator.data.available_presets
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current preset."""
|
||||
return self.coordinator.data.current_preset
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select a preset."""
|
||||
await self.coordinator.client.set_preset(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Configuration Select
|
||||
|
||||
```python
|
||||
class ConfigSelect(MyEntity, SelectEntity):
|
||||
"""Configuration select (settings)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_options = ["silent", "normal", "boost"]
|
||||
_attr_translation_key = "performance_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current performance mode."""
|
||||
return self.coordinator.data.performance_mode
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set performance mode."""
|
||||
await self.coordinator.client.set_performance_mode(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"heat": "Heat",
|
||||
"fan_only": "Fan only",
|
||||
"dry": "Dry"
|
||||
}
|
||||
},
|
||||
"fan_speed": {
|
||||
"name": "Fan speed",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"auto": "Auto"
|
||||
}
|
||||
},
|
||||
"performance_mode": {
|
||||
"name": "Performance mode",
|
||||
"state": {
|
||||
"silent": "Silent",
|
||||
"normal": "Normal",
|
||||
"boost": "Boost"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Icon by State
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"default": "mdi:thermostat",
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"heat": "Heat"
|
||||
},
|
||||
"state_icons": {
|
||||
"auto": "mdi:thermostat-auto",
|
||||
"cool": "mdi:snowflake",
|
||||
"heat": "mdi:fire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: State icons are defined in `icons.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"default": "mdi:thermostat",
|
||||
"state": {
|
||||
"auto": "mdi:thermostat-auto",
|
||||
"cool": "mdi:snowflake",
|
||||
"heat": "mdi:fire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,271 +0,0 @@
|
||||
# Sensor Platform Reference
|
||||
|
||||
Sensors represent read-only values from devices.
|
||||
|
||||
## Basic Sensor
|
||||
|
||||
```python
|
||||
"""Sensor platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
TemperatureSensor(coordinator),
|
||||
HumiditySensor(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class TemperatureSensor(MyEntity, SensorEntity):
|
||||
"""Temperature sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_translation_key = "temperature"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_temperature"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data.temperature
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
Common sensor device classes:
|
||||
|
||||
| Device Class | Unit Examples | Use Case |
|
||||
|--------------|---------------|----------|
|
||||
| `TEMPERATURE` | °C, °F | Temperature readings |
|
||||
| `HUMIDITY` | % | Humidity levels |
|
||||
| `PRESSURE` | hPa, mbar | Atmospheric pressure |
|
||||
| `BATTERY` | % | Battery level |
|
||||
| `POWER` | W, kW | Power consumption |
|
||||
| `ENERGY` | Wh, kWh | Energy usage |
|
||||
| `VOLTAGE` | V | Electrical voltage |
|
||||
| `CURRENT` | A, mA | Electrical current |
|
||||
| `CO2` | ppm | Carbon dioxide |
|
||||
| `PM25` | µg/m³ | Particulate matter |
|
||||
|
||||
## State Classes
|
||||
|
||||
```python
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
|
||||
# For instantaneous values that can go up or down
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
# For ever-increasing totals (like energy consumption)
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
# For totals that reset periodically
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
For multiple sensors with similar structure:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe My sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyData], Any]
|
||||
|
||||
|
||||
SENSORS: tuple[MySensorEntityDescription, ...] = (
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="humidity",
|
||||
translation_key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="signal_strength",
|
||||
translation_key="signal_strength",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False, # Disabled by default
|
||||
value_fn=lambda data: data.rssi,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MySensor(coordinator, description)
|
||||
for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor using entity description."""
|
||||
|
||||
entity_description: MySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Suggested Display Precision
|
||||
|
||||
```python
|
||||
# Control decimal places shown in UI
|
||||
_attr_suggested_display_precision = 1 # Show 21.5 instead of 21.456789
|
||||
```
|
||||
|
||||
## Timestamp Sensors
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
|
||||
|
||||
class LastUpdatedSensor(MyEntity, SensorEntity):
|
||||
"""Last updated timestamp sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_translation_key = "last_updated"
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""Return the last update timestamp."""
|
||||
return self.coordinator.data.last_updated
|
||||
```
|
||||
|
||||
## Enum Sensors
|
||||
|
||||
```python
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
|
||||
|
||||
class StatusSensor(MyEntity, SensorEntity):
|
||||
"""Status sensor with enum values."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = ["idle", "running", "error", "offline"]
|
||||
_attr_translation_key = "status"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current status."""
|
||||
return self.coordinator.data.status
|
||||
```
|
||||
|
||||
With translations in `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"error": "Error",
|
||||
"offline": "Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Icons
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"name": "Battery level",
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"10": "mdi:battery-10",
|
||||
"50": "mdi:battery-50",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PARALLEL_UPDATES
|
||||
|
||||
```python
|
||||
# At module level - limit concurrent updates
|
||||
PARALLEL_UPDATES = 1 # Serialize to prevent overwhelming device
|
||||
|
||||
# Or unlimited for coordinator-based platforms
|
||||
PARALLEL_UPDATES = 0
|
||||
```
|
||||
@@ -1,335 +0,0 @@
|
||||
# Services Reference
|
||||
|
||||
Services allow automations and users to trigger actions.
|
||||
|
||||
## Service Registration
|
||||
|
||||
Register services in `async_setup`, NOT in `async_setup_entry`:
|
||||
|
||||
```python
|
||||
"""My Integration setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN
|
||||
|
||||
SERVICE_REFRESH = "refresh"
|
||||
SERVICE_SET_SCHEDULE = "set_schedule"
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up My Integration services."""
|
||||
|
||||
async def handle_refresh(call: ServiceCall) -> None:
|
||||
"""Handle refresh service call."""
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH,
|
||||
handle_refresh,
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
}),
|
||||
)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Service with Response
|
||||
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up services with response."""
|
||||
|
||||
async def handle_get_schedule(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle get_schedule service call."""
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
schedule = await coordinator.client.get_schedule()
|
||||
|
||||
return {
|
||||
"schedule": [
|
||||
{"day": item.day, "start": item.start, "end": item.end}
|
||||
for item in schedule
|
||||
]
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_schedule",
|
||||
handle_get_schedule,
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
}),
|
||||
supports_response=SupportsResponse.ONLY, # or SupportsResponse.OPTIONAL
|
||||
)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Entity Services
|
||||
|
||||
Register entity-specific services in platform setup:
|
||||
|
||||
```python
|
||||
"""Switch platform with entity service."""
|
||||
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([PowerSwitch(coordinator)])
|
||||
|
||||
# Register entity service
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
"set_timer",
|
||||
{
|
||||
vol.Required("minutes"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=120)
|
||||
),
|
||||
},
|
||||
"async_set_timer",
|
||||
)
|
||||
|
||||
|
||||
class PowerSwitch(MyEntity, SwitchEntity):
|
||||
"""Power switch with timer service."""
|
||||
|
||||
async def async_set_timer(self, minutes: int) -> None:
|
||||
"""Set auto-off timer."""
|
||||
await self.coordinator.client.set_timer(minutes)
|
||||
```
|
||||
|
||||
## Service Validation
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
|
||||
async def handle_set_schedule(call: ServiceCall) -> None:
|
||||
"""Handle set_schedule service call."""
|
||||
start_date = call.data["start_date"]
|
||||
end_date = call.data["end_date"]
|
||||
|
||||
# Validate input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
|
||||
try:
|
||||
await entry.runtime_data.client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
```
|
||||
|
||||
## services.yaml
|
||||
|
||||
Define services in `services.yaml`:
|
||||
|
||||
```yaml
|
||||
refresh:
|
||||
name: Refresh
|
||||
description: Force a data refresh from the device.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to refresh.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
|
||||
set_schedule:
|
||||
name: Set schedule
|
||||
description: Set the device schedule.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to configure.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Schedule start date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Schedule end date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
|
||||
get_schedule:
|
||||
name: Get schedule
|
||||
description: Get the current device schedule.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to query.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
|
||||
set_timer:
|
||||
name: Set timer
|
||||
description: Set auto-off timer for the switch.
|
||||
target:
|
||||
entity:
|
||||
integration: my_integration
|
||||
domain: switch
|
||||
fields:
|
||||
minutes:
|
||||
name: Minutes
|
||||
description: Timer duration in minutes.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 120
|
||||
unit_of_measurement: min
|
||||
```
|
||||
|
||||
## Exception Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"entry_not_found": {
|
||||
"message": "Config entry not found."
|
||||
},
|
||||
"entry_not_loaded": {
|
||||
"message": "Config entry is not loaded."
|
||||
},
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to connect to the device."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Device-Based Service Targeting
|
||||
|
||||
```python
|
||||
async def handle_device_service(call: ServiceCall) -> None:
|
||||
"""Handle service call targeting a device."""
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
|
||||
# Find config entry for device
|
||||
entry_id = next(
|
||||
(entry_id for entry_id in device.config_entries if entry_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if entry_id is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
# ... continue with service logic
|
||||
```
|
||||
|
||||
## Service Schema Patterns
|
||||
|
||||
```python
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
# Basic schema
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required("value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
|
||||
# With entity targeting
|
||||
SERVICE_SCHEMA_ENTITY = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
})
|
||||
|
||||
# With selectors (for services.yaml)
|
||||
# Use selector in services.yaml, not in Python schema
|
||||
```
|
||||
|
||||
## Quality Scale Requirements
|
||||
|
||||
- **Bronze**: `action-setup` - Register services in `async_setup` if integration has services
|
||||
- Services must validate config entry state before use
|
||||
- Use translated exceptions for error messages
|
||||
@@ -1,165 +0,0 @@
|
||||
---
|
||||
name: ha-integration
|
||||
description: Develop Home Assistant integrations following best practices. Use when creating, modifying, or reviewing integration code including config flows, entities, coordinators, diagnostics, services, and tests.
|
||||
---
|
||||
|
||||
# Home Assistant Integration Development
|
||||
|
||||
You are developing a Home Assistant integration. Follow these guidelines and reference the supporting documentation for specific components.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Reference File |
|
||||
|-----------|----------------|
|
||||
| Config flow | [CONFIG-FLOW.MD](CONFIG-FLOW.MD) |
|
||||
| Data coordinator | [COORDINATOR.MD](COORDINATOR.MD) |
|
||||
| Entities (base) | [ENTITY.MD](ENTITY.MD) |
|
||||
| Sensors | [SENSOR.MD](SENSOR.MD) |
|
||||
| Binary sensors | [BINARY-SENSOR.MD](BINARY-SENSOR.MD) |
|
||||
| Switches | [SWITCH.MD](SWITCH.MD) |
|
||||
| Numbers | [NUMBER.MD](NUMBER.MD) |
|
||||
| Selects | [SELECT.MD](SELECT.MD) |
|
||||
| Buttons | [BUTTON.MD](BUTTON.MD) |
|
||||
| Device management | [DEVICE.MD](DEVICE.MD) |
|
||||
| Diagnostics | [DIAGNOSTICS.MD](DIAGNOSTICS.MD) |
|
||||
| Services | [SERVICES.MD](SERVICES.MD) |
|
||||
| Testing | [TESTING.MD](TESTING.MD) |
|
||||
|
||||
## Integration Structure
|
||||
|
||||
```
|
||||
homeassistant/components/my_integration/
|
||||
├── __init__.py # Entry point with async_setup_entry
|
||||
├── manifest.json # Integration metadata and dependencies
|
||||
├── const.py # Domain and constants
|
||||
├── config_flow.py # UI configuration flow
|
||||
├── coordinator.py # Data update coordinator
|
||||
├── entity.py # Base entity class
|
||||
├── sensor.py # Sensor platform
|
||||
├── diagnostics.py # Diagnostic data collection
|
||||
├── strings.json # User-facing text and translations
|
||||
├── services.yaml # Service definitions (if applicable)
|
||||
└── quality_scale.yaml # Quality scale rule status
|
||||
```
|
||||
|
||||
## Quality Scale Levels
|
||||
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality (entity unavailability, parallel updates, auth flows)
|
||||
- **Gold**: Advanced features (device management, diagnostics, translations)
|
||||
- **Platinum**: Highest quality (strict typing, async dependencies, websession injection)
|
||||
|
||||
Check `manifest.json` for `"quality_scale"` key and `quality_scale.yaml` for rule status.
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Entry Point (`__init__.py`)
|
||||
|
||||
```python
|
||||
"""Integration for My Device."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MyCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Set up My Integration from a config entry."""
|
||||
coordinator = MyCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
```
|
||||
|
||||
### Constants (`const.py`)
|
||||
|
||||
```python
|
||||
"""Constants for My Integration."""
|
||||
|
||||
DOMAIN = "my_integration"
|
||||
```
|
||||
|
||||
### Manifest (`manifest.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@username"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/my_integration",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["my-library==1.0.0"],
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
```
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
- **Type hints**: Required for all functions and methods
|
||||
- **f-strings**: Preferred over `%` or `.format()`
|
||||
- **Async**: All external I/O must be async
|
||||
|
||||
## Code Quality
|
||||
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Testing**: pytest with >95% coverage
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
```python
|
||||
# Blocking operations
|
||||
data = requests.get(url) # Use async or executor
|
||||
time.sleep(5) # Use asyncio.sleep()
|
||||
|
||||
# Hardcoded strings
|
||||
self._attr_name = "Temperature" # Use translation_key
|
||||
|
||||
# Too much in try block
|
||||
try:
|
||||
data = await client.get_data()
|
||||
processed = data["value"] * 100 # Move outside try
|
||||
except Error:
|
||||
pass
|
||||
|
||||
# User-configurable polling
|
||||
vol.Optional("scan_interval"): cv.positive_int # Not allowed
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
pytest ./tests/components/<domain> \
|
||||
--cov=homeassistant.components.<domain> \
|
||||
--cov-report term-missing \
|
||||
--numprocesses=auto
|
||||
|
||||
# Type checking
|
||||
mypy homeassistant/components/<domain>
|
||||
|
||||
# Linting
|
||||
pylint homeassistant/components/<domain>
|
||||
|
||||
# Validate integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/<domain>
|
||||
```
|
||||
@@ -1,236 +0,0 @@
|
||||
# Switch Platform Reference
|
||||
|
||||
Switches control on/off functionality.
|
||||
|
||||
## Basic Switch
|
||||
|
||||
```python
|
||||
"""Switch platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
PowerSwitch(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class PowerSwitch(MyEntity, SwitchEntity):
|
||||
"""Power switch."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
_attr_translation_key = "power"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_power"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on."""
|
||||
return self.coordinator.data.is_on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.coordinator.client.turn_on()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.coordinator.client.turn_off()
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
| Device Class | Use Case |
|
||||
|--------------|----------|
|
||||
| `OUTLET` | Electrical outlet |
|
||||
| `SWITCH` | Generic switch |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describe My switch entity."""
|
||||
|
||||
is_on_fn: Callable[[MyData], bool | None]
|
||||
turn_on_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SWITCHES: tuple[MySwitchEntityDescription, ...] = (
|
||||
MySwitchEntityDescription(
|
||||
key="power",
|
||||
translation_key="power",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
is_on_fn=lambda data: data.is_on,
|
||||
turn_on_fn=lambda client: client.turn_on(),
|
||||
turn_off_fn=lambda client: client.turn_off(),
|
||||
),
|
||||
MySwitchEntityDescription(
|
||||
key="child_lock",
|
||||
translation_key="child_lock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda data: data.child_lock_enabled,
|
||||
turn_on_fn=lambda client: client.set_child_lock(True),
|
||||
turn_off_fn=lambda client: client.set_child_lock(False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySwitch(MyEntity, SwitchEntity):
|
||||
"""Switch using entity description."""
|
||||
|
||||
entity_description: MySwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.entity_description.turn_on_fn(self.coordinator.client)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.entity_description.turn_off_fn(self.coordinator.client)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Configuration Switch
|
||||
|
||||
```python
|
||||
class ConfigSwitch(MyEntity, SwitchEntity):
|
||||
"""Configuration switch (e.g., enable/disable a feature)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "auto_mode"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if auto mode is enabled."""
|
||||
return self.coordinator.data.auto_mode_enabled
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable auto mode."""
|
||||
await self.coordinator.client.set_auto_mode(True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable auto mode."""
|
||||
await self.coordinator.client.set_auto_mode(False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For devices with slow response:
|
||||
|
||||
```python
|
||||
class OptimisticSwitch(MyEntity, SwitchEntity):
|
||||
"""Switch with optimistic state updates."""
|
||||
|
||||
_attr_assumed_state = True # Indicates state may not be accurate
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._optimistic_state: bool | None = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return optimistic state if set, otherwise coordinator state."""
|
||||
if self._optimistic_state is not None:
|
||||
return self._optimistic_state
|
||||
return self.coordinator.data.is_on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on with optimistic update."""
|
||||
self._optimistic_state = True
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.coordinator.client.turn_on()
|
||||
finally:
|
||||
self._optimistic_state = None
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class RobustSwitch(MyEntity, SwitchEntity):
|
||||
"""Switch with proper error handling."""
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.coordinator.client.turn_on()
|
||||
except MyDeviceError as err:
|
||||
raise HomeAssistantError(f"Failed to turn on: {err}") from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"power": {
|
||||
"name": "Power"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
},
|
||||
"auto_mode": {
|
||||
"name": "Auto mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,396 +0,0 @@
|
||||
# Testing Reference
|
||||
|
||||
Testing patterns for Home Assistant integrations.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/components/my_integration/
|
||||
├── __init__.py
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_config_flow.py # Config flow tests (100% coverage required)
|
||||
├── test_init.py # Integration setup tests
|
||||
├── test_sensor.py # Sensor platform tests
|
||||
├── test_diagnostics.py # Diagnostics tests
|
||||
├── snapshots/ # Snapshot files
|
||||
│ └── test_sensor.ambr
|
||||
└── fixtures/ # Test data fixtures
|
||||
└── device_data.json
|
||||
```
|
||||
|
||||
## conftest.py
|
||||
|
||||
```python
|
||||
"""Fixtures for My Integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.my_integration.const import DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="My Device",
|
||||
data={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_api_key",
|
||||
},
|
||||
unique_id="device_serial_123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Generator[MagicMock]:
|
||||
"""Return a mocked client."""
|
||||
with patch(
|
||||
"homeassistant.components.my_integration.MyClient",
|
||||
autospec=True,
|
||||
) as client_mock:
|
||||
client = client_mock.return_value
|
||||
client.get_data = AsyncMock(
|
||||
return_value=MyData.from_json(load_fixture("device_data.json", DOMAIN))
|
||||
)
|
||||
client.serial_number = "device_serial_123"
|
||||
client.name = "My Device"
|
||||
client.model = "Model X"
|
||||
client.firmware_version = "1.2.3"
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Return platforms to test."""
|
||||
return [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.my_integration.PLATFORMS",
|
||||
platforms,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Config Flow Tests
|
||||
|
||||
**100% coverage required for all paths:**
|
||||
|
||||
```python
|
||||
"""Test config flow for My Integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.my_integration.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
}
|
||||
assert result["result"].unique_id == "device_serial_123"
|
||||
|
||||
|
||||
async def test_user_flow_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test connection error in user flow."""
|
||||
mock_client.get_data.side_effect = ConnectionError("Cannot connect")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test already configured error."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test reauthentication flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_API_KEY: "new_api_key"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
|
||||
```
|
||||
|
||||
## Entity Tests with Snapshots
|
||||
|
||||
```python
|
||||
"""Test sensor platform."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Override platforms for sensor tests."""
|
||||
return [Platform.SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_sensor_device_assignment(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensors are assigned to correct device."""
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={("my_integration", "device_serial_123")}
|
||||
)
|
||||
assert device is not None
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity in entities:
|
||||
assert entity.device_id == device.id
|
||||
```
|
||||
|
||||
## Coordinator Tests
|
||||
|
||||
```python
|
||||
"""Test coordinator."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
|
||||
async def test_coordinator_update_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful coordinator update."""
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert coordinator.data.temperature == 21.5
|
||||
assert coordinator.last_update_success
|
||||
|
||||
|
||||
async def test_coordinator_update_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles API error."""
|
||||
mock_client.get_data.side_effect = MyError("Connection failed")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(UpdateFailed):
|
||||
await coordinator._async_update_data()
|
||||
|
||||
|
||||
async def test_coordinator_auth_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles auth error."""
|
||||
mock_client.get_data.side_effect = AuthError("Invalid token")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(ConfigEntryAuthFailed):
|
||||
await coordinator._async_update_data()
|
||||
```
|
||||
|
||||
## Diagnostics Tests
|
||||
|
||||
```python
|
||||
"""Test diagnostics."""
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.my_integration import snapshot_platform
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
init_integration: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, init_integration
|
||||
) == snapshot
|
||||
```
|
||||
|
||||
## Common Fixtures
|
||||
|
||||
```python
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
# Load JSON fixture
|
||||
data = load_fixture("device_data.json", DOMAIN)
|
||||
|
||||
# Enable all entities (including disabled by default)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
|
||||
# Freeze time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
|
||||
async def test_with_frozen_time(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
await hass.async_block_till_done()
|
||||
```
|
||||
|
||||
## Update Snapshots
|
||||
|
||||
```bash
|
||||
# Update snapshots
|
||||
pytest tests/components/my_integration --snapshot-update
|
||||
|
||||
# Always re-run without flag to verify
|
||||
pytest tests/components/my_integration
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
pytest tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing \
|
||||
--numprocesses=auto
|
||||
|
||||
# Run specific test
|
||||
pytest tests/components/my_integration/test_config_flow.py::test_user_flow_success
|
||||
|
||||
# Quick test of changed files
|
||||
pytest --timeout=10 --picked
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never access `hass.data` directly** - Use fixtures and proper setup
|
||||
2. **Mock all external APIs** - Use fixtures with realistic JSON data
|
||||
3. **Use snapshot testing** - For entity states and attributes
|
||||
4. **Test error paths** - Connection errors, auth failures, invalid data
|
||||
5. **Test edge cases** - Empty data, missing fields, None values
|
||||
6. **>95% coverage required** - All code paths must be tested
|
||||
784
.claude/skills/integrations/SKILL.md
Normal file
784
.claude/skills/integrations/SKILL.md
Normal file
@@ -0,0 +1,784 @@
|
||||
---
|
||||
name: Home Assistant Integration knowledge
|
||||
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
|
||||
---
|
||||
|
||||
### File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## Integration Templates
|
||||
|
||||
### Standard Integration Structure
|
||||
```
|
||||
homeassistant/components/my_integration/
|
||||
├── __init__.py # Entry point with async_setup_entry
|
||||
├── manifest.json # Integration metadata and dependencies
|
||||
├── const.py # Domain and constants
|
||||
├── config_flow.py # UI configuration flow
|
||||
├── coordinator.py # Data update coordinator (if needed)
|
||||
├── entity.py # Base entity class (if shared patterns)
|
||||
├── sensor.py # Sensor platform
|
||||
├── strings.json # User-facing text and translations
|
||||
├── services.yaml # Service definitions (if applicable)
|
||||
└── quality_scale.yaml # Quality scale rule status
|
||||
```
|
||||
|
||||
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
### Minimal Integration Checklist
|
||||
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
|
||||
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
|
||||
- [ ] `config_flow.py` with UI configuration support
|
||||
- [ ] `const.py` with `DOMAIN` constant
|
||||
- [ ] `strings.json` with at least config flow text
|
||||
- [ ] Platform files (`sensor.py`, etc.) as needed
|
||||
- [ ] `quality_scale.yaml` with rule status tracking
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
|
||||
|
||||
### Quality Scale Levels
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality
|
||||
- **Gold**: Advanced features
|
||||
- **Platinum**: Highest quality standards
|
||||
|
||||
### Quality Scale Progression
|
||||
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
|
||||
- **Silver → Gold**: Add device management, diagnostics, translations
|
||||
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
2. **Bronze Rules**: Always required for any integration with quality scale
|
||||
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
|
||||
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
|
||||
- `done`: Rule implemented
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
### Example `quality_scale.yaml` Structure
|
||||
```yaml
|
||||
rules:
|
||||
# Bronze (mandatory)
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
|
||||
# Silver (if targeting Silver+)
|
||||
entity-unavailable: done
|
||||
parallel-updates: done
|
||||
|
||||
# Gold (if targeting Gold+)
|
||||
devices: done
|
||||
diagnostics: done
|
||||
|
||||
# Platinum (if targeting Platinum)
|
||||
strict-typing: done
|
||||
```
|
||||
|
||||
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Core Locations
|
||||
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
|
||||
- Integration structure:
|
||||
- `homeassistant/components/{domain}/const.py` - Constants
|
||||
- `homeassistant/components/{domain}/models.py` - Data models
|
||||
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
|
||||
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
|
||||
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
|
||||
|
||||
### Common Modules
|
||||
- **coordinator.py**: Centralize data fetching logic
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
- **entity.py**: Base entity definitions to reduce duplication
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
_attr_has_entity_name = True
|
||||
```
|
||||
|
||||
### Runtime Data Storage
|
||||
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
client = MyClient(entry.data[CONF_HOST])
|
||||
entry.runtime_data = client
|
||||
```
|
||||
|
||||
### Manifest Requirements
|
||||
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
|
||||
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
|
||||
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
|
||||
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
|
||||
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
|
||||
|
||||
### Config Flow Patterns
|
||||
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
|
||||
- **Unique ID Management**:
|
||||
```python
|
||||
await self.async_set_unique_id(device_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
```
|
||||
- **Error Handling**: Define errors in `strings.json` under `config.error`
|
||||
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
|
||||
|
||||
### Integration Ownership
|
||||
- **manifest.json**: Add GitHub usernames to `codeowners`:
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@me"]
|
||||
}
|
||||
```
|
||||
|
||||
### Async Dependencies (Platinum)
|
||||
- **Requirement**: All dependencies must use asyncio
|
||||
- Ensures efficient task handling without thread context switching
|
||||
|
||||
### WebSession Injection (Platinum)
|
||||
- **Pass WebSession**: Support passing web sessions to dependencies
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Set up integration from config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
```
|
||||
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
|
||||
|
||||
### Data Update Coordinator
|
||||
- **Standard Pattern**: Use for efficient data management
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self):
|
||||
try:
|
||||
return await self.client.fetch_data()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(f"API communication error: {err}")
|
||||
```
|
||||
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
|
||||
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
### Configuration Flow
|
||||
- **UI Setup Required**: All integrations must support configuration via UI
|
||||
- **Manifest**: Set `"config_flow": true` in `manifest.json`
|
||||
- **Data Storage**:
|
||||
- Connection-critical config: Store in `ConfigEntry.data`
|
||||
- Non-critical settings: Store in `ConfigEntry.options`
|
||||
- **Validation**: Always validate user input before creating entries
|
||||
- **Config Entry Naming**:
|
||||
- ❌ Do NOT allow users to set config entry names in config flows
|
||||
- Names are automatically generated or can be customized later in UI
|
||||
- ✅ Exception: Helper integrations MAY allow custom names in config flow
|
||||
- **Connection Testing**: Test device/service connection during config flow:
|
||||
```python
|
||||
try:
|
||||
await client.get_data()
|
||||
except MyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
```
|
||||
- **Duplicate Prevention**: Prevent duplicate configurations:
|
||||
```python
|
||||
# Using unique ID
|
||||
await self.async_set_unique_id(identifier)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Using unique data
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
### Reauthentication Support
|
||||
- **Required Method**: Implement `async_step_reauth` in config flow
|
||||
- **Credential Updates**: Allow users to update credentials without re-adding
|
||||
- **Validation**: Verify account matches existing unique ID:
|
||||
```python
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
|
||||
)
|
||||
```
|
||||
|
||||
### Reconfiguration Flow
|
||||
- **Purpose**: Allow configuration updates without removing device
|
||||
- **Implementation**: Add `async_step_reconfigure` method
|
||||
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
|
||||
|
||||
### Device Discovery
|
||||
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
|
||||
```json
|
||||
{
|
||||
"zeroconf": ["_mydevice._tcp.local."]
|
||||
}
|
||||
```
|
||||
- **Discovery Handler**: Implement appropriate `async_step_*` method:
|
||||
```python
|
||||
async def async_step_zeroconf(self, discovery_info):
|
||||
"""Handle zeroconf discovery."""
|
||||
await self.async_set_unique_id(discovery_info.properties["serialno"])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
```
|
||||
- **Network Updates**: Use discovery to update dynamic IP addresses
|
||||
|
||||
### Network Discovery Implementation
|
||||
- **Zeroconf/mDNS**: Use async instances
|
||||
```python
|
||||
aiozc = await zeroconf.async_get_async_instance(hass)
|
||||
```
|
||||
- **SSDP Discovery**: Register callbacks with cleanup
|
||||
```python
|
||||
entry.async_on_unload(
|
||||
ssdp.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Bluetooth Integration
|
||||
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
|
||||
- **Connectable**: Set `"connectable": true` for connection-required devices
|
||||
- **Scanner Usage**: Always use shared scanner instance
|
||||
```python
|
||||
scanner = bluetooth.async_get_scanner()
|
||||
entry.async_on_unload(
|
||||
bluetooth.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"service_uuid": "example_uuid"},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE
|
||||
)
|
||||
)
|
||||
```
|
||||
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
|
||||
|
||||
### Setup Validation
|
||||
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
|
||||
- **Exception Handling**:
|
||||
- `ConfigEntryNotReady`: Device offline or temporary failure
|
||||
- `ConfigEntryAuthFailed`: Authentication issues
|
||||
- `ConfigEntryError`: Unresolvable setup problems
|
||||
|
||||
### Config Entry Unloading
|
||||
- **Required**: Implement `async_unload_entry` for runtime removal/reload
|
||||
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
|
||||
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
|
||||
```python
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
entry.runtime_data.listener() # Clean up resources
|
||||
return unload_ok
|
||||
```
|
||||
|
||||
### Service Actions
|
||||
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
|
||||
- **Validation**: Check config entry existence and loaded state:
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def service_action(call: ServiceCall) -> ServiceResponse:
|
||||
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
```
|
||||
- **Exception Handling**: Raise appropriate exceptions:
|
||||
```python
|
||||
# For invalid input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError("End date must be after start date")
|
||||
|
||||
# For service errors
|
||||
try:
|
||||
await client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError("Could not connect to the schedule") from err
|
||||
```
|
||||
|
||||
### Service Registration Patterns
|
||||
- **Entity Services**: Register on platform setup
|
||||
```python
|
||||
platform.async_register_entity_service(
|
||||
"my_entity_service",
|
||||
{vol.Required("parameter"): cv.string},
|
||||
"handle_service_method"
|
||||
)
|
||||
```
|
||||
- **Service Schema**: Always validate input
|
||||
```python
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
```
|
||||
- **Services File**: Create `services.yaml` with descriptions and field definitions
|
||||
|
||||
### Polling
|
||||
- Use update coordinator pattern when possible
|
||||
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
|
||||
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
|
||||
- **Minimum Intervals**:
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
- **Parallel Updates**: Specify number of concurrent updates:
|
||||
```python
|
||||
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
|
||||
# OR
|
||||
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
|
||||
```
|
||||
|
||||
## Entity Development
|
||||
|
||||
### Unique IDs
|
||||
- **Required**: Every entity must have a unique ID for registry tracking
|
||||
- Must be unique per platform (not per integration)
|
||||
- Don't include integration domain or platform in ID
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
def __init__(self, device_id: str) -> None:
|
||||
self._attr_unique_id = f"{device_id}_temperature"
|
||||
```
|
||||
|
||||
**Acceptable ID Sources**:
|
||||
- Device serial numbers
|
||||
- MAC addresses (formatted using `format_mac` from device registry)
|
||||
- Physical identifiers (printed/EEPROM)
|
||||
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
|
||||
|
||||
**Never Use**:
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
### Entity Descriptions
|
||||
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
|
||||
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
|
||||
- **Bad pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
|
||||
)
|
||||
```
|
||||
- **Good pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Naming
|
||||
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
|
||||
- **For specific fields**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
def __init__(self, device: Device, field: str) -> None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
)
|
||||
self._attr_name = field # e.g., "temperature", "humidity"
|
||||
```
|
||||
- **For device itself**: Set `_attr_name = None`
|
||||
|
||||
### Event Lifecycle Management
|
||||
- **Subscribe in `async_added_to_hass`**:
|
||||
```python
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events."""
|
||||
self.async_on_remove(
|
||||
self.client.events.subscribe("my_event", self._handle_event)
|
||||
)
|
||||
```
|
||||
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
|
||||
- Never subscribe in `__init__` or other methods
|
||||
|
||||
### State Handling
|
||||
- Unknown values: Use `None` (not "unknown" or "unavailable")
|
||||
- Availability: Implement `available()` property instead of using "unavailable" state
|
||||
|
||||
### Entity Availability
|
||||
- **Mark Unavailable**: When data cannot be fetched from device/service
|
||||
- **Coordinator Pattern**:
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.identifier in self.coordinator.data
|
||||
```
|
||||
- **Direct Update Pattern**:
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
### Extra State Attributes
|
||||
- All attribute keys must always be present
|
||||
- Unknown values: Use `None`
|
||||
- Provide descriptive attributes
|
||||
|
||||
## Device Management
|
||||
|
||||
### Device Registry
|
||||
- **Create Devices**: Group related entities under devices
|
||||
- **Device Info**: Provide comprehensive metadata:
|
||||
```python
|
||||
_attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac)},
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model="My Sensor",
|
||||
sw_version=device.version,
|
||||
)
|
||||
```
|
||||
- For services: Add `entry_type=DeviceEntryType.SERVICE`
|
||||
|
||||
### Dynamic Device Addition
|
||||
- **Auto-detect New Devices**: After initial setup
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
```
|
||||
|
||||
### Stale Device Removal
|
||||
- **Auto-remove**: When devices disappear from hub/account
|
||||
- **Device Registry Update**:
|
||||
```python
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
```
|
||||
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
|
||||
|
||||
### Entity Categories
|
||||
- **Required**: Assign appropriate category to entities
|
||||
- **Implementation**: Set `_attr_entity_category`
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
```
|
||||
- Categories include: `DIAGNOSTIC` for system/technical information
|
||||
|
||||
### Device Classes
|
||||
- **Use When Available**: Set appropriate device class for entity type
|
||||
```python
|
||||
class MyTemperatureSensor(SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
```
|
||||
- Provides context for: unit conversion, voice control, UI representation
|
||||
|
||||
### Disabled by Default
|
||||
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
|
||||
```python
|
||||
class MySignalStrengthSensor(SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
- Target: frequently changing states, technical diagnostics
|
||||
|
||||
### Entity Translations
|
||||
- **Required with has_entity_name**: Support international users
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "phase_voltage"
|
||||
```
|
||||
- Create `strings.json` with translations:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"phase_voltage": {
|
||||
"name": "Phase voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Translations (Gold)
|
||||
- **Translatable Errors**: Use translation keys for user-facing exceptions
|
||||
- **Implementation**:
|
||||
```python
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
```
|
||||
- Add to `strings.json`:
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Translations (Gold)
|
||||
- **Dynamic Icons**: Support state and range-based icon selection
|
||||
- **State-based Icons**:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"tree_pollen": {
|
||||
"default": "mdi:tree",
|
||||
"state": {
|
||||
"high": "mdi:tree-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Range-based Icons** (for numeric values):
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- **Location**: `tests/components/{domain}/`
|
||||
- **Coverage Requirement**: Above 95% test coverage for all modules
|
||||
- **Best Practices**:
|
||||
- Use pytest fixtures from `tests.common`
|
||||
- Mock all external dependencies
|
||||
- Use snapshots for complex data structures
|
||||
- Follow existing test patterns
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
```bash
|
||||
pytest ./tests/components/<integration_domain> \
|
||||
--cov=homeassistant.components.<integration_domain> \
|
||||
--cov-report term-missing \
|
||||
--durations-min=1 \
|
||||
--durations=0 \
|
||||
--numprocesses=auto
|
||||
```
|
||||
|
||||
### Testing Best Practices
|
||||
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
|
||||
- **Use snapshot testing** - For verifying entity states and attributes
|
||||
- **Test through integration setup** - Don't test entities in isolation
|
||||
- **Mock external APIs** - Use fixtures with realistic JSON data
|
||||
- **Verify registries** - Ensure entities are properly registered with devices
|
||||
|
||||
### Config Flow Testing Template
|
||||
```python
|
||||
async def test_user_flow_success(hass, mock_api):
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Test form submission
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
async def test_flow_connection_error(hass, mock_api_error):
|
||||
"""Test connection error handling."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
```
|
||||
|
||||
### Entity Testing Patterns
|
||||
```python
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Overridden fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR] # Or another specific platform as needed.
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
# Ensure entities are correctly assigned to device
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "device_unique_id")}
|
||||
)
|
||||
assert device_entry
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_entry.device_id == device_entry.id
|
||||
```
|
||||
|
||||
### Mock Patterns
|
||||
```python
|
||||
# Modern integration fixture setup
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="My Integration",
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
|
||||
unique_id="device_unique_id",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_api() -> Generator[MagicMock]:
|
||||
"""Return a mocked device API."""
|
||||
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
|
||||
api = api_mock.return_value
|
||||
api.get_data.return_value = MyDeviceData.from_json(
|
||||
load_fixture("device_data.json", DOMAIN)
|
||||
)
|
||||
yield api
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return PLATFORMS
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device_api: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Debugging & Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
- **Integration won't load**: Check `manifest.json` syntax and required fields
|
||||
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
|
||||
- **Config flow errors**: Check `strings.json` entries and error handling
|
||||
- **Discovery not working**: Verify manifest discovery configuration and callbacks
|
||||
- **Tests failing**: Check mock setup and async context
|
||||
|
||||
### Debug Logging Setup
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
caplog.set_level(logging.DEBUG, logger="my_integration")
|
||||
|
||||
# In integration code - use proper logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
|
||||
```
|
||||
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Check specific integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/my_integration
|
||||
|
||||
# Validate quality scale
|
||||
# Check quality_scale.yaml against current rules
|
||||
|
||||
# Run integration tests with coverage
|
||||
pytest ./tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing
|
||||
```
|
||||
19
.claude/skills/integrations/platform-diagnostics.md
Normal file
19
.claude/skills/integrations/platform-diagnostics.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Integration Diagnostics
|
||||
|
||||
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
|
||||
|
||||
- **Required**: Implement diagnostic data collection
|
||||
- **Implementation**:
|
||||
```python
|
||||
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": entry.runtime_data.data,
|
||||
}
|
||||
```
|
||||
- **Security**: Never expose passwords, tokens, or sensitive coordinates
|
||||
55
.claude/skills/integrations/platform-repairs.md
Normal file
55
.claude/skills/integrations/platform-repairs.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Repairs platform
|
||||
|
||||
Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
|
||||
- **Actionable Issues Required**: All repair issues must be actionable for end users
|
||||
- **Issue Content Requirements**:
|
||||
- Clearly explain what is happening
|
||||
- Provide specific steps users need to take to resolve the issue
|
||||
- Use friendly, helpful language
|
||||
- Include relevant context (device names, error details, etc.)
|
||||
- **Implementation**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"outdated_version",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="outdated_version",
|
||||
)
|
||||
```
|
||||
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"outdated_version": {
|
||||
"title": "Device firmware is outdated",
|
||||
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **String Content Must Include**:
|
||||
- What the problem is
|
||||
- Why it matters
|
||||
- Exact steps to resolve (numbered list when multiple steps)
|
||||
- What to expect after following the steps
|
||||
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
|
||||
- **Severity Guidelines**:
|
||||
- `CRITICAL`: Reserved for extreme scenarios only
|
||||
- `ERROR`: Requires immediate user attention
|
||||
- `WARNING`: Indicates future potential breakage
|
||||
- **Additional Attributes**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass, DOMAIN, "issue_id",
|
||||
breaks_in_ha_version="2024.1.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="issue_description",
|
||||
)
|
||||
```
|
||||
- Only create issues for problems users can potentially resolve
|
||||
@@ -22,6 +22,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/calendar/**
|
||||
- homeassistant/components/camera/**
|
||||
- homeassistant/components/climate/**
|
||||
- homeassistant/components/conversation/**
|
||||
- homeassistant/components/cover/**
|
||||
- homeassistant/components/date/**
|
||||
- homeassistant/components/datetime/**
|
||||
@@ -53,6 +54,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/update/**
|
||||
- homeassistant/components/vacuum/**
|
||||
- homeassistant/components/valve/**
|
||||
- homeassistant/components/wake_word/**
|
||||
- homeassistant/components/water_heater/**
|
||||
- homeassistant/components/weather/**
|
||||
|
||||
@@ -70,7 +72,6 @@ components: &components
|
||||
- homeassistant/components/cloud/**
|
||||
- homeassistant/components/config/**
|
||||
- homeassistant/components/configurator/**
|
||||
- homeassistant/components/conversation/**
|
||||
- homeassistant/components/demo/**
|
||||
- homeassistant/components/device_automation/**
|
||||
- homeassistant/components/dhcp/**
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"PYTHONASYNCIODEBUG": "1"
|
||||
},
|
||||
"features": {
|
||||
// Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
// Port 5683 udp is used by Shelly integration
|
||||
@@ -63,7 +60,13 @@
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"json.schemas": [
|
||||
|
||||
1
.gemini/skills
Symbolic link
1
.gemini/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -80,7 +80,7 @@ If the code communicates with devices, web services, or third-party tools:
|
||||
Updated and included derived files by running: `python3 -m script.hassfest`.
|
||||
- [ ] New or updated dependencies have been added to `requirements_all.txt`.
|
||||
Updated by running `python3 -m script.gen_requirements_all`.
|
||||
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
|
||||
- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.
|
||||
|
||||
<!--
|
||||
This project is very active and we have a high turnover of pull requests.
|
||||
|
||||
910
.github/copilot-instructions.md
vendored
910
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -9,3 +9,5 @@ updates:
|
||||
labels:
|
||||
- dependency
|
||||
- github_actions
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
184
.github/workflows/builder.yml
vendored
184
.github/workflows/builder.yml
vendored
@@ -10,19 +10,27 @@ on:
|
||||
|
||||
env:
|
||||
BUILD_TYPE: core
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
PIP_TIMEOUT: 60
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2025.12.0"
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
init:
|
||||
name: Initialize build
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
@@ -30,25 +38,27 @@ jobs:
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
uses: home-assistant/actions/helpers/info@master
|
||||
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
uses: home-assistant/actions/helpers/version@master
|
||||
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
ignore-dev: true
|
||||
|
||||
@@ -82,9 +92,9 @@ jobs:
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -96,11 +106,13 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -111,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -122,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -131,11 +143,12 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
UV_PRERELEASE: allow
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
python3 -m pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install packaging tomli
|
||||
uv pip install .
|
||||
python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}"
|
||||
python3 script/version_bump.py nightly --set-nightly-version "${VERSION}"
|
||||
|
||||
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
|
||||
echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
|
||||
@@ -165,7 +178,7 @@ jobs:
|
||||
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
|
||||
homeassistant/package_constraints.txt
|
||||
|
||||
sed -i "s|home-assistant-intents==.*||" requirements_all.txt
|
||||
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
@@ -181,17 +194,16 @@ jobs:
|
||||
- name: Write meta info file
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- &install_cosign
|
||||
name: Install Cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
@@ -202,30 +214,36 @@ jobs:
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
env:
|
||||
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${{ steps.vars.outputs.base_image }}"
|
||||
"${BASE_IMAGE}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
env:
|
||||
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${{ steps.vars.outputs.cache_image }}"
|
||||
"${CACHE_IMAGE}"
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -235,6 +253,7 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
@@ -242,8 +261,12 @@ jobs:
|
||||
org.opencontainers.image.version=${{ needs.init.outputs.version }}
|
||||
|
||||
- name: Sign image
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
|
||||
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -251,9 +274,9 @@ jobs:
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
matrix:
|
||||
machine:
|
||||
@@ -273,29 +296,32 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set build additional args
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -309,19 +335,23 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_machine"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
name: ${{ secrets.GIT_NAME }}
|
||||
email: ${{ secrets.GIT_EMAIL }}
|
||||
token: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Update version file
|
||||
uses: home-assistant/actions/helpers/version-push@master
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
@@ -331,7 +361,7 @@ jobs:
|
||||
|
||||
- name: Update version file (stable -> beta)
|
||||
if: needs.init.outputs.channel == 'stable'
|
||||
uses: home-assistant/actions/helpers/version-push@master
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
@@ -346,25 +376,28 @@ jobs:
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- *install_cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -372,14 +405,17 @@ jobs:
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Verifying ${arch} image signature..."
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
|
||||
@@ -410,16 +446,19 @@ jobs:
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Use imagetools to copy image blobs directly between registries
|
||||
# This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
@@ -429,23 +468,28 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
REGISTRY: ${{ matrix.registry }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
|
||||
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
done
|
||||
|
||||
# Build list of all tags for single manifest creation
|
||||
# Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
TAG_ARGS=()
|
||||
IFS=',' read -ra TAGS <<< "${{ steps.meta.outputs.tags }}"
|
||||
IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
@@ -469,15 +513,17 @@ jobs:
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
contents: read # To check out the repository
|
||||
id-token: write # For PyPI trusted publishing
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -508,10 +554,10 @@ jobs:
|
||||
name: Build and test hassfest image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
attestations: write # For build provenance attestation
|
||||
id-token: write # For build provenance attestation
|
||||
needs: ["init"]
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
env:
|
||||
@@ -519,17 +565,19 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -537,12 +585,12 @@ jobs:
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
- name: Run hassfest against core
|
||||
run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
|
||||
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -551,7 +599,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
897
.github/workflows/ci.yaml
vendored
897
.github/workflows/ci.yaml
vendored
File diff suppressed because it is too large
Load Diff
16
.github/workflows/codeql.yml
vendored
16
.github/workflows/codeql.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
schedule:
|
||||
- cron: "30 18 * * 4"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -15,20 +17,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read # To read workflow information for CodeQL
|
||||
contents: read # To check out the repository
|
||||
security-events: write # To upload CodeQL results
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
13
.github/workflows/detect-duplicate-issues.yml
vendored
13
.github/workflows/detect-duplicate-issues.yml
vendored
@@ -5,13 +5,18 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
models: read
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
detect-duplicates:
|
||||
name: Detect duplicate issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To comment on and label issues
|
||||
models: read # For AI-based duplicate detection
|
||||
|
||||
steps:
|
||||
- name: Check if integration label was added and extract details
|
||||
@@ -231,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
13
.github/workflows/detect-non-english-issues.yml
vendored
13
.github/workflows/detect-non-english-issues.yml
vendored
@@ -5,13 +5,18 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
models: read
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
detect-language:
|
||||
name: Detect non-English issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To comment on, label, and close issues
|
||||
models: read # For AI-based language detection
|
||||
|
||||
steps:
|
||||
- name: Check issue language
|
||||
@@ -57,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
10
.github/workflows/lock.yml
vendored
10
.github/workflows/lock.yml
vendored
@@ -5,10 +5,20 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
name: Lock inactive threads
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
|
||||
32
.github/workflows/restrict-task-creation.yml
vendored
32
.github/workflows/restrict-task-creation.yml
vendored
@@ -5,9 +5,39 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
check-authorization:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To read CODEOWNERS file
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
|
||||
16
.github/workflows/stale.yml
vendored
16
.github/workflows/stale.yml
vendored
@@ -6,10 +6,20 @@ on:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Mark stale issues and PRs
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
steps:
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
@@ -17,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
17
.github/workflows/translations.yml
vendored
17
.github/workflows/translations.yml
vendored
@@ -9,8 +9,14 @@ on:
|
||||
paths:
|
||||
- "**strings.json"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
@@ -19,14 +25,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
|
||||
python3 -m script.translations upload
|
||||
|
||||
68
.github/workflows/wheels.yml
vendored
68
.github/workflows/wheels.yml
vendored
@@ -17,7 +17,9 @@ on:
|
||||
- "script/gen_requirements_all.py"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name}}
|
||||
@@ -29,13 +31,14 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
@@ -50,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Create requirements_diff file
|
||||
run: |
|
||||
if [[ ${{ github.event_name }} =~ (schedule|workflow_dispatch) ]]; then
|
||||
if [[ "${GITHUB_EVENT_NAME}" =~ (schedule|workflow_dispatch) ]]; then
|
||||
touch requirements_diff.txt
|
||||
else
|
||||
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
) > .env_file
|
||||
|
||||
- name: Upload env_file
|
||||
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -82,7 +85,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: *actions-upload-artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -94,7 +97,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: *actions-upload-artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
@@ -106,7 +109,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: &matrix-build
|
||||
matrix:
|
||||
abi: ["cp313", "cp314"]
|
||||
arch: ["amd64", "aarch64"]
|
||||
include:
|
||||
@@ -115,17 +118,18 @@ jobs:
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- &download-requirements-diff
|
||||
name: Download requirements_diff
|
||||
uses: *actions-download-artifact
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -136,7 +140,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -156,16 +160,32 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: *matrix-build
|
||||
matrix:
|
||||
abi: ["cp313", "cp314"]
|
||||
arch: ["amd64", "aarch64"]
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- *download-env-file
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- *download-requirements-diff
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: *actions-download-artifact
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
@@ -178,7 +198,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: *home-assistant-wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.13
|
||||
rev: v0.15.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -17,6 +17,12 @@ repos:
|
||||
- --quiet-level=2
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
- --pedantic
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.13
|
||||
3.14
|
||||
|
||||
@@ -49,10 +49,12 @@ homeassistant.components.actiontec.*
|
||||
homeassistant.components.adax.*
|
||||
homeassistant.components.adguard.*
|
||||
homeassistant.components.aftership.*
|
||||
homeassistant.components.ai_task.*
|
||||
homeassistant.components.air_quality.*
|
||||
homeassistant.components.airgradient.*
|
||||
homeassistant.components.airly.*
|
||||
homeassistant.components.airnow.*
|
||||
homeassistant.components.airobot.*
|
||||
homeassistant.components.airos.*
|
||||
homeassistant.components.airq.*
|
||||
homeassistant.components.airthings.*
|
||||
@@ -83,6 +85,7 @@ homeassistant.components.androidtv_remote.*
|
||||
homeassistant.components.anel_pwrctrl.*
|
||||
homeassistant.components.anova.*
|
||||
homeassistant.components.anthemav.*
|
||||
homeassistant.components.anthropic.*
|
||||
homeassistant.components.apache_kafka.*
|
||||
homeassistant.components.apcupsd.*
|
||||
homeassistant.components.api.*
|
||||
@@ -128,6 +131,7 @@ homeassistant.components.bring.*
|
||||
homeassistant.components.brother.*
|
||||
homeassistant.components.browser.*
|
||||
homeassistant.components.bryant_evolution.*
|
||||
homeassistant.components.bsblan.*
|
||||
homeassistant.components.bthome.*
|
||||
homeassistant.components.button.*
|
||||
homeassistant.components.calendar.*
|
||||
@@ -207,6 +211,7 @@ homeassistant.components.firefly_iii.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.folder_watcher.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.fritz.*
|
||||
homeassistant.components.fritzbox.*
|
||||
@@ -220,6 +225,7 @@ homeassistant.components.generic_hygrostat.*
|
||||
homeassistant.components.generic_thermostat.*
|
||||
homeassistant.components.geo_location.*
|
||||
homeassistant.components.geocaching.*
|
||||
homeassistant.components.ghost.*
|
||||
homeassistant.components.gios.*
|
||||
homeassistant.components.github.*
|
||||
homeassistant.components.glances.*
|
||||
@@ -240,6 +246,7 @@ homeassistant.components.guardian.*
|
||||
homeassistant.components.habitica.*
|
||||
homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
homeassistant.components.hdfury.*
|
||||
homeassistant.components.heos.*
|
||||
homeassistant.components.here_travel_time.*
|
||||
homeassistant.components.history.*
|
||||
@@ -271,6 +278,7 @@ homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
homeassistant.components.ibeacon.*
|
||||
homeassistant.components.idasen_desk.*
|
||||
homeassistant.components.image.*
|
||||
@@ -285,6 +293,7 @@ homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
homeassistant.components.integration.*
|
||||
homeassistant.components.intelliclima.*
|
||||
homeassistant.components.intent.*
|
||||
homeassistant.components.intent_script.*
|
||||
homeassistant.components.ios.*
|
||||
@@ -292,6 +301,7 @@ homeassistant.components.iotty.*
|
||||
homeassistant.components.ipp.*
|
||||
homeassistant.components.iqvia.*
|
||||
homeassistant.components.iron_os.*
|
||||
homeassistant.components.isal.*
|
||||
homeassistant.components.islamic_prayer_times.*
|
||||
homeassistant.components.isy994.*
|
||||
homeassistant.components.jellyfin.*
|
||||
@@ -302,6 +312,7 @@ homeassistant.components.knocki.*
|
||||
homeassistant.components.knx.*
|
||||
homeassistant.components.kraken.*
|
||||
homeassistant.components.kulersky.*
|
||||
homeassistant.components.labs.*
|
||||
homeassistant.components.lacrosse.*
|
||||
homeassistant.components.lacrosse_view.*
|
||||
homeassistant.components.lamarzocco.*
|
||||
@@ -361,7 +372,7 @@ homeassistant.components.my.*
|
||||
homeassistant.components.mysensors.*
|
||||
homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.namecheapdns.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
@@ -375,6 +386,7 @@ homeassistant.components.no_ip.*
|
||||
homeassistant.components.nordpool.*
|
||||
homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.nrgkick.*
|
||||
homeassistant.components.ntfy.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
@@ -382,11 +394,13 @@ homeassistant.components.ohme.*
|
||||
homeassistant.components.onboarding.*
|
||||
homeassistant.components.oncue.*
|
||||
homeassistant.components.onedrive.*
|
||||
homeassistant.components.onedrive_for_business.*
|
||||
homeassistant.components.onewire.*
|
||||
homeassistant.components.onkyo.*
|
||||
homeassistant.components.open_meteo.*
|
||||
homeassistant.components.open_router.*
|
||||
homeassistant.components.openai_conversation.*
|
||||
homeassistant.components.openevse.*
|
||||
homeassistant.components.openexchangerates.*
|
||||
homeassistant.components.opensky.*
|
||||
homeassistant.components.openuv.*
|
||||
@@ -394,6 +408,7 @@ homeassistant.components.opnsense.*
|
||||
homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
@@ -410,6 +425,7 @@ homeassistant.components.plugwise.*
|
||||
homeassistant.components.pooldose.*
|
||||
homeassistant.components.portainer.*
|
||||
homeassistant.components.powerfox.*
|
||||
homeassistant.components.powerfox_local.*
|
||||
homeassistant.components.powerwall.*
|
||||
homeassistant.components.private_ble_device.*
|
||||
homeassistant.components.prometheus.*
|
||||
@@ -428,10 +444,13 @@ homeassistant.components.radarr.*
|
||||
homeassistant.components.radio_browser.*
|
||||
homeassistant.components.rainforest_raven.*
|
||||
homeassistant.components.rainmachine.*
|
||||
homeassistant.components.random.*
|
||||
homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.recovery_mode.*
|
||||
homeassistant.components.redgtech.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.remote_calendar.*
|
||||
@@ -462,6 +481,7 @@ homeassistant.components.schlage.*
|
||||
homeassistant.components.scrape.*
|
||||
homeassistant.components.script.*
|
||||
homeassistant.components.search.*
|
||||
homeassistant.components.season.*
|
||||
homeassistant.components.select.*
|
||||
homeassistant.components.sensibo.*
|
||||
homeassistant.components.sensirion_ble.*
|
||||
@@ -488,6 +508,7 @@ homeassistant.components.smtp.*
|
||||
homeassistant.components.snooz.*
|
||||
homeassistant.components.solarlog.*
|
||||
homeassistant.components.sonarr.*
|
||||
homeassistant.components.spaceapi.*
|
||||
homeassistant.components.speedtestdotnet.*
|
||||
homeassistant.components.spotify.*
|
||||
homeassistant.components.sql.*
|
||||
@@ -512,6 +533,7 @@ homeassistant.components.synology_dsm.*
|
||||
homeassistant.components.system_health.*
|
||||
homeassistant.components.system_log.*
|
||||
homeassistant.components.systemmonitor.*
|
||||
homeassistant.components.systemnexa2.*
|
||||
homeassistant.components.tag.*
|
||||
homeassistant.components.tailscale.*
|
||||
homeassistant.components.tailwind.*
|
||||
@@ -554,6 +576,7 @@ homeassistant.components.update.*
|
||||
homeassistant.components.uptime.*
|
||||
homeassistant.components.uptime_kuma.*
|
||||
homeassistant.components.uptimerobot.*
|
||||
homeassistant.components.usage_prediction.*
|
||||
homeassistant.components.usb.*
|
||||
homeassistant.components.uvc.*
|
||||
homeassistant.components.vacuum.*
|
||||
@@ -572,6 +595,7 @@ homeassistant.components.water_heater.*
|
||||
homeassistant.components.watts.*
|
||||
homeassistant.components.watttime.*
|
||||
homeassistant.components.weather.*
|
||||
homeassistant.components.web_rtc.*
|
||||
homeassistant.components.webhook.*
|
||||
homeassistant.components.webostv.*
|
||||
homeassistant.components.websocket_api.*
|
||||
|
||||
328
AGENTS.md
Normal file
328
AGENTS.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
**When reviewing code, do NOT comment on:**
|
||||
- **Missing imports** - We use static analysis tooling to catch that
|
||||
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
- **Language Features**: Use the newest features when possible:
|
||||
- Pattern matching
|
||||
- Type hints
|
||||
- f-strings (preferred over `%` or `.format()`)
|
||||
- Dataclasses
|
||||
- Walrus operator
|
||||
|
||||
### Strict Typing (Platinum)
|
||||
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
|
||||
- **Custom Config Entry Types**: When using runtime_data:
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
```
|
||||
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
|
||||
- **Testing**: pytest with plain functions and fixtures
|
||||
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
|
||||
|
||||
### Writing Style Guidelines
|
||||
- **Tone**: Friendly and informative
|
||||
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
|
||||
- **Inclusivity**: Use objective, non-discriminatory language
|
||||
- **Clarity**: Write for non-native English speakers
|
||||
- **Formatting in Messages**:
|
||||
- Use backticks for: file paths, filenames, variable names, field entries
|
||||
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
|
||||
- Avoid abbreviations when possible
|
||||
|
||||
### Documentation Standards
|
||||
- **File Headers**: Short and concise
|
||||
```python
|
||||
"""Integration for Peblar EV chargers."""
|
||||
```
|
||||
- **Method/Function Docstrings**: Required for all
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
|
||||
"""Set up Peblar from a config entry."""
|
||||
```
|
||||
- **Comment Style**:
|
||||
- Use clear, descriptive comments
|
||||
- Explain the "why" not just the "what"
|
||||
- Keep code block lines under 80 characters when possible
|
||||
- Use progressive disclosure (simple explanation first, complex details later)
|
||||
|
||||
## Async Programming
|
||||
|
||||
- All external I/O operations must be async
|
||||
- **Best Practices**:
|
||||
- Avoid sleeping in loops
|
||||
- Avoid awaiting in loops - use `gather` instead
|
||||
- No blocking calls
|
||||
- Group executor jobs when possible - switching between event loop and executor is expensive
|
||||
|
||||
### Blocking Operations
|
||||
- **Use Executor**: For blocking I/O operations
|
||||
```python
|
||||
result = await hass.async_add_executor_job(blocking_function, args)
|
||||
```
|
||||
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
|
||||
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
|
||||
|
||||
### Thread Safety
|
||||
- **@callback Decorator**: For event loop safe functions
|
||||
```python
|
||||
@callback
|
||||
def async_update_callback(self, event):
|
||||
"""Safe to run in event loop."""
|
||||
self.async_write_ha_state()
|
||||
```
|
||||
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
|
||||
- **Registry Changes**: Must be done in event loop thread
|
||||
|
||||
### Error Handling
|
||||
- **Exception Types**: Choose most specific exception available
|
||||
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
|
||||
- `HomeAssistantError`: Device communication failures
|
||||
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
|
||||
- `ConfigEntryAuthFailed`: Authentication problems
|
||||
- `ConfigEntryError`: Permanent setup issues
|
||||
- **Try/Catch Best Practices**:
|
||||
- Only wrap code that can throw exceptions
|
||||
- Keep try blocks minimal - process data after the try/catch
|
||||
- **Avoid bare exceptions** except in specific cases:
|
||||
- ❌ Generally not allowed: `except:` or `except Exception:`
|
||||
- ✅ Allowed in config flows to ensure robustness
|
||||
- ✅ Allowed in functions/methods that run in background tasks
|
||||
- Bad pattern:
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
# ❌ Don't process data inside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
self._attr_native_value = processed
|
||||
except DeviceError:
|
||||
_LOGGER.error("Failed to get data")
|
||||
```
|
||||
- Good pattern:
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
except DeviceError:
|
||||
_LOGGER.error("Failed to get data")
|
||||
return
|
||||
|
||||
# ✅ Process data outside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
self._attr_native_value = processed
|
||||
```
|
||||
- **Bare Exception Usage**:
|
||||
```python
|
||||
# ❌ Not allowed in regular code
|
||||
try:
|
||||
data = await device.get_data()
|
||||
except Exception: # Too broad
|
||||
_LOGGER.error("Failed")
|
||||
|
||||
# ✅ Allowed in config flow for robustness
|
||||
async def async_step_user(self, user_input=None):
|
||||
try:
|
||||
await self._test_connection(user_input)
|
||||
except Exception: # Allowed here
|
||||
errors["base"] = "unknown"
|
||||
|
||||
# ✅ Allowed in background tasks
|
||||
async def _background_refresh():
|
||||
try:
|
||||
await coordinator.async_refresh()
|
||||
except Exception: # Allowed in task
|
||||
_LOGGER.exception("Unexpected error in background task")
|
||||
```
|
||||
- **Setup Failure Patterns**:
|
||||
```python
|
||||
try:
|
||||
await device.async_setup()
|
||||
except (asyncio.TimeoutError, TimeoutException) as ex:
|
||||
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
|
||||
except AuthFailed as ex:
|
||||
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
|
||||
```
|
||||
|
||||
### Logging
|
||||
- **Format Guidelines**:
|
||||
- No periods at end of messages
|
||||
- No integration names/domains (added automatically)
|
||||
- No sensitive data (keys, tokens, passwords)
|
||||
- Use debug level for non-user-facing messages
|
||||
- **Use Lazy Logging**:
|
||||
```python
|
||||
_LOGGER.debug("This is a log message with %s", variable)
|
||||
```
|
||||
|
||||
### Unavailability Logging
|
||||
- **Log Once**: When device/service becomes unavailable (info level)
|
||||
- **Log Recovery**: When device/service comes back online
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
_unavailable_logged: bool = False
|
||||
|
||||
if not self._unavailable_logged:
|
||||
_LOGGER.info("The sensor is unavailable: %s", ex)
|
||||
self._unavailable_logged = True
|
||||
# On recovery:
|
||||
if self._unavailable_logged:
|
||||
_LOGGER.info("The sensor is back online")
|
||||
self._unavailable_logged = False
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment
|
||||
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
|
||||
- **Dev container**: No activation needed, the environment is pre-configured
|
||||
|
||||
### Code Quality & Linting
|
||||
- **Run all linters on all files**: `prek run --all-files`
|
||||
- **Run linters on staged files only**: `prek run`
|
||||
- **PyLint on everything** (slow): `pylint homeassistant`
|
||||
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
|
||||
- **MyPy type checking (whole project)**: `mypy homeassistant/`
|
||||
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
|
||||
|
||||
### Testing
|
||||
- **Quick test of changed files**: `pytest --timeout=10 --picked`
|
||||
- **Update test snapshots**: Add `--snapshot-update` to pytest command
|
||||
- ⚠️ Omit test results after using `--snapshot-update`
|
||||
- Always run tests again without the flag to verify snapshots
|
||||
- **Full test suite** (AVOID - very slow): `pytest ./tests`
|
||||
|
||||
### Dependencies & Requirements
|
||||
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
|
||||
- **Install all Python requirements**:
|
||||
```bash
|
||||
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
|
||||
```
|
||||
- **Install test requirements only**:
|
||||
```bash
|
||||
uv pip install -r requirements_test_all.txt -r requirements.txt
|
||||
```
|
||||
|
||||
### Translations
|
||||
- **Update translations after strings.json changes**:
|
||||
```bash
|
||||
python -m script.translations develop --all
|
||||
```
|
||||
|
||||
### Project Validation
|
||||
- **Run hassfest** (checks project structure and updates generated files):
|
||||
```bash
|
||||
python -m script.hassfest
|
||||
```
|
||||
|
||||
## Common Anti-Patterns & Best Practices
|
||||
|
||||
### ❌ **Avoid These Patterns**
|
||||
```python
|
||||
# Blocking operations in event loop
|
||||
data = requests.get(url) # ❌ Blocks event loop
|
||||
time.sleep(5) # ❌ Blocks event loop
|
||||
|
||||
# Reusing BleakClient instances
|
||||
self.client = BleakClient(address)
|
||||
await self.client.connect()
|
||||
# Later...
|
||||
await self.client.connect() # ❌ Don't reuse
|
||||
|
||||
# Hardcoded strings in code
|
||||
self._attr_name = "Temperature Sensor" # ❌ Not translatable
|
||||
|
||||
# Missing error handling
|
||||
data = await self.api.get_data() # ❌ No exception handling
|
||||
|
||||
# Storing sensitive data in diagnostics
|
||||
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
|
||||
|
||||
# Accessing hass.data directly in tests
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
|
||||
|
||||
# User-configurable polling intervals
|
||||
# In config flow
|
||||
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
|
||||
# In coordinator
|
||||
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
|
||||
|
||||
# User-configurable config entry names (non-helper integrations)
|
||||
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
|
||||
|
||||
# Too much code in try block
|
||||
try:
|
||||
response = await client.get_data() # Can throw
|
||||
# ❌ Data processing should be outside try block
|
||||
temperature = response["temperature"] / 10
|
||||
humidity = response["humidity"]
|
||||
self._attr_native_value = temperature
|
||||
except ClientError:
|
||||
_LOGGER.error("Failed to fetch data")
|
||||
|
||||
# Bare exceptions in regular code
|
||||
try:
|
||||
value = await sensor.read_value()
|
||||
except Exception: # ❌ Too broad - catch specific exceptions
|
||||
_LOGGER.error("Failed to read sensor")
|
||||
```
|
||||
|
||||
### ✅ **Use These Patterns Instead**
|
||||
```python
|
||||
# Async operations with executor
|
||||
data = await hass.async_add_executor_job(requests.get, url)
|
||||
await asyncio.sleep(5) # ✅ Non-blocking
|
||||
|
||||
# Fresh BleakClient instances
|
||||
client = BleakClient(address) # ✅ New instance each time
|
||||
await client.connect()
|
||||
|
||||
# Translatable entity names
|
||||
_attr_translation_key = "temperature_sensor" # ✅ Translatable
|
||||
|
||||
# Proper error handling
|
||||
try:
|
||||
data = await self.api.get_data()
|
||||
except ApiException as err:
|
||||
raise UpdateFailed(f"API error: {err}") from err
|
||||
|
||||
# Redacted diagnostics data
|
||||
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
|
||||
|
||||
# Test through proper integration setup and fixtures
|
||||
@pytest.fixture
|
||||
async def init_integration(hass, mock_config_entry, mock_api):
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
|
||||
|
||||
# Integration-determined polling intervals (not user-configurable)
|
||||
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
# ✅ Integration determines interval based on device capabilities, connection type, etc.
|
||||
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=interval,
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
62
CODEOWNERS
generated
62
CODEOWNERS
generated
@@ -15,7 +15,7 @@
|
||||
.yamllint @home-assistant/core
|
||||
pyproject.toml @home-assistant/core
|
||||
requirements_test.txt @home-assistant/core
|
||||
/.devcontainer/ @home-assistant/core
|
||||
/.devcontainer/ @home-assistant/core @edenhaus
|
||||
/.github/ @home-assistant/core
|
||||
/.vscode/ @home-assistant/core
|
||||
/homeassistant/*.py @home-assistant/core
|
||||
@@ -288,6 +288,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cloud/ @home-assistant/cloud
|
||||
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
|
||||
/tests/components/cloudflare/ @ludeeus @ctalkington
|
||||
/homeassistant/components/cloudflare_r2/ @corrreia
|
||||
/tests/components/cloudflare_r2/ @corrreia
|
||||
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/tests/components/co2signal/ @jpbede @VIKTORVAV99
|
||||
/homeassistant/components/coinbase/ @tombrien
|
||||
@@ -593,6 +595,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/geonetnz_quakes/ @exxamalte
|
||||
/homeassistant/components/geonetnz_volcano/ @exxamalte
|
||||
/tests/components/geonetnz_volcano/ @exxamalte
|
||||
/homeassistant/components/ghost/ @johnonolan
|
||||
/tests/components/ghost/ @johnonolan
|
||||
/homeassistant/components/gios/ @bieniu
|
||||
/tests/components/gios/ @bieniu
|
||||
/homeassistant/components/github/ @timmo001 @ludeeus
|
||||
@@ -641,6 +645,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/gpsd/ @fabaff @jrieger
|
||||
/homeassistant/components/gree/ @cmroche
|
||||
/tests/components/gree/ @cmroche
|
||||
/homeassistant/components/green_planet_energy/ @petschni
|
||||
/tests/components/green_planet_energy/ @petschni
|
||||
/homeassistant/components/greeneye_monitor/ @jkeljo
|
||||
/tests/components/greeneye_monitor/ @jkeljo
|
||||
/homeassistant/components/group/ @home-assistant/core
|
||||
@@ -666,6 +672,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/hdmi_cec/ @inytar
|
||||
/tests/components/hdmi_cec/ @inytar
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -709,8 +717,10 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homekit_controller/ @Jc2k @bdraco
|
||||
/homeassistant/components/homematic/ @pvizeli
|
||||
/tests/components/homematic/ @pvizeli
|
||||
/homeassistant/components/homematicip_cloud/ @hahn-th
|
||||
/tests/components/homematicip_cloud/ @hahn-th
|
||||
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/tests/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/homeassistant/components/homevolt/ @danielhiversen
|
||||
/tests/components/homevolt/ @danielhiversen
|
||||
/homeassistant/components/homewizard/ @DCSBL
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
@@ -743,6 +753,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
|
||||
/homeassistant/components/hyperion/ @dermotduffy
|
||||
/tests/components/hyperion/ @dermotduffy
|
||||
/homeassistant/components/hypontech/ @jcisio
|
||||
/tests/components/hypontech/ @jcisio
|
||||
/homeassistant/components/ialarm/ @RyuzakiKK
|
||||
/tests/components/ialarm/ @RyuzakiKK
|
||||
/homeassistant/components/iammeter/ @lewei50
|
||||
@@ -752,6 +764,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/icloud/ @Quentame @nzapponi
|
||||
/homeassistant/components/idasen_desk/ @abmantis
|
||||
/tests/components/idasen_desk/ @abmantis
|
||||
/homeassistant/components/idrive_e2/ @patrickvorgers
|
||||
/tests/components/idrive_e2/ @patrickvorgers
|
||||
/homeassistant/components/igloohome/ @keithle888
|
||||
/tests/components/igloohome/ @keithle888
|
||||
/homeassistant/components/ign_sismologia/ @exxamalte
|
||||
@@ -774,10 +788,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
/tests/components/incomfort/ @jbouwh
|
||||
/homeassistant/components/indevolt/ @xirtnl
|
||||
/tests/components/indevolt/ @xirtnl
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/tests/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
/tests/components/inkbird/ @bdraco
|
||||
/homeassistant/components/input_boolean/ @home-assistant/core
|
||||
@@ -796,6 +812,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/insteon/ @teharris1
|
||||
/homeassistant/components/integration/ @dgomes
|
||||
/tests/components/integration/ @dgomes
|
||||
/homeassistant/components/intelliclima/ @dvdinth
|
||||
/tests/components/intelliclima/ @dvdinth
|
||||
/homeassistant/components/intellifire/ @jeeftor
|
||||
/tests/components/intellifire/ @jeeftor
|
||||
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
@@ -917,6 +935,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/liebherr/ @mettolen
|
||||
/tests/components/liebherr/ @mettolen
|
||||
/homeassistant/components/lifx/ @Djelibeybi
|
||||
/tests/components/lifx/ @Djelibeybi
|
||||
/homeassistant/components/light/ @home-assistant/core
|
||||
@@ -1052,6 +1072,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
/homeassistant/components/mta/ @OnFreund
|
||||
/tests/components/mta/ @OnFreund
|
||||
/homeassistant/components/mullvad/ @meichthys
|
||||
/tests/components/mullvad/ @meichthys
|
||||
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
|
||||
@@ -1070,14 +1092,14 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nam/ @bieniu
|
||||
/homeassistant/components/namecheapdns/ @tr4nt0r
|
||||
/tests/components/namecheapdns/ @tr4nt0r
|
||||
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek
|
||||
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
|
||||
/homeassistant/components/nasweb/ @nasWebio
|
||||
/tests/components/nasweb/ @nasWebio
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/homeassistant/components/ness_alarm/ @nickw444
|
||||
/tests/components/ness_alarm/ @nickw444
|
||||
/homeassistant/components/ness_alarm/ @nickw444 @poshy163
|
||||
/tests/components/ness_alarm/ @nickw444 @poshy163
|
||||
/homeassistant/components/nest/ @allenporter
|
||||
/tests/components/nest/ @allenporter
|
||||
/homeassistant/components/netatmo/ @cgtobi
|
||||
@@ -1124,6 +1146,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/notify_events/ @matrozov @papajojo
|
||||
/homeassistant/components/notion/ @bachya
|
||||
/tests/components/notion/ @bachya
|
||||
/homeassistant/components/nrgkick/ @andijakl
|
||||
/tests/components/nrgkick/ @andijakl
|
||||
/homeassistant/components/nsw_fuel_station/ @nickw444
|
||||
/tests/components/nsw_fuel_station/ @nickw444
|
||||
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||
@@ -1162,6 +1186,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ondilo_ico/ @JeromeHXP
|
||||
/homeassistant/components/onedrive/ @zweckj
|
||||
/tests/components/onedrive/ @zweckj
|
||||
/homeassistant/components/onedrive_for_business/ @zweckj
|
||||
/tests/components/onedrive_for_business/ @zweckj
|
||||
/homeassistant/components/onewire/ @garbled1 @epenet
|
||||
/tests/components/onewire/ @garbled1 @epenet
|
||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||
@@ -1257,8 +1283,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/portainer/ @erwindouna
|
||||
/homeassistant/components/powerfox/ @klaasnicolaas
|
||||
/tests/components/powerfox/ @klaasnicolaas
|
||||
/homeassistant/components/powerfox_local/ @klaasnicolaas
|
||||
/tests/components/powerfox_local/ @klaasnicolaas
|
||||
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/prana/ @prana-dev-official
|
||||
/tests/components/prana/ @prana-dev-official
|
||||
/homeassistant/components/private_ble_device/ @Jc2k
|
||||
/tests/components/private_ble_device/ @Jc2k
|
||||
/homeassistant/components/probe_plus/ @pantherale0
|
||||
@@ -1345,6 +1375,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/recorder/ @home-assistant/core
|
||||
/homeassistant/components/recovery_mode/ @home-assistant/core
|
||||
/tests/components/recovery_mode/ @home-assistant/core
|
||||
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
|
||||
/tests/components/redgtech/ @jonhsady @luan-nvg
|
||||
/homeassistant/components/refoss/ @ashionky
|
||||
/tests/components/refoss/ @ashionky
|
||||
/homeassistant/components/rehlko/ @bdraco @peterager
|
||||
@@ -1551,6 +1583,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87
|
||||
/tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87
|
||||
/homeassistant/components/splunk/ @Bre77
|
||||
/tests/components/splunk/ @Bre77
|
||||
/homeassistant/components/spotify/ @frenck @joostlek
|
||||
/tests/components/spotify/ @frenck @joostlek
|
||||
/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira
|
||||
@@ -1615,6 +1648,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
|
||||
/tests/components/systemnexa2/ @konsulten @slangstrom
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tag/ @home-assistant/core
|
||||
@@ -1640,6 +1675,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/telegram_bot/ @hanwg
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/teltonika/ @karlbeecken
|
||||
/tests/components/teltonika/ @karlbeecken
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
/tests/components/template/ @Petro31 @home-assistant/core
|
||||
/homeassistant/components/tesla_fleet/ @Bre77
|
||||
@@ -1706,6 +1743,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_train/ @gjohansson-ST
|
||||
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/homeassistant/components/trane/ @bdraco
|
||||
/tests/components/trane/ @bdraco
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
@@ -1722,6 +1761,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen
|
||||
/homeassistant/components/twitch/ @joostlek
|
||||
/tests/components/twitch/ @joostlek
|
||||
/homeassistant/components/uhoo/ @getuhoo @joshsmonta
|
||||
/tests/components/uhoo/ @getuhoo @joshsmonta
|
||||
/homeassistant/components/ukraine_alarm/ @PaulAnnekov
|
||||
/tests/components/ukraine_alarm/ @PaulAnnekov
|
||||
/homeassistant/components/unifi/ @Kane610
|
||||
@@ -1809,6 +1850,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/water_heater/ @home-assistant/core
|
||||
/tests/components/water_heater/ @home-assistant/core
|
||||
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
|
||||
/tests/components/waterfurnace/ @sdague @masterkoppa
|
||||
/homeassistant/components/watergate/ @adam-the-hero
|
||||
/tests/components/watergate/ @adam-the-hero
|
||||
/homeassistant/components/watson_tts/ @rutkai
|
||||
@@ -1867,6 +1909,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/worldclock/ @fabaff
|
||||
/homeassistant/components/ws66i/ @ssaenger
|
||||
/tests/components/ws66i/ @ssaenger
|
||||
/homeassistant/components/wsdot/ @ucodery
|
||||
/tests/components/wsdot/ @ucodery
|
||||
/homeassistant/components/wyoming/ @synesthesiam
|
||||
/tests/components/wyoming/ @synesthesiam
|
||||
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
|
||||
|
||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -24,13 +24,13 @@ ENV \
|
||||
COPY rootfs /
|
||||
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.9.17
|
||||
&& pip3 install uv==0.9.26
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
|
||||
# Claude Code native install
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
# Set the default shell to bash instead of sh
|
||||
|
||||
@@ -67,8 +67,6 @@ from .const import (
|
||||
BASE_PLATFORMS,
|
||||
FORMAT_DATETIME,
|
||||
KEY_DATA_LOGGING as DATA_LOGGING,
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
REQUIRED_NEXT_PYTHON_VER,
|
||||
SIGNAL_BOOTSTRAP_INTEGRATIONS,
|
||||
)
|
||||
from .core_config import async_process_ha_core_config
|
||||
@@ -516,38 +514,6 @@ async def async_from_config_dict(
|
||||
|
||||
stop = monotonic()
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
|
||||
|
||||
if (
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE
|
||||
and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER
|
||||
):
|
||||
current_python_version = ".".join(str(x) for x in sys.version_info[:3])
|
||||
required_python_version = ".".join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Support for the running Python version %s is deprecated and "
|
||||
"will be removed in Home Assistant %s; "
|
||||
"Please upgrade Python to %s"
|
||||
),
|
||||
current_python_version,
|
||||
REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
required_python_version,
|
||||
)
|
||||
issue_registry.async_create_issue(
|
||||
hass,
|
||||
core.DOMAIN,
|
||||
f"python_version_{required_python_version}",
|
||||
is_fixable=False,
|
||||
severity=issue_registry.IssueSeverity.WARNING,
|
||||
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
translation_key="python_version",
|
||||
translation_placeholders={
|
||||
"current_python_version": current_python_version,
|
||||
"required_python_version": required_python_version,
|
||||
"breaks_in_ha_version": REQUIRED_NEXT_PYTHON_HA_RELEASE,
|
||||
},
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
|
||||
5
homeassistant/brands/american_standard.json
Normal file
5
homeassistant/brands/american_standard.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "american_standard",
|
||||
"name": "American Standard",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
5
homeassistant/brands/cloudflare.json
Normal file
5
homeassistant/brands/cloudflare.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "cloudflare",
|
||||
"name": "Cloudflare",
|
||||
"integrations": ["cloudflare", "cloudflare_r2"]
|
||||
}
|
||||
5
homeassistant/brands/heatit.json
Normal file
5
homeassistant/brands/heatit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heatit",
|
||||
"name": "Heatit",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
5
homeassistant/brands/heiman.json
Normal file
5
homeassistant/brands/heiman.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heiman",
|
||||
"name": "Heiman",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"domain": "leviton",
|
||||
"name": "Leviton",
|
||||
"integrations": ["decora_wifi"],
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"microsoft",
|
||||
"msteams",
|
||||
"onedrive",
|
||||
"onedrive_for_business",
|
||||
"xbox"
|
||||
]
|
||||
}
|
||||
|
||||
5
homeassistant/brands/powerfox.json
Normal file
5
homeassistant/brands/powerfox.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "powerfox",
|
||||
"name": "Powerfox",
|
||||
"integrations": ["powerfox", "powerfox_local"]
|
||||
}
|
||||
5
homeassistant/brands/trane.json
Normal file
5
homeassistant/brands/trane.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "trane",
|
||||
"name": "Trane",
|
||||
"integrations": ["nexia", "trane"]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
||||
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
|
||||
from .services import async_setup_services
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
|
||||
|
||||
hass.data[DOMAIN] = AbodeSystem(abode, polling)
|
||||
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -113,11 +113,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
|
||||
|
||||
hass.data[DOMAIN].logout_listener()
|
||||
hass.data.pop(DOMAIN)
|
||||
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
|
||||
logout_listener()
|
||||
hass.data.pop(DOMAIN_DATA)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -127,16 +128,16 @@ async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||
|
||||
def logout(event: Event) -> None:
|
||||
"""Logout of Abode."""
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.data[DOMAIN].abode.events.stop()
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
hass.data[DOMAIN_DATA].abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN].abode.logout()
|
||||
hass.data[DOMAIN_DATA].abode.logout()
|
||||
LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
|
||||
|
||||
hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
|
||||
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, logout
|
||||
)
|
||||
|
||||
@@ -178,6 +179,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
|
||||
]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN].abode.events.add_event_callback(
|
||||
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
|
||||
event, partial(event_callback, event)
|
||||
)
|
||||
|
||||
@@ -13,8 +13,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -24,7 +23,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode alarm control panel device."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
async_add_entities(
|
||||
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
|
||||
)
|
||||
|
||||
@@ -15,8 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -26,7 +25,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode binary sensor devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
device_types = [
|
||||
"connectivity",
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN_DATA, LOGGER
|
||||
from .entity import AbodeDevice
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
@@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode camera devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
async_add_entities(
|
||||
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
|
||||
|
||||
@@ -64,7 +64,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
errors = {"base": "cannot_connect"}
|
||||
|
||||
except (ConnectTimeout, HTTPError):
|
||||
except ConnectTimeout, HTTPError:
|
||||
errors = {"base": "cannot_connect"}
|
||||
|
||||
if errors:
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
"""Constants for the Abode Security System component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AbodeSystem
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "abode"
|
||||
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
|
||||
ATTRIBUTION = "Data provided by goabode.com"
|
||||
|
||||
CONF_POLLING = "polling"
|
||||
|
||||
@@ -9,8 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -20,7 +19,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode cover devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
async_add_entities(
|
||||
AbodeCover(data, device)
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import ATTRIBUTION, DOMAIN
|
||||
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
|
||||
|
||||
|
||||
class AbodeEntity(Entity):
|
||||
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
|
||||
self._update_connection_status,
|
||||
)
|
||||
|
||||
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
|
||||
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from Abode connection status updates."""
|
||||
|
||||
@@ -20,8 +20,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -31,7 +30,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode light devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
async_add_entities(
|
||||
AbodeLight(data, device)
|
||||
@@ -100,7 +99,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return _hs
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
if self.hs_color is not None:
|
||||
@@ -111,7 +110,7 @@ class AbodeLight(AbodeDevice, LightEntity):
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Flag supported color modes."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
return {ColorMode.COLOR_TEMP, ColorMode.HS}
|
||||
|
||||
@@ -9,8 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
@@ -20,7 +19,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode lock devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
async_add_entities(
|
||||
AbodeLock(data, device)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeDevice
|
||||
|
||||
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode sensor devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
async_add_entities(
|
||||
AbodeSensor(data, device, description)
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN, DOMAIN_DATA, LOGGER
|
||||
|
||||
SERVICE_SETTINGS = "change_setting"
|
||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||
@@ -35,7 +35,7 @@ def _change_setting(call: ServiceCall) -> None:
|
||||
value = call.data[ATTR_VALUE]
|
||||
|
||||
try:
|
||||
call.hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
LOGGER.warning(ex)
|
||||
|
||||
@@ -46,7 +46,7 @@ def _capture_image(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
@@ -61,7 +61,7 @@ def _trigger_automation(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AbodeSystem
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN_DATA
|
||||
from .entity import AbodeAutomation, AbodeDevice
|
||||
|
||||
DEVICE_TYPES = ["switch", "valve"]
|
||||
@@ -25,7 +24,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode switch devices."""
|
||||
data: AbodeSystem = hass.data[DOMAIN]
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
|
||||
entities: list[SwitchEntity] = [
|
||||
AbodeSwitch(data, device)
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
|
||||
from accuweather import AccuWeather
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
|
||||
ent_reg = er.async_get(hass)
|
||||
for day in range(5):
|
||||
unique_id = f"{location_key}-ozone-{day}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
|
||||
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Removing ozone sensor entity %s", entity_id)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
longitude=user_input[CONF_LONGITUDE],
|
||||
)
|
||||
await accuweather.async_get_location()
|
||||
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||
except ApiError, ClientConnectorError, TimeoutError, ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidApiKeyError:
|
||||
errors[CONF_API_KEY] = "invalid_api_key"
|
||||
@@ -104,7 +104,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
longitude=self._longitude,
|
||||
)
|
||||
await accuweather.async_get_location()
|
||||
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||
except ApiError, ClientConnectorError, TimeoutError, ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity, ActronAirZoneEntity
|
||||
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -136,16 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
"""Return the target temperature."""
|
||||
return self._status.user_aircon_settings.temperature_setpoint_cool_c
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set a new fan mode."""
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
|
||||
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
|
||||
await self._status.ac_system.set_system_mode(ac_mode)
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
@@ -209,11 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
"""Return the target temperature."""
|
||||
return self._zone.temperature_setpoint_cool_c
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
is_enabled = hvac_mode != HVACMode.OFF
|
||||
await self._zone.enable(is_enabled)
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Base entity classes for Actron Air integration."""
|
||||
|
||||
from actron_neo_api import ActronAirZone
|
||||
from collections.abc import Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from actron_neo_api import ActronAirAPIError, ActronAirZone
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -9,6 +14,26 @@ from .const import DOMAIN
|
||||
from .coordinator import ActronAirSystemCoordinator
|
||||
|
||||
|
||||
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap API calls with exception handling."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except ActronAirAPIError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
|
||||
"""Base class for Actron Air entities."""
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Failed to communicate with Actron Air device: {error}"
|
||||
},
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity
|
||||
from .entity import ActronAirAcEntity, handle_actron_api_errors
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -29,30 +29,42 @@ SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
|
||||
key="away_mode",
|
||||
translation_key="away_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
|
||||
set_fn=lambda coordinator, enabled: (
|
||||
coordinator.data.user_aircon_settings.set_away_mode(enabled)
|
||||
),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="continuous_fan",
|
||||
translation_key="continuous_fan",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.user_aircon_settings.continuous_fan_enabled
|
||||
),
|
||||
set_fn=lambda coordinator, enabled: (
|
||||
coordinator.data.user_aircon_settings.set_continuous_mode(enabled)
|
||||
),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="quiet_mode",
|
||||
translation_key="quiet_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.user_aircon_settings.quiet_mode_enabled
|
||||
),
|
||||
set_fn=lambda coordinator, enabled: (
|
||||
coordinator.data.user_aircon_settings.set_quiet_mode(enabled)
|
||||
),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="turbo_mode",
|
||||
translation_key="turbo_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
|
||||
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.user_aircon_settings.turbo_enabled
|
||||
),
|
||||
set_fn=lambda coordinator, enabled: (
|
||||
coordinator.data.user_aircon_settings.set_turbo_mode(enabled)
|
||||
),
|
||||
is_supported_fn=lambda coordinator: (
|
||||
coordinator.data.user_aircon_settings.turbo_supported
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -93,10 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
|
||||
"""Return true if the switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator)
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.entity_description.set_fn(self.coordinator, True)
|
||||
|
||||
@handle_actron_api_errors
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.entity_description.set_fn(self.coordinator, False)
|
||||
|
||||
@@ -87,7 +87,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=data_schema,
|
||||
)
|
||||
|
||||
wifi_ssid = user_input[WIFI_SSID].replace(" ", "")
|
||||
wifi_ssid = user_input[WIFI_SSID]
|
||||
wifi_pswd = user_input[WIFI_PSWD].replace(" ", "")
|
||||
configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd)
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_FORCE,
|
||||
@@ -45,6 +46,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
||||
|
||||
@@ -57,6 +59,69 @@ class AdGuardData:
|
||||
version: str
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
|
||||
def _get_adguard_instances(hass: HomeAssistant) -> list[AdGuardHome]:
|
||||
"""Get the AdGuardHome instances."""
|
||||
entries: list[AdGuardConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
if not entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
|
||||
)
|
||||
return [entry.runtime_data.client for entry in entries]
|
||||
|
||||
async def add_url(call: ServiceCall) -> None:
|
||||
"""Service call to add a new filter subscription to AdGuard Home."""
|
||||
for adguard in _get_adguard_instances(call.hass):
|
||||
await adguard.filtering.add_url(
|
||||
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
|
||||
)
|
||||
|
||||
async def remove_url(call: ServiceCall) -> None:
|
||||
"""Service call to remove a filter subscription from AdGuard Home."""
|
||||
for adguard in _get_adguard_instances(call.hass):
|
||||
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
|
||||
|
||||
async def enable_url(call: ServiceCall) -> None:
|
||||
"""Service call to enable a filter subscription in AdGuard Home."""
|
||||
for adguard in _get_adguard_instances(call.hass):
|
||||
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
|
||||
|
||||
async def disable_url(call: ServiceCall) -> None:
|
||||
"""Service call to disable a filter subscription in AdGuard Home."""
|
||||
for adguard in _get_adguard_instances(call.hass):
|
||||
await adguard.filtering.disable_url(
|
||||
allowlist=False, url=call.data[CONF_URL]
|
||||
)
|
||||
|
||||
async def refresh(call: ServiceCall) -> None:
|
||||
"""Service call to refresh the filter subscriptions in AdGuard Home."""
|
||||
for adguard in _get_adguard_instances(call.hass):
|
||||
await adguard.filtering.refresh(
|
||||
allowlist=False, force=call.data[CONF_FORCE]
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
|
||||
"""Set up AdGuard Home from a config entry."""
|
||||
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
|
||||
@@ -79,56 +144,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def add_url(call: ServiceCall) -> None:
|
||||
"""Service call to add a new filter subscription to AdGuard Home."""
|
||||
await adguard.filtering.add_url(
|
||||
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
|
||||
)
|
||||
|
||||
async def remove_url(call: ServiceCall) -> None:
|
||||
"""Service call to remove a filter subscription from AdGuard Home."""
|
||||
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
|
||||
|
||||
async def enable_url(call: ServiceCall) -> None:
|
||||
"""Service call to enable a filter subscription in AdGuard Home."""
|
||||
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
|
||||
|
||||
async def disable_url(call: ServiceCall) -> None:
|
||||
"""Service call to disable a filter subscription in AdGuard Home."""
|
||||
await adguard.filtering.disable_url(allowlist=False, url=call.data[CONF_URL])
|
||||
|
||||
async def refresh(call: ServiceCall) -> None:
|
||||
"""Service call to refresh the filter subscriptions in AdGuard Home."""
|
||||
await adguard.filtering.refresh(allowlist=False, force=call.data[CONF_FORCE])
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
|
||||
"""Unload AdGuard Home config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# This is the last loaded instance of AdGuard, deregister any services
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -107,7 +107,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_hassio(
|
||||
self, discovery_info: HassioServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Prepare configuration for a Hass.io AdGuard Home add-on.
|
||||
"""Prepare configuration for a Hass.io AdGuard Home app.
|
||||
|
||||
This flow is triggered by the discovery component.
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
|
||||
"title": "AdGuard Home via Home Assistant add-on"
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the app: {addon}?",
|
||||
"title": "AdGuard Home via Home Assistant app"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -76,6 +76,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"add_url": {
|
||||
"description": "Adds a new filter subscription to AdGuard Home.",
|
||||
|
||||
@@ -9,9 +9,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -24,13 +28,20 @@ from .entity import AdsEntity
|
||||
from .hub import AdsHub
|
||||
|
||||
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
|
||||
CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin"
|
||||
CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
|
||||
CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
|
||||
STATE_KEY_BRIGHTNESS = "brightness"
|
||||
STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin"
|
||||
|
||||
DEFAULT_NAME = "ADS Light"
|
||||
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,
|
||||
vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string,
|
||||
vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int,
|
||||
vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -47,9 +58,24 @@ def setup_platform(
|
||||
|
||||
ads_var_enable: str = config[CONF_ADS_VAR]
|
||||
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
|
||||
ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN)
|
||||
min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN)
|
||||
max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN)
|
||||
name: str = config[CONF_NAME]
|
||||
|
||||
add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
|
||||
add_entities(
|
||||
[
|
||||
AdsLight(
|
||||
ads_hub,
|
||||
ads_var_enable,
|
||||
ads_var_brightness,
|
||||
ads_var_color_temp_kelvin,
|
||||
min_color_temp_kelvin,
|
||||
max_color_temp_kelvin,
|
||||
name,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AdsLight(AdsEntity, LightEntity):
|
||||
@@ -60,18 +86,40 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
ads_hub: AdsHub,
|
||||
ads_var_enable: str,
|
||||
ads_var_brightness: str | None,
|
||||
ads_var_color_temp_kelvin: str | None,
|
||||
min_color_temp_kelvin: int | None,
|
||||
max_color_temp_kelvin: int | None,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize AdsLight entity."""
|
||||
super().__init__(ads_hub, name, ads_var_enable)
|
||||
self._state_dict[STATE_KEY_BRIGHTNESS] = None
|
||||
self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None
|
||||
self._ads_var_brightness = ads_var_brightness
|
||||
self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin
|
||||
|
||||
# Determine supported color modes
|
||||
color_modes = {ColorMode.ONOFF}
|
||||
if ads_var_brightness is not None:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
self._attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
|
||||
|
||||
# Set color temperature range (static config values take precedence over defaults)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
min_color_temp_kelvin
|
||||
if min_color_temp_kelvin is not None
|
||||
else DEFAULT_MIN_KELVIN
|
||||
)
|
||||
self._attr_max_color_temp_kelvin = (
|
||||
max_color_temp_kelvin
|
||||
if max_color_temp_kelvin is not None
|
||||
else DEFAULT_MAX_KELVIN
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register device notification."""
|
||||
@@ -84,11 +132,23 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
STATE_KEY_BRIGHTNESS,
|
||||
)
|
||||
|
||||
if self._ads_var_color_temp_kelvin is not None:
|
||||
await self.async_initialize_device(
|
||||
self._ads_var_color_temp_kelvin,
|
||||
pyads.PLCTYPE_UINT,
|
||||
STATE_KEY_COLOR_TEMP_KELVIN,
|
||||
)
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of the light (0..255)."""
|
||||
return self._state_dict[STATE_KEY_BRIGHTNESS]
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature in Kelvin."""
|
||||
return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the entity is on."""
|
||||
@@ -97,6 +157,8 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on or set a specific dimmer value."""
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
|
||||
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
|
||||
|
||||
if self._ads_var_brightness is not None and brightness is not None:
|
||||
@@ -104,6 +166,11 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT
|
||||
)
|
||||
|
||||
if self._ads_var_color_temp_kelvin is not None and color_temp is not None:
|
||||
self._ads_hub.write_by_name(
|
||||
self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
"""Advantage Air climate integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from advantage_air import advantage_air
|
||||
|
||||
from advantage_air import ApiError, advantage_air
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import ADVANTAGE_AIR_RETRY
|
||||
from .models import AdvantageAirData
|
||||
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
|
||||
from .coordinator import AdvantageAirCoordinator, AdvantageAirDataConfigEntry
|
||||
from .services import async_setup_services
|
||||
|
||||
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData]
|
||||
|
||||
ADVANTAGE_AIR_SYNC_INTERVAL = 15
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
@@ -29,8 +23,13 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUEST_REFRESH_DELAY = 0.5
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -46,27 +45,10 @@ async def async_setup_entry(
|
||||
retry=ADVANTAGE_AIR_RETRY,
|
||||
)
|
||||
|
||||
async def async_get():
|
||||
try:
|
||||
return await api.async_get()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="Advantage Air",
|
||||
update_method=async_get,
|
||||
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
|
||||
),
|
||||
)
|
||||
|
||||
coordinator = AdvantageAirCoordinator(hass, entry, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = AdvantageAirData(coordinator, api)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -24,19 +24,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir Binary Sensor platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BinarySensorEntity] = []
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirFilter(instance, ac_key))
|
||||
entities.append(AdvantageAirFilter(coordinator, ac_key))
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only add motion sensor when motion is enabled
|
||||
if zone["motionConfig"] >= 2:
|
||||
entities.append(AdvantageAirZoneMotion(instance, ac_key, zone_key))
|
||||
entities.append(
|
||||
AdvantageAirZoneMotion(coordinator, ac_key, zone_key)
|
||||
)
|
||||
# Only add MyZone if it is available
|
||||
if zone["type"] != 0:
|
||||
entities.append(AdvantageAirZoneMyZone(instance, ac_key, zone_key))
|
||||
entities.append(
|
||||
AdvantageAirZoneMyZone(coordinator, ac_key, zone_key)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -47,9 +51,9 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_name = "Filter"
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air Filter sensor."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self._attr_unique_id += "-filter"
|
||||
|
||||
@property
|
||||
@@ -63,9 +67,11 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone Motion sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = f"{self._zone['name']} motion"
|
||||
self._attr_unique_id += "-motion"
|
||||
|
||||
@@ -81,9 +87,11 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone MyZone sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = f"{self._zone['name']} myZone"
|
||||
self._attr_unique_id += "-myzone"
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ from .const import (
|
||||
ADVANTAGE_AIR_STATE_ON,
|
||||
ADVANTAGE_AIR_STATE_OPEN,
|
||||
)
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_HVAC_MODES = {
|
||||
"heat": HVACMode.HEAT,
|
||||
@@ -90,16 +90,16 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir climate platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[ClimateEntity] = []
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirAC(instance, ac_key))
|
||||
entities.append(AdvantageAirAC(coordinator, ac_key))
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only add zone climate control when zone is in temperature control
|
||||
if zone["type"] > 0:
|
||||
entities.append(AdvantageAirZone(instance, ac_key, zone_key))
|
||||
entities.append(AdvantageAirZone(coordinator, ac_key, zone_key))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
_attr_name = None
|
||||
_support_preset = ClimateEntityFeature(0)
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an AdvantageAir AC unit."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
|
||||
self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE]
|
||||
|
||||
@@ -282,9 +282,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
_attr_max_temp = 32
|
||||
_attr_min_temp = 16
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an AdvantageAir Zone control."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = self._zone["name"]
|
||||
|
||||
@property
|
||||
|
||||
59
homeassistant/components/advantage_air/coordinator.py
Normal file
59
homeassistant/components/advantage_air/coordinator.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Coordinator for the Advantage Air integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from advantage_air import ApiError, advantage_air
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ADVANTAGE_AIR_SYNC_INTERVAL = 15
|
||||
REQUEST_REFRESH_DELAY = 0.5
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirCoordinator]
|
||||
|
||||
|
||||
class AdvantageAirCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Advantage Air coordinator."""
|
||||
|
||||
config_entry: AdvantageAirDataConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AdvantageAirDataConfigEntry,
|
||||
api: advantage_air,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="Advantage Air",
|
||||
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
|
||||
),
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the API."""
|
||||
try:
|
||||
return await self.api.async_get()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -13,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -26,24 +26,24 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir cover platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[CoverEntity] = []
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only add zone vent controls when zone in vent control mode.
|
||||
if zone["type"] == 0:
|
||||
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key))
|
||||
if things := coordinator.data.get("myThings"):
|
||||
for thing in things["things"].values():
|
||||
if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2"
|
||||
entities.append(
|
||||
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
|
||||
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.BLIND)
|
||||
)
|
||||
elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door"
|
||||
entities.append(
|
||||
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
|
||||
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.GARAGE)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -58,9 +58,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone Vent."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = self._zone["name"]
|
||||
|
||||
@property
|
||||
@@ -106,12 +108,12 @@ class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instance: AdvantageAirData,
|
||||
coordinator: AdvantageAirCoordinator,
|
||||
thing: dict[str, Any],
|
||||
device_class: CoverDeviceClass,
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Things Cover."""
|
||||
super().__init__(instance, thing)
|
||||
super().__init__(coordinator, thing)
|
||||
self._attr_device_class = device_class
|
||||
|
||||
@property
|
||||
|
||||
@@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
data = config_entry.runtime_data.coordinator.data
|
||||
data = config_entry.runtime_data.data
|
||||
|
||||
# Return only the relevant children
|
||||
return {
|
||||
|
||||
@@ -9,17 +9,17 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .models import AdvantageAirData
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
|
||||
|
||||
class AdvantageAirEntity(CoordinatorEntity):
|
||||
class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]):
|
||||
"""Parent class for Advantage Air Entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, instance: AdvantageAirData) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator) -> None:
|
||||
"""Initialize common aspects of an Advantage Air entity."""
|
||||
super().__init__(instance.coordinator)
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id: str = self.coordinator.data["system"]["rid"]
|
||||
|
||||
def update_handle_factory(self, func, *keys):
|
||||
@@ -41,9 +41,9 @@ class AdvantageAirEntity(CoordinatorEntity):
|
||||
class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
"""Parent class for Advantage Air AC Entities."""
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize common aspects of an Advantage Air ac entity."""
|
||||
super().__init__(instance)
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.ac_key: str = ac_key
|
||||
self._attr_unique_id += f"-{ac_key}"
|
||||
@@ -56,7 +56,7 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"],
|
||||
)
|
||||
self.async_update_ac = self.update_handle_factory(
|
||||
instance.api.aircon.async_update_ac, self.ac_key
|
||||
coordinator.api.aircon.async_update_ac, self.ac_key
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -73,14 +73,16 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
|
||||
class AdvantageAirZoneEntity(AdvantageAirAcEntity):
|
||||
"""Parent class for Advantage Air Zone Entities."""
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize common aspects of an Advantage Air zone entity."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
|
||||
self.zone_key: str = zone_key
|
||||
self._attr_unique_id += f"-{zone_key}"
|
||||
self.async_update_zone = self.update_handle_factory(
|
||||
instance.api.aircon.async_update_zone, self.ac_key, self.zone_key
|
||||
coordinator.api.aircon.async_update_zone, self.ac_key, self.zone_key
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -93,9 +95,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, thing: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize common aspects of an Advantage Air Things entity."""
|
||||
super().__init__(instance)
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._id = thing["id"]
|
||||
self._attr_unique_id += f"-{self._id}"
|
||||
@@ -108,7 +112,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
|
||||
name=thing["name"],
|
||||
)
|
||||
self.async_update_value = self.update_handle_factory(
|
||||
instance.api.things.async_update_value, self._id
|
||||
coordinator.api.things.async_update_value, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -117,7 +121,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
|
||||
return self.coordinator.data["myThings"]["things"][self._id]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def is_on(self) -> bool:
|
||||
"""Return if the thing is considered on."""
|
||||
return self._data["value"] > 0
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirEntity, AdvantageAirThingEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -20,21 +20,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir light platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[LightEntity] = []
|
||||
if my_lights := instance.coordinator.data.get("myLights"):
|
||||
if my_lights := coordinator.data.get("myLights"):
|
||||
for light in my_lights["lights"].values():
|
||||
if light.get("relay"):
|
||||
entities.append(AdvantageAirLight(instance, light))
|
||||
entities.append(AdvantageAirLight(coordinator, light))
|
||||
else:
|
||||
entities.append(AdvantageAirLightDimmable(instance, light))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
entities.append(AdvantageAirLightDimmable(coordinator, light))
|
||||
if things := coordinator.data.get("myThings"):
|
||||
for thing in things["things"].values():
|
||||
if thing["channelDipState"] == 4: # 4 = "Light (on/off)""
|
||||
entities.append(AdvantageAirThingLight(instance, thing))
|
||||
entities.append(AdvantageAirThingLight(coordinator, thing))
|
||||
elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)""
|
||||
entities.append(AdvantageAirThingLightDimmable(instance, thing))
|
||||
entities.append(AdvantageAirThingLightDimmable(coordinator, thing))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -45,9 +45,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Light."""
|
||||
super().__init__(instance)
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._id: str = light["id"]
|
||||
self._attr_unique_id += f"-{self._id}"
|
||||
@@ -59,7 +61,7 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
|
||||
name=light["name"],
|
||||
)
|
||||
self.async_update_state = self.update_handle_factory(
|
||||
instance.api.lights.async_update_state, self._id
|
||||
coordinator.api.lights.async_update_state, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -87,11 +89,13 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Dimmable Light."""
|
||||
super().__init__(instance, light)
|
||||
super().__init__(coordinator, light)
|
||||
self.async_update_value = self.update_handle_factory(
|
||||
instance.api.lights.async_update_value, self._id
|
||||
coordinator.api.lights.async_update_value, self._id
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""The Advantage Air integration models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from advantage_air import advantage_air
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdvantageAirData:
|
||||
"""Data for the Advantage Air integration."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
api: advantage_air
|
||||
99
homeassistant/components/advantage_air/quality_scale.yaml
Normal file
99
homeassistant/components/advantage_air/quality_scale.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Add mock_setup_entry common fixture.
|
||||
Test unique_id of the entry in happy flow.
|
||||
Split duplicate entry test from happy flow, use mock_config_entry.
|
||||
Error flow should end in CREATE_ENTRY to test recovery.
|
||||
Add data_description for ip_address (and port) to strings.json - tests fail with:
|
||||
"Translation not found for advantage_air: config.step.user.data_description.ip_address"
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Data descriptions missing
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: todo
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities do not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options to be set.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: todo
|
||||
comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration connects to local device without authentication.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Patch the library instead of mocking at integration level.
|
||||
Split binary sensor tests into multiple tests (enable entities etc).
|
||||
Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors.
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices.
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: AC zones are static per unit and configured on the device itself.
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Consider using UPDATE device class for app update binary sensor instead of custom.
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: todo
|
||||
exception-translations:
|
||||
status: todo
|
||||
comment: HomeAssistantError in entity.py and ServiceValidationError in climate.py
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not raise repair issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Zones are part of the AC unit, not separate removable devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -5,8 +5,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirAcEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_INACTIVE = "Inactive"
|
||||
|
||||
@@ -18,10 +18,12 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir select platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons)
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
async_add_entities(
|
||||
AdvantageAirMyZone(coordinator, ac_key) for ac_key in aircons
|
||||
)
|
||||
|
||||
|
||||
class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
|
||||
@@ -30,16 +32,16 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
|
||||
_attr_icon = "mdi:home-thermometer"
|
||||
_attr_name = "MyZone"
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air MyZone control."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self._attr_unique_id += "-myzone"
|
||||
self._attr_options = [ADVANTAGE_AIR_INACTIVE]
|
||||
self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE}
|
||||
self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0}
|
||||
|
||||
if "aircons" in instance.coordinator.data:
|
||||
for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values():
|
||||
if "aircons" in coordinator.data:
|
||||
for zone in coordinator.data["aircons"][ac_key]["zones"].values():
|
||||
if zone["type"] > 0:
|
||||
self._name_to_number[zone["name"]] = zone["number"]
|
||||
self._number_to_name[zone["number"]] = zone["name"]
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -14,17 +12,15 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import ADVANTAGE_AIR_STATE_OPEN
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
|
||||
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -36,30 +32,25 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir sensor platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[SensorEntity] = []
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
entities.append(AdvantageAirTimeTo(instance, ac_key, "On"))
|
||||
entities.append(AdvantageAirTimeTo(instance, ac_key, "Off"))
|
||||
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "On"))
|
||||
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "Off"))
|
||||
for zone_key, zone in ac_device["zones"].items():
|
||||
# Only show damper and temp sensors when zone is in temperature control
|
||||
if zone["type"] != 0:
|
||||
entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
|
||||
entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key))
|
||||
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key))
|
||||
entities.append(AdvantageAirZoneTemp(coordinator, ac_key, zone_key))
|
||||
# Only show wireless signal strength sensors when using wireless sensors
|
||||
if zone["rssi"] > 0:
|
||||
entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key))
|
||||
entities.append(
|
||||
AdvantageAirZoneSignal(coordinator, ac_key, zone_key)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
{vol.Required("minutes"): cv.positive_int},
|
||||
"set_time_to",
|
||||
)
|
||||
|
||||
|
||||
class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
|
||||
"""Representation of Advantage Air timer control."""
|
||||
@@ -67,9 +58,11 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
|
||||
_attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, action: str
|
||||
) -> None:
|
||||
"""Initialize the Advantage Air timer control."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self.action = action
|
||||
self._time_key = f"countDownTo{action}"
|
||||
self._attr_name = f"Time to {action}"
|
||||
@@ -100,9 +93,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone Vent Sensor."""
|
||||
super().__init__(instance, ac_key, zone_key=zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key=zone_key)
|
||||
self._attr_name = f"{self._zone['name']} vent"
|
||||
self._attr_unique_id += "-vent"
|
||||
|
||||
@@ -128,9 +123,11 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone wireless signal sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = f"{self._zone['name']} signal"
|
||||
self._attr_unique_id += "-signal"
|
||||
|
||||
@@ -162,9 +159,11 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
|
||||
def __init__(
|
||||
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
|
||||
) -> None:
|
||||
"""Initialize an Advantage Air Zone Temp Sensor."""
|
||||
super().__init__(instance, ac_key, zone_key)
|
||||
super().__init__(coordinator, ac_key, zone_key)
|
||||
self._attr_name = f"{self._zone['name']} temperature"
|
||||
self._attr_unique_id += "-temp"
|
||||
|
||||
|
||||
27
homeassistant/components/advantage_air/services.py
Normal file
27
homeassistant/components/advantage_air/services.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Services for Advantage Air integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
entity_domain=SENSOR_DOMAIN,
|
||||
schema={vol.Required("minutes"): cv.positive_int},
|
||||
func="set_time_to",
|
||||
)
|
||||
@@ -17,6 +17,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "An error occurred while updating from the Advantage Air API: {error}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_time_to": {
|
||||
"description": "Controls timers to turn the system on or off after a set number of minutes.",
|
||||
|
||||
@@ -13,8 +13,8 @@ from .const import (
|
||||
ADVANTAGE_AIR_STATE_OFF,
|
||||
ADVANTAGE_AIR_STATE_ON,
|
||||
)
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -24,20 +24,20 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir switch platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[SwitchEntity] = []
|
||||
if aircons := instance.coordinator.data.get("aircons"):
|
||||
if aircons := coordinator.data.get("aircons"):
|
||||
for ac_key, ac_device in aircons.items():
|
||||
if ac_device["info"]["freshAirStatus"] != "none":
|
||||
entities.append(AdvantageAirFreshAir(instance, ac_key))
|
||||
entities.append(AdvantageAirFreshAir(coordinator, ac_key))
|
||||
if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]:
|
||||
entities.append(AdvantageAirMyFan(instance, ac_key))
|
||||
entities.append(AdvantageAirMyFan(coordinator, ac_key))
|
||||
if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]:
|
||||
entities.append(AdvantageAirNightMode(instance, ac_key))
|
||||
if things := instance.coordinator.data.get("myThings"):
|
||||
entities.append(AdvantageAirNightMode(coordinator, ac_key))
|
||||
if things := coordinator.data.get("myThings"):
|
||||
entities.extend(
|
||||
AdvantageAirRelay(instance, thing)
|
||||
AdvantageAirRelay(coordinator, thing)
|
||||
for thing in things["things"].values()
|
||||
if thing["channelDipState"] == 8 # 8 = Other relay
|
||||
)
|
||||
@@ -51,9 +51,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
|
||||
_attr_name = "Fresh air"
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air fresh air control."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self._attr_unique_id += "-freshair"
|
||||
|
||||
@property
|
||||
@@ -77,9 +77,9 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity):
|
||||
_attr_name = "MyFan"
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air MyFan control."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self._attr_unique_id += "-myfan"
|
||||
|
||||
@property
|
||||
@@ -103,9 +103,9 @@ class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity):
|
||||
_attr_name = "MySleep$aver"
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
|
||||
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None:
|
||||
"""Initialize an Advantage Air Night Mode control."""
|
||||
super().__init__(instance, ac_key)
|
||||
super().__init__(coordinator, ac_key)
|
||||
self._attr_unique_id += "-nightmode"
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,8 +7,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdvantageAirDataConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AdvantageAirCoordinator
|
||||
from .entity import AdvantageAirEntity
|
||||
from .models import AdvantageAirData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -18,9 +18,9 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up AdvantageAir update platform."""
|
||||
|
||||
instance = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities([AdvantageAirApp(instance)])
|
||||
async_add_entities([AdvantageAirApp(coordinator)])
|
||||
|
||||
|
||||
class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
|
||||
@@ -28,9 +28,9 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
|
||||
|
||||
_attr_name = "App"
|
||||
|
||||
def __init__(self, instance: AdvantageAirData) -> None:
|
||||
def __init__(self, coordinator: AdvantageAirCoordinator) -> None:
|
||||
"""Initialize the Advantage Air App."""
|
||||
super().__init__(instance)
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])},
|
||||
manufacturer="Advantage Air",
|
||||
|
||||
@@ -74,7 +74,7 @@ class AemetWeather(
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
@property
|
||||
def condition(self):
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION])
|
||||
return CONDITIONS_MAP.get(cond)
|
||||
@@ -90,31 +90,31 @@ class AemetWeather(
|
||||
return self.get_aemet_forecast(AOD_FORECAST_HOURLY)
|
||||
|
||||
@property
|
||||
def humidity(self):
|
||||
def humidity(self) -> float | None:
|
||||
"""Return the humidity."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY])
|
||||
|
||||
@property
|
||||
def native_pressure(self):
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE])
|
||||
|
||||
@property
|
||||
def native_temperature(self):
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_TEMP])
|
||||
|
||||
@property
|
||||
def wind_bearing(self):
|
||||
def wind_bearing(self) -> float | None:
|
||||
"""Return the wind bearing."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION])
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self):
|
||||
def native_wind_gust_speed(self) -> float | None:
|
||||
"""Return the wind gust speed in native units."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX])
|
||||
|
||||
@property
|
||||
def native_wind_speed(self):
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED])
|
||||
|
||||
@@ -7,10 +7,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, SERVER_URL
|
||||
from .services import async_setup_services
|
||||
|
||||
ATTRIBUTION = "ispyconnect.com"
|
||||
DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
|
||||
@@ -19,6 +21,14 @@ PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA]
|
||||
|
||||
AgentDVRConfigEntry = ConfigEntry[Agent]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: AgentDVRConfigEntry
|
||||
|
||||
@@ -9,10 +9,7 @@ from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AgentDVRConfigEntry
|
||||
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN
|
||||
@@ -21,20 +18,6 @@ SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEV_EN_ALT = "enable_alerts"
|
||||
_DEV_DS_ALT = "disable_alerts"
|
||||
_DEV_EN_REC = "start_recording"
|
||||
_DEV_DS_REC = "stop_recording"
|
||||
_DEV_SNAP = "snapshot"
|
||||
|
||||
CAMERA_SERVICES = {
|
||||
_DEV_EN_ALT: "async_enable_alerts",
|
||||
_DEV_DS_ALT: "async_disable_alerts",
|
||||
_DEV_EN_REC: "async_start_recording",
|
||||
_DEV_DS_REC: "async_stop_recording",
|
||||
_DEV_SNAP: "async_snapshot",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -57,10 +40,6 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities(cameras)
|
||||
|
||||
platform = async_get_current_platform()
|
||||
for service, method in CAMERA_SERVICES.items():
|
||||
platform.async_register_entity_service(service, None, method)
|
||||
|
||||
|
||||
class AgentCamera(MjpegCamera):
|
||||
"""Representation of an Agent Device Stream."""
|
||||
|
||||
38
homeassistant/components/agent_dvr/services.py
Normal file
38
homeassistant/components/agent_dvr/services.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Services for Agent DVR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_DEV_EN_ALT = "enable_alerts"
|
||||
_DEV_DS_ALT = "disable_alerts"
|
||||
_DEV_EN_REC = "start_recording"
|
||||
_DEV_DS_REC = "stop_recording"
|
||||
_DEV_SNAP = "snapshot"
|
||||
|
||||
CAMERA_SERVICES = {
|
||||
_DEV_EN_ALT: "async_enable_alerts",
|
||||
_DEV_DS_ALT: "async_disable_alerts",
|
||||
_DEV_EN_REC: "async_start_recording",
|
||||
_DEV_DS_REC: "async_stop_recording",
|
||||
_DEV_SNAP: "async_snapshot",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
for service_name, method in CAMERA_SERVICES.items():
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
service_name,
|
||||
entity_domain=CAMERA_DOMAIN,
|
||||
schema=None,
|
||||
func=method,
|
||||
)
|
||||
@@ -133,8 +133,9 @@ CONTROL_ENTITIES: tuple[AirGradientSelectEntityDescription, ...] = (
|
||||
value_fn=lambda config: _get_value(
|
||||
config.co2_automatic_baseline_calibration_days, ABC_DAYS
|
||||
),
|
||||
set_value_fn=lambda client,
|
||||
value: client.set_co2_automatic_baseline_calibration(int(value)),
|
||||
set_value_fn=lambda client, value: (
|
||||
client.set_co2_automatic_baseline_calibration(int(value))
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM
|
||||
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -75,9 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo
|
||||
# Remove air_quality entities from registry if they exist
|
||||
ent_reg = er.async_get(hass)
|
||||
unique_id = f"{coordinator.latitude}-{coordinator.longitude}"
|
||||
if entity_id := ent_reg.async_get_entity_id(
|
||||
AIR_QUALITY_PLATFORM, DOMAIN, unique_id
|
||||
):
|
||||
if entity_id := ent_reg.async_get_entity_id(AIR_QUALITY_DOMAIN, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Removing deprecated air_quality entity %s", entity_id)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except (AirobotConnectionError, AirobotTimeoutError):
|
||||
except AirobotConnectionError, AirobotTimeoutError:
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
pass
|
||||
except AirobotError as err:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user