mirror of
https://github.com/home-assistant/core.git
synced 2026-04-13 21:26:19 +02:00
Compare commits
660 Commits
remove_dev
...
168007
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a0b5812d | ||
|
|
7d20308fda | ||
|
|
6fe2132e88 | ||
|
|
bbcaf8fd2d | ||
|
|
d1fcc7564e | ||
|
|
667002ddfa | ||
|
|
10780adb6e | ||
|
|
431736c5d8 | ||
|
|
6a3051718a | ||
|
|
95c3624b01 | ||
|
|
f53b629dfd | ||
|
|
d901541f48 | ||
|
|
cdcf810506 | ||
|
|
274146cbb2 | ||
|
|
b8cdd8dccc | ||
|
|
5abaa2ae72 | ||
|
|
4a511a3e53 | ||
|
|
81a657ab2c | ||
|
|
e9a79ee0e5 | ||
|
|
ffd439abc5 | ||
|
|
982a2b8af7 | ||
|
|
ef589f9b46 | ||
|
|
81f8319af4 | ||
|
|
442df2733a | ||
|
|
a061e47bec | ||
|
|
e5c49b6455 | ||
|
|
5c51820869 | ||
|
|
bb46c1c2ac | ||
|
|
0052055db5 | ||
|
|
300ed2ac1e | ||
|
|
58031a87cc | ||
|
|
a3fadbd7b9 | ||
|
|
525335b022 | ||
|
|
eb64589115 | ||
|
|
4ebf0bf0b6 | ||
|
|
f521838bf1 | ||
|
|
efb0162c6f | ||
|
|
ba62b6cbda | ||
|
|
4e13731838 | ||
|
|
4f255c23dd | ||
|
|
af69e9b5de | ||
|
|
df734655f6 | ||
|
|
4926ea9ef0 | ||
|
|
322dc2adeb | ||
|
|
2e648aca8b | ||
|
|
dac2777729 | ||
|
|
1e1e37637f | ||
|
|
d695250507 | ||
|
|
ab7b257785 | ||
|
|
3b1fa609f7 | ||
|
|
822fae227a | ||
|
|
2fa0bdb2dc | ||
|
|
8a43d1a12c | ||
|
|
483265a707 | ||
|
|
84f5cd8a12 | ||
|
|
e23da7a5f0 | ||
|
|
fe1e12a298 | ||
|
|
938eacd777 | ||
|
|
ba7a959727 | ||
|
|
966eadad69 | ||
|
|
f34ed8f8ba | ||
|
|
ac4b253a2f | ||
|
|
640fea89e0 | ||
|
|
fdf1b6536a | ||
|
|
974047664c | ||
|
|
03d6f5a756 | ||
|
|
9f1c396407 | ||
|
|
054b8ad534 | ||
|
|
b93cdc64f3 | ||
|
|
59248e5414 | ||
|
|
a5b830cc34 | ||
|
|
299562d6ee | ||
|
|
47cc31067c | ||
|
|
f050407bfa | ||
|
|
a202742fc6 | ||
|
|
63a0b5d2ff | ||
|
|
53ed4b2c77 | ||
|
|
2f91c6b050 | ||
|
|
d17cb0e096 | ||
|
|
c9ee533916 | ||
|
|
e88022c2cc | ||
|
|
d633ac8120 | ||
|
|
fb90237ae3 | ||
|
|
4658f4246d | ||
|
|
99e4c87f5e | ||
|
|
7690d9570c | ||
|
|
00560abd9c | ||
|
|
b6d4fca477 | ||
|
|
44e51c1103 | ||
|
|
3ad2c5e574 | ||
|
|
212c9b1a94 | ||
|
|
b670172867 | ||
|
|
23bcde09b0 | ||
|
|
62717fd3f5 | ||
|
|
86b72501ad | ||
|
|
59827967e6 | ||
|
|
fe5d45ed57 | ||
|
|
cf87e9ab72 | ||
|
|
64907ad7e2 | ||
|
|
9e111b2418 | ||
|
|
97d64ab37c | ||
|
|
547830b450 | ||
|
|
f2f605b425 | ||
|
|
781b5e1c0e | ||
|
|
68a7cbb620 | ||
|
|
a6a716571d | ||
|
|
ba09a54a37 | ||
|
|
7125796aac | ||
|
|
ce9875806d | ||
|
|
7cf422361b | ||
|
|
9a97f1e8d2 | ||
|
|
777f78f74d | ||
|
|
10c922b21f | ||
|
|
aa293ba2f4 | ||
|
|
5edcfdf621 | ||
|
|
244ed14019 | ||
|
|
109ec0705c | ||
|
|
6f7fa85d18 | ||
|
|
8d2564f00f | ||
|
|
f7096e3744 | ||
|
|
d7f28a09bb | ||
|
|
a54ea071f8 | ||
|
|
1597b740da | ||
|
|
3758d606c9 | ||
|
|
a79988aca7 | ||
|
|
837cd7d89d | ||
|
|
038bb6c15d | ||
|
|
6ccede7f30 | ||
|
|
fb541d8835 | ||
|
|
39a2c08d4e | ||
|
|
ea642980f2 | ||
|
|
4c8ea3669c | ||
|
|
14f24226ae | ||
|
|
3a9f805f10 | ||
|
|
191dd42a92 | ||
|
|
35ffffb159 | ||
|
|
4494f9ff6b | ||
|
|
09e6b6533a | ||
|
|
c42e37dd7d | ||
|
|
853b6a80d2 | ||
|
|
eaa1fc591a | ||
|
|
3f388e88e0 | ||
|
|
44eea221b7 | ||
|
|
6a3937b96b | ||
|
|
1c5e020344 | ||
|
|
6ac7952f26 | ||
|
|
7f0d94da9f | ||
|
|
8f383bccd9 | ||
|
|
8c50cb2ab1 | ||
|
|
b0888b051c | ||
|
|
0764e3e239 | ||
|
|
cf4d8f0974 | ||
|
|
e7e4c495fd | ||
|
|
8f6ae15a6a | ||
|
|
910dcb4d68 | ||
|
|
86b5efaf2c | ||
|
|
5f8483ba07 | ||
|
|
496c9551b3 | ||
|
|
2d45f9978e | ||
|
|
caa1a8880f | ||
|
|
1e78666b90 | ||
|
|
53738c0168 | ||
|
|
ca96c751e1 | ||
|
|
2a0a386e6d | ||
|
|
79dfa61e8b | ||
|
|
431387b76d | ||
|
|
f4a2f37fa6 | ||
|
|
ec54a121c1 | ||
|
|
f5d5ee71f5 | ||
|
|
7e1f4d27e8 | ||
|
|
4700c79ace | ||
|
|
b6ea61f953 | ||
|
|
1eab08f986 | ||
|
|
f491ec8b44 | ||
|
|
e639e983dc | ||
|
|
a983cb7ccd | ||
|
|
89ddfff66f | ||
|
|
77c8eab698 | ||
|
|
9ac730fb58 | ||
|
|
6cc05e6a28 | ||
|
|
75a4b088bc | ||
|
|
9056e0b64f | ||
|
|
3a1002457b | ||
|
|
97fe710187 | ||
|
|
09585a7e1c | ||
|
|
6d55c076e4 | ||
|
|
8b37cc8719 | ||
|
|
6510b3d1d1 | ||
|
|
e5a83106d7 | ||
|
|
de973e8900 | ||
|
|
fefc5a950f | ||
|
|
b3e7ae0fdd | ||
|
|
7bad7fc4f6 | ||
|
|
36944525e1 | ||
|
|
f3d25a04f8 | ||
|
|
f2c20fedeb | ||
|
|
93e9575547 | ||
|
|
681f8bedb4 | ||
|
|
566ff6d1d5 | ||
|
|
5bec3d1b41 | ||
|
|
050d929d8a | ||
|
|
15045f55d5 | ||
|
|
b2fb6c0a68 | ||
|
|
66e35cef06 | ||
|
|
9ea527520a | ||
|
|
872120821c | ||
|
|
998f24649d | ||
|
|
cc21c99e55 | ||
|
|
4efb6b9b56 | ||
|
|
a9f0cd203c | ||
|
|
eb31499e78 | ||
|
|
d292aa2e90 | ||
|
|
87f44a67be | ||
|
|
efb0e80577 | ||
|
|
11c34c7ddf | ||
|
|
4b820a0204 | ||
|
|
0c98f01b07 | ||
|
|
aa50822a82 | ||
|
|
f634525798 | ||
|
|
047500af42 | ||
|
|
db589f7318 | ||
|
|
3ea15f2743 | ||
|
|
8e430d9f26 | ||
|
|
075b47b5f9 | ||
|
|
949c907407 | ||
|
|
326799209c | ||
|
|
65bc7c9ea7 | ||
|
|
86d443f8c6 | ||
|
|
19ae7e722e | ||
|
|
57568fdc2c | ||
|
|
4c8a660b2d | ||
|
|
b0511519a1 | ||
|
|
038b583888 | ||
|
|
018c130988 | ||
|
|
462e9965d7 | ||
|
|
ea4d85f96c | ||
|
|
1a4d518ef2 | ||
|
|
a48a770ca4 | ||
|
|
e4aeee9d85 | ||
|
|
726edf3a3b | ||
|
|
b98aa0ad91 | ||
|
|
f82b8cb7c7 | ||
|
|
d6342d51cc | ||
|
|
1eead15c24 | ||
|
|
2e6137325c | ||
|
|
8d3d4a1b5c | ||
|
|
3e5132bf85 | ||
|
|
65e4b26006 | ||
|
|
13f1a42d69 | ||
|
|
5be48affcf | ||
|
|
8994f501f1 | ||
|
|
7f49ecffd3 | ||
|
|
a560967861 | ||
|
|
82202ee1c2 | ||
|
|
b697b3a54e | ||
|
|
6cf5bbe2f5 | ||
|
|
c0c61533e6 | ||
|
|
15e434431d | ||
|
|
0452bb91c7 | ||
|
|
5620fc9e96 | ||
|
|
1a5ef199da | ||
|
|
e98eec113e | ||
|
|
c74d4047d8 | ||
|
|
f5ae250720 | ||
|
|
bea4eea871 | ||
|
|
f4f202a8a1 | ||
|
|
c30ccf3750 | ||
|
|
0b8390cf21 | ||
|
|
1a048a7845 | ||
|
|
08097c67eb | ||
|
|
550e53d192 | ||
|
|
09ee76c265 | ||
|
|
f7b2f5e8f1 | ||
|
|
a1414717ad | ||
|
|
2f0889ac02 | ||
|
|
323b3a4d96 | ||
|
|
8aa0e9f6c3 | ||
|
|
906475249c | ||
|
|
354b5860bb | ||
|
|
74957969f7 | ||
|
|
b52ce22ee7 | ||
|
|
920ffdb9b5 | ||
|
|
4a454dff02 | ||
|
|
481eb66bc5 | ||
|
|
b76627a442 | ||
|
|
1aa214fb61 | ||
|
|
6e30de3a1c | ||
|
|
2f0488f985 | ||
|
|
61c02c854f | ||
|
|
a10f16ce3e | ||
|
|
293db47101 | ||
|
|
c9cdbcc3db | ||
|
|
856d363ca8 | ||
|
|
cb71628ee2 | ||
|
|
f34301f236 | ||
|
|
554b906788 | ||
|
|
d5ffd7f37a | ||
|
|
17fbb9909c | ||
|
|
bd4ac7993f | ||
|
|
eaba7b0e48 | ||
|
|
2a8551138d | ||
|
|
7586fe6dec | ||
|
|
74a6f781a1 | ||
|
|
4dba27f15e | ||
|
|
298b9b962f | ||
|
|
f6fb6f40dd | ||
|
|
12ee952a97 | ||
|
|
9d6a335127 | ||
|
|
e1fa894572 | ||
|
|
43c83cc850 | ||
|
|
7f27915825 | ||
|
|
54a63d2c3e | ||
|
|
9709fbd6c6 | ||
|
|
12abff5b9e | ||
|
|
9b2abb0acc | ||
|
|
e7bc593fa8 | ||
|
|
2a4b8c88a8 | ||
|
|
a6c66a86ee | ||
|
|
6dfcc1c4f6 | ||
|
|
dfb61ee881 | ||
|
|
4776b99f5f | ||
|
|
49f5557947 | ||
|
|
4ed9113e35 | ||
|
|
e2688f909b | ||
|
|
9e1c521fed | ||
|
|
73fbc87639 | ||
|
|
8986477c96 | ||
|
|
ca40d68417 | ||
|
|
d2539ccaf2 | ||
|
|
56e7b8ddbb | ||
|
|
ca5aa215d2 | ||
|
|
d3ca5132fc | ||
|
|
f1d309779e | ||
|
|
85fa2415c1 | ||
|
|
ddc00f6924 | ||
|
|
2216fcccc7 | ||
|
|
6212d548b8 | ||
|
|
bf12323782 | ||
|
|
8b9ba690f1 | ||
|
|
3c7c0091f2 | ||
|
|
7bea4a53e2 | ||
|
|
50d9109e5f | ||
|
|
6d80b3769a | ||
|
|
9ff97ecd7b | ||
|
|
34dc52c732 | ||
|
|
67243a5044 | ||
|
|
74770f0b33 | ||
|
|
b1d81536ce | ||
|
|
b8d8b1cfa8 | ||
|
|
dfaed39a01 | ||
|
|
4849dc0eb9 | ||
|
|
05aaf8745d | ||
|
|
7c1abd993d | ||
|
|
0674de2ce4 | ||
|
|
ba26d119f7 | ||
|
|
616a0f204c | ||
|
|
0eaa8d38db | ||
|
|
4ad2f752a3 | ||
|
|
310af5a31a | ||
|
|
11fac8ee48 | ||
|
|
e70514f540 | ||
|
|
1a7465dd72 | ||
|
|
15ddce74a7 | ||
|
|
ee4c941610 | ||
|
|
459fc43625 | ||
|
|
893d9306d4 | ||
|
|
c243680113 | ||
|
|
db6d95273c | ||
|
|
a084e85028 | ||
|
|
1d7eb5ed15 | ||
|
|
7b19b5a416 | ||
|
|
2b0f2bcd12 | ||
|
|
5d5d00f0b2 | ||
|
|
735adb61a3 | ||
|
|
42f602a2fd | ||
|
|
287b38bebd | ||
|
|
415f711039 | ||
|
|
28ddb3a590 | ||
|
|
0f1dbba65a | ||
|
|
206d1ab3a8 | ||
|
|
cb15014dc0 | ||
|
|
a30fc9e9d5 | ||
|
|
b5480da68e | ||
|
|
a5a8cca424 | ||
|
|
5c890a8a2b | ||
|
|
dc45f86beb | ||
|
|
5ef506623d | ||
|
|
5632d308dd | ||
|
|
43210b845b | ||
|
|
d798aad2d7 | ||
|
|
4232db480a | ||
|
|
8b7d1f60a0 | ||
|
|
cb69e638b4 | ||
|
|
f3a020780d | ||
|
|
d1224e3f92 | ||
|
|
ef8a0a404c | ||
|
|
c86eb38cdb | ||
|
|
2333e9e8b1 | ||
|
|
f8476e4e84 | ||
|
|
d904175a2f | ||
|
|
193720b4c6 | ||
|
|
409b3f17db | ||
|
|
c3c1c3eb46 | ||
|
|
6b2a4df6e0 | ||
|
|
2ac3979f83 | ||
|
|
2fb44bce5d | ||
|
|
d187e61274 | ||
|
|
7f79da2f75 | ||
|
|
4871344138 | ||
|
|
b0ba740024 | ||
|
|
b6f49d2063 | ||
|
|
c8e2a2b520 | ||
|
|
27365a4457 | ||
|
|
4efcb5a700 | ||
|
|
4ffc4b8f71 | ||
|
|
e8fa61ae63 | ||
|
|
c6c469cc7a | ||
|
|
d51afe20e0 | ||
|
|
5dac5ef099 | ||
|
|
a1b93b418b | ||
|
|
22a96583a9 | ||
|
|
01ffa6a676 | ||
|
|
abf37849fb | ||
|
|
72cd7ed178 | ||
|
|
6305ea8cf2 | ||
|
|
815c30b213 | ||
|
|
68cc6df3e0 | ||
|
|
7f700c891a | ||
|
|
e33727a75a | ||
|
|
374a050636 | ||
|
|
90045e5539 | ||
|
|
e30c379979 | ||
|
|
86d80c96d7 | ||
|
|
1bbecec991 | ||
|
|
3970a27369 | ||
|
|
745107c192 | ||
|
|
24530d13af | ||
|
|
7529b9252c | ||
|
|
92a1c4568d | ||
|
|
c6527c9f6d | ||
|
|
05b7fa9602 | ||
|
|
375bd55ae6 | ||
|
|
fbd0cb8666 | ||
|
|
7e061262ad | ||
|
|
0e9c17fb16 | ||
|
|
940de5ea84 | ||
|
|
bf4773d9bc | ||
|
|
0bedcc55ce | ||
|
|
313f97fc47 | ||
|
|
b7d32e0650 | ||
|
|
b9afb2a861 | ||
|
|
d3a01d4c80 | ||
|
|
05bd2d05f5 | ||
|
|
23469d8950 | ||
|
|
26f677dcd1 | ||
|
|
484d9b0cbe | ||
|
|
03ed46aa07 | ||
|
|
e5f4000ac2 | ||
|
|
406598dbfa | ||
|
|
7f23a35155 | ||
|
|
0e521eda2e | ||
|
|
5a72dc8eca | ||
|
|
b11292385f | ||
|
|
2179a5405a | ||
|
|
78f5989cd6 | ||
|
|
cca44c675c | ||
|
|
0ebe65c25b | ||
|
|
f7ee95c4b9 | ||
|
|
d78c05ab62 | ||
|
|
9f41e3341f | ||
|
|
8ab3d482b9 | ||
|
|
a485c3d410 | ||
|
|
38b27d624a | ||
|
|
f437d65d3c | ||
|
|
5ba0764a87 | ||
|
|
69fd6532cc | ||
|
|
ee8bd9f016 | ||
|
|
de5a2d47a5 | ||
|
|
54b2e0285c | ||
|
|
a0e118d411 | ||
|
|
07c33233ee | ||
|
|
962cac902b | ||
|
|
9ff5c9863f | ||
|
|
b60e396241 | ||
|
|
6e567ced92 | ||
|
|
e1c1e9a8b2 | ||
|
|
25b66be84d | ||
|
|
4d6a278137 | ||
|
|
7a77b071a2 | ||
|
|
279c9e71df | ||
|
|
2881916c91 | ||
|
|
f09602363c | ||
|
|
79b37bff0b | ||
|
|
7c549870b5 | ||
|
|
e50b7f41aa | ||
|
|
efc8053027 | ||
|
|
d104a1126f | ||
|
|
a573ef4b1c | ||
|
|
83e8c3fc19 | ||
|
|
cd0ed42941 | ||
|
|
2beca6b322 | ||
|
|
0fc62c3150 | ||
|
|
7daaf3de6a | ||
|
|
6470cbeada | ||
|
|
983bade8c5 | ||
|
|
9d27b9290c | ||
|
|
d9acf64904 | ||
|
|
cc1114de63 | ||
|
|
bff97254d7 | ||
|
|
6355adc6de | ||
|
|
879d9176bd | ||
|
|
a3badd0a83 | ||
|
|
73da736ebb | ||
|
|
c077538015 | ||
|
|
33bcd710fc | ||
|
|
6cf264dc18 | ||
|
|
d50d6db1bd | ||
|
|
d680c72c7c | ||
|
|
49a8c73f72 | ||
|
|
dc00fcaf60 | ||
|
|
b056723b98 | ||
|
|
0e5fc44af3 | ||
|
|
803531125b | ||
|
|
c70ddd559b | ||
|
|
c06d898b00 | ||
|
|
c6233d02e8 | ||
|
|
37e69cad16 | ||
|
|
b14e729b2d | ||
|
|
87e0f2d36c | ||
|
|
ae60135a08 | ||
|
|
3ed2dccbec | ||
|
|
689ee7c1e7 | ||
|
|
12d6d7ef88 | ||
|
|
4f88c5ed29 | ||
|
|
35826dfd14 | ||
|
|
12dc33eabc | ||
|
|
9650aea6a1 | ||
|
|
aaff319e70 | ||
|
|
d9babc37f0 | ||
|
|
a616de7452 | ||
|
|
817d3e1178 | ||
|
|
e353ed1e2e | ||
|
|
96b7210bca | ||
|
|
22a6968a08 | ||
|
|
ce8519c1b1 | ||
|
|
871d9ee0b4 | ||
|
|
11d9f236b9 | ||
|
|
8be6f441dd | ||
|
|
d432092296 | ||
|
|
4d168023a2 | ||
|
|
d4d639dfa2 | ||
|
|
92375078c0 | ||
|
|
fc6efac559 | ||
|
|
a9e1bbd5ab | ||
|
|
dcf6416ae9 | ||
|
|
df6b2ba0cd | ||
|
|
19166e7938 | ||
|
|
3472a2bfbf | ||
|
|
8ac66e888e | ||
|
|
39f2e89c4b | ||
|
|
fa0ea041ad | ||
|
|
46b1981b77 | ||
|
|
29980d69b5 | ||
|
|
3a81eb9552 | ||
|
|
06e8333eab | ||
|
|
8ee0b97e5f | ||
|
|
414756edc4 | ||
|
|
1355958f53 | ||
|
|
425d380d03 | ||
|
|
ff08335890 | ||
|
|
7170e3b232 | ||
|
|
6111eaa9e9 | ||
|
|
e02a9fe61e | ||
|
|
cba9bf5dc4 | ||
|
|
72a661f1fa | ||
|
|
4168000155 | ||
|
|
9d230b4f7c | ||
|
|
745f32faa3 | ||
|
|
112ad886c6 | ||
|
|
8b0ec21a15 | ||
|
|
afce52a0f4 | ||
|
|
7e4757c213 | ||
|
|
d6dbcc8d82 | ||
|
|
fca87a2b8a | ||
|
|
87e648b8b8 | ||
|
|
ada549489c | ||
|
|
15e13de2a6 | ||
|
|
dd74665622 | ||
|
|
ff8fc56696 | ||
|
|
2d8c903533 | ||
|
|
c1606f515b | ||
|
|
fac2702063 | ||
|
|
76ae6958ed | ||
|
|
1876ed7d16 | ||
|
|
08ef4e0de0 | ||
|
|
a48db9d817 | ||
|
|
1334531740 | ||
|
|
d769b16ada | ||
|
|
c830320730 | ||
|
|
336aa0f5df | ||
|
|
754291b34f | ||
|
|
bbae0862b0 | ||
|
|
6b7693b2fd | ||
|
|
954926a05c | ||
|
|
71981f66ec | ||
|
|
7f94f95ac9 | ||
|
|
4ee3177c5d | ||
|
|
9c1f9ca5c6 | ||
|
|
cff4cf4d2c | ||
|
|
ee9d9781ee | ||
|
|
1b972d4adc | ||
|
|
72598479d5 | ||
|
|
02599a4a6e | ||
|
|
af9f351fce | ||
|
|
ff79943776 | ||
|
|
e60048ef30 | ||
|
|
24c0b22038 | ||
|
|
6f32a53742 | ||
|
|
da9d1080d9 | ||
|
|
2ea4d7913e | ||
|
|
16999e3707 | ||
|
|
5c53b847dc | ||
|
|
3afd763d16 | ||
|
|
75a15ed24e | ||
|
|
6d56597a2a | ||
|
|
5872222213 | ||
|
|
bd5c73fd7b | ||
|
|
d8a32dcf69 | ||
|
|
87cd90ab5d | ||
|
|
cb5b0c5b5e | ||
|
|
2fa16101f4 | ||
|
|
6dd5c30b49 | ||
|
|
72f5a572eb | ||
|
|
d501d8cb28 | ||
|
|
35c4b4ff5b | ||
|
|
f3e8ac5b8e | ||
|
|
ab2bcd84c6 | ||
|
|
cdf7b013a9 | ||
|
|
eeba0467a1 | ||
|
|
43ca72bf7e | ||
|
|
aa9e279026 | ||
|
|
9f3917830d | ||
|
|
c458bc2ee3 | ||
|
|
e0455629d7 | ||
|
|
b802dcba8d | ||
|
|
7ff868e94c | ||
|
|
44bd3e3d74 | ||
|
|
9d793ce1df | ||
|
|
d8dee8fc91 | ||
|
|
3c52acb825 | ||
|
|
cb195be6ad | ||
|
|
08f7bed679 | ||
|
|
744563c7a7 | ||
|
|
5d48801645 | ||
|
|
4211686c07 | ||
|
|
98379c9642 | ||
|
|
a3c9d35a13 | ||
|
|
5a7abc0a92 | ||
|
|
ade73ec159 | ||
|
|
6f7a5d9320 |
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: github-pr-reviewer
|
||||
description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR.
|
||||
description: Reviews GitHub pull requests and provides feedback comments.
|
||||
disallowedTools: Write, Edit
|
||||
---
|
||||
|
||||
# Review GitHub Pull Request
|
||||
@@ -195,6 +195,7 @@ GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_R
|
||||
# Create PR (gh pr create pushes the branch automatically)
|
||||
gh pr create --repo home-assistant/core --base dev \
|
||||
--head "$GITHUB_USER:$BRANCH" \
|
||||
--draft \
|
||||
--title "TITLE_HERE" \
|
||||
--body "$(cat <<'EOF'
|
||||
BODY_HERE
|
||||
|
||||
@@ -3,54 +3,27 @@ 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
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## Integration Templates
|
||||
## General guidelines
|
||||
|
||||
### 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
|
||||
```
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- 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.
|
||||
|
||||
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
|
||||
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:
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
### 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
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
@@ -61,726 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons
|
||||
- `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
|
||||
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
- Reauthentication/reconfigure flows
|
||||
|
||||
### 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
|
||||
```
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
|
||||
@@ -3,17 +3,4 @@
|
||||
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
|
||||
|
||||
@@ -8,29 +8,6 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
- 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
|
||||
@@ -41,15 +18,4 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
- `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
|
||||
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -11,10 +11,9 @@
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
## Code Review Guidelines
|
||||
## Git Commit Guidelines
|
||||
|
||||
**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
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
18
.github/workflows/builder.yml
vendored
18
.github/workflows/builder.yml
vendored
@@ -47,10 +47,6 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
|
||||
@@ -112,7 +108,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -123,7 +119,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -342,19 +338,19 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -503,7 +499,7 @@ jobs:
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -527,7 +523,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -1392,7 +1392,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1563,7 +1563,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1591,7 +1591,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -87,6 +87,13 @@ repos:
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
|
||||
- id: gen_copilot_instructions
|
||||
name: gen_copilot_instructions
|
||||
entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$
|
||||
- id: hassfest
|
||||
name: hassfest
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest
|
||||
|
||||
@@ -174,6 +174,7 @@ homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dropbox.*
|
||||
homeassistant.components.droplet.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
@@ -331,6 +332,7 @@ homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.liebherr.*
|
||||
homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
homeassistant.components.linkplay.*
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
## Code Review Guidelines
|
||||
## Git Commit Guidelines
|
||||
|
||||
**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
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
43
CODEOWNERS
generated
43
CODEOWNERS
generated
@@ -37,6 +37,13 @@ build.json @home-assistant/supervisor
|
||||
# Other code
|
||||
/homeassistant/scripts/check_config.py @kellerza
|
||||
|
||||
# Agent Configurations
|
||||
AGENTS.md @home-assistant/core
|
||||
CLAUDE.md @home-assistant/core
|
||||
/.agent/ @home-assistant/core
|
||||
/.claude/ @home-assistant/core
|
||||
/.gemini/ @home-assistant/core
|
||||
|
||||
# Integrations
|
||||
/homeassistant/components/abode/ @shred86
|
||||
/tests/components/abode/ @shred86
|
||||
@@ -401,6 +408,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dremel_3d_printer/ @tkdrob
|
||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/homeassistant/components/dropbox/ @bdr99
|
||||
/tests/components/dropbox/ @bdr99
|
||||
/homeassistant/components/droplet/ @sarahseidman
|
||||
/tests/components/droplet/ @sarahseidman
|
||||
/homeassistant/components/dsmr/ @Robbie1221
|
||||
@@ -409,6 +418,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/homeassistant/components/duckdns/ @tr4nt0r
|
||||
/tests/components/duckdns/ @tr4nt0r
|
||||
/homeassistant/components/duco/ @ronaldvdmeer
|
||||
/tests/components/duco/ @ronaldvdmeer
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
/tests/components/duotecno/ @cereal2nd
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
|
||||
@@ -478,8 +489,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
|
||||
/tests/components/epic_games_store/ @hacf-fr @Quentame
|
||||
/homeassistant/components/epic_games_store/ @Quentame
|
||||
/tests/components/epic_games_store/ @Quentame
|
||||
/homeassistant/components/epion/ @lhgravendeel
|
||||
/tests/components/epion/ @lhgravendeel
|
||||
/homeassistant/components/epson/ @pszafer
|
||||
@@ -494,6 +505,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/essent/ @jaapp
|
||||
/homeassistant/components/eufylife_ble/ @bdr99
|
||||
/tests/components/eufylife_ble/ @bdr99
|
||||
/homeassistant/components/eurotronic_cometblue/ @rikroe
|
||||
/tests/components/eurotronic_cometblue/ @rikroe
|
||||
/homeassistant/components/event/ @home-assistant/core
|
||||
/tests/components/event/ @home-assistant/core
|
||||
/homeassistant/components/evohome/ @zxdavb
|
||||
@@ -553,8 +566,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/fortios/ @kimfrellsen
|
||||
/homeassistant/components/foscam/ @Foscam-wangzhengyu
|
||||
/tests/components/foscam/ @Foscam-wangzhengyu
|
||||
/homeassistant/components/freebox/ @hacf-fr @Quentame
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame
|
||||
/tests/components/freebox/ @hacf-fr/reviewers @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/freshr/ @SierraNL
|
||||
@@ -1044,8 +1057,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/met/ @danielhiversen
|
||||
/homeassistant/components/met_eireann/ @DylanGore
|
||||
/tests/components/met_eireann/ @DylanGore
|
||||
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
|
||||
/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
|
||||
/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
|
||||
/homeassistant/components/meteo_lt/ @xE1H
|
||||
/tests/components/meteo_lt/ @xE1H
|
||||
/homeassistant/components/meteoalarm/ @rolfberkenbosch
|
||||
@@ -1137,8 +1150,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/netatmo/ @cgtobi
|
||||
/tests/components/netatmo/ @cgtobi
|
||||
/homeassistant/components/netdata/ @fabaff
|
||||
/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG
|
||||
/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG
|
||||
/homeassistant/components/netgear/ @Quentame @starkillerOG
|
||||
/tests/components/netgear/ @Quentame @starkillerOG
|
||||
/homeassistant/components/netgear_lte/ @tkdrob
|
||||
/tests/components/netgear_lte/ @tkdrob
|
||||
/homeassistant/components/network/ @home-assistant/core
|
||||
@@ -1254,8 +1267,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/openuv/ @bachya
|
||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/homeassistant/components/opnsense/ @mtreinish
|
||||
/tests/components/opnsense/ @mtreinish
|
||||
/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2
|
||||
/tests/components/opnsense/ @HarlemSquirrel @Snuffy2
|
||||
/homeassistant/components/opower/ @tronikos
|
||||
/tests/components/opower/ @tronikos
|
||||
/homeassistant/components/oralb/ @bdraco @Lash-L
|
||||
@@ -1299,6 +1312,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl @codesalatdev
|
||||
/tests/components/picnic/ @corneyl @codesalatdev
|
||||
/homeassistant/components/picotts/ @rooggiieerr
|
||||
/tests/components/picotts/ @rooggiieerr
|
||||
/homeassistant/components/ping/ @jpbede
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
@@ -1679,8 +1694,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/syncthing/ @zhulik
|
||||
/homeassistant/components/syncthru/ @nielstron
|
||||
/tests/components/syncthru/ @nielstron
|
||||
/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185
|
||||
/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185
|
||||
/homeassistant/components/synology_dsm/ @Quentame @mib1185
|
||||
/tests/components/synology_dsm/ @Quentame @mib1185
|
||||
/homeassistant/components/synology_srm/ @aerialls
|
||||
/homeassistant/components/system_bridge/ @timmo001
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
@@ -1813,6 +1828,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/unifi_access/ @imhotep @RaHehl
|
||||
/tests/components/unifi_access/ @imhotep @RaHehl
|
||||
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
|
||||
/homeassistant/components/unifi_discovery/ @RaHehl
|
||||
/tests/components/unifi_discovery/ @RaHehl
|
||||
/homeassistant/components/unifiled/ @florisvdk
|
||||
/homeassistant/components/unifiprotect/ @RaHehl
|
||||
/tests/components/unifiprotect/ @RaHehl
|
||||
@@ -1864,6 +1881,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_ble/ @rajlaud
|
||||
/tests/components/victron_ble/ @rajlaud
|
||||
/homeassistant/components/victron_gx/ @tomer-w
|
||||
/tests/components/victron_gx/ @tomer-w
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
||||
@@ -470,7 +470,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
translation.async_setup(hass)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
device_registry.async_get(hass)
|
||||
device_registry.async_setup(hass)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifi_discovery",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "victron",
|
||||
"name": "Victron",
|
||||
"integrations": ["victron_ble", "victron_remote_monitoring"]
|
||||
"integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""The Actron Air integration."""
|
||||
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAPIError,
|
||||
ActronAirAuthError,
|
||||
)
|
||||
from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError
|
||||
from actron_neo_api.models.system import ActronAirSystemInfo
|
||||
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -25,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
"""Set up Actron Air integration from a config entry."""
|
||||
|
||||
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
|
||||
systems: list[ActronAirACSystem] = []
|
||||
systems: list[ActronAirSystemInfo] = []
|
||||
|
||||
try:
|
||||
systems = await api.get_ac_systems()
|
||||
@@ -36,14 +32,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_connection_error",
|
||||
) from err
|
||||
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||
for system in systems:
|
||||
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
|
||||
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
|
||||
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
system_coordinators[system["serial"]] = coordinator
|
||||
system_coordinators[system.serial] = coordinator
|
||||
|
||||
entry.runtime_data = ActronAirRuntimeData(
|
||||
api=api,
|
||||
|
||||
@@ -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, handle_actron_api_errors
|
||||
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -136,19 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
"""Return the target temperature."""
|
||||
return self._status.user_aircon_settings.temperature_setpoint_cool_c
|
||||
|
||||
@handle_actron_api_errors
|
||||
@actron_air_command
|
||||
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
|
||||
@actron_air_command
|
||||
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
|
||||
@actron_air_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
@@ -212,13 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
"""Return the target temperature."""
|
||||
return self._zone.temperature_setpoint_cool_c
|
||||
|
||||
@handle_actron_api_errors
|
||||
@actron_air_command
|
||||
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
|
||||
@actron_air_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
|
||||
|
||||
@@ -38,10 +38,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.error("OAuth2 flow failed: %s", err)
|
||||
return self.async_abort(reason="oauth2_error")
|
||||
|
||||
self._device_code = device_code_response["device_code"]
|
||||
self._user_code = device_code_response["user_code"]
|
||||
self._verification_uri = device_code_response["verification_uri_complete"]
|
||||
self._expires_minutes = str(device_code_response["expires_in"] // 60)
|
||||
self._device_code = device_code_response.device_code
|
||||
self._user_code = device_code_response.user_code
|
||||
self._verification_uri = device_code_response.verification_uri_complete
|
||||
self._expires_minutes = str(device_code_response.expires_in // 60)
|
||||
|
||||
async def _wait_for_authorization() -> None:
|
||||
"""Wait for the user to authorize the device."""
|
||||
|
||||
@@ -6,12 +6,12 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAPIError,
|
||||
ActronAirAuthError,
|
||||
ActronAirStatus,
|
||||
)
|
||||
from actron_neo_api.models.system import ActronAirSystemInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -38,7 +38,7 @@ class ActronAirRuntimeData:
|
||||
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
||||
|
||||
|
||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
|
||||
"""System coordinator for Actron Air integration."""
|
||||
|
||||
def __init__(
|
||||
@@ -46,7 +46,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
hass: HomeAssistant,
|
||||
entry: ActronAirConfigEntry,
|
||||
api: ActronAirAPI,
|
||||
system: ActronAirACSystem,
|
||||
system: ActronAirSystemInfo,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -57,7 +57,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
config_entry=entry,
|
||||
)
|
||||
self.system = system
|
||||
self.serial_number = system["serial"]
|
||||
self.serial_number = system.serial
|
||||
self.api = api
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
|
||||
35
homeassistant/components/actron_air/diagnostics.py
Normal file
35
homeassistant/components/actron_air/diagnostics.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Diagnostics support for Actron Air."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ActronAirConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: ActronAirConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinators: dict[int, Any] = {}
|
||||
for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()):
|
||||
coordinators[idx] = {
|
||||
"system": async_redact_data(
|
||||
coordinator.system.model_dump(mode="json"), TO_REDACT
|
||||
),
|
||||
"status": async_redact_data(
|
||||
coordinator.data.model_dump(mode="json", exclude={"last_known_state"}),
|
||||
TO_REDACT,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"coordinators": coordinators,
|
||||
}
|
||||
@@ -14,10 +14,14 @@ from .const import DOMAIN
|
||||
from .coordinator import ActronAirSystemCoordinator
|
||||
|
||||
|
||||
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
|
||||
def actron_air_command[_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."""
|
||||
"""Decorator for Actron Air API calls.
|
||||
|
||||
Handles ActronAirAPIError exceptions, and requests a coordinator update
|
||||
to update the status of the devices as soon as possible.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
@@ -30,6 +34,7 @@ def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
self.coordinator.async_set_updated_data(self.coordinator.data)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
"requirements": ["actron-neo-api==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
|
||||
@@ -54,17 +54,11 @@ rules:
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not use entity categories.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not use entity device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Not required for this integration at this stage.
|
||||
entity-category: done
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"setup_connection_error": {
|
||||
"message": "Failed to connect to the Actron Air API"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
|
||||
}
|
||||
|
||||
@@ -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, handle_actron_api_errors
|
||||
from .entity import ActronAirAcEntity, actron_air_command
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -105,12 +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
|
||||
@actron_air_command
|
||||
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
|
||||
@actron_air_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.entity_description.set_fn(self.coordinator, False)
|
||||
|
||||
@@ -74,7 +74,8 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
mime_type=attachment.get("media_content_type")
|
||||
or image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
@@ -89,7 +90,7 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=media.mime_type,
|
||||
mime_type=attachment.get("media_content_type") or media.mime_type,
|
||||
path=media.path,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,14 +33,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
from .coordinator import (
|
||||
AirOSConfigEntry,
|
||||
AirOSDataUpdateCoordinator,
|
||||
AirOSFirmwareUpdateCoordinator,
|
||||
AirOSRuntimeData,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SENSOR,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -86,10 +93,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
|
||||
airos_device = airos_class(**conn_data)
|
||||
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
data_coordinator = AirOSDataUpdateCoordinator(
|
||||
hass, entry, device_data, airos_device
|
||||
)
|
||||
await data_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None
|
||||
if device_data["fw_major"] >= 8:
|
||||
firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device)
|
||||
await firmware_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = AirOSRuntimeData(
|
||||
status=data_coordinator,
|
||||
firmware=firmware_coordinator,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the AirOS binary sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data.status
|
||||
|
||||
entities = [
|
||||
AirOSBinarySensor(coordinator, description)
|
||||
|
||||
@@ -31,7 +31,9 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the AirOS button from a config entry."""
|
||||
async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)])
|
||||
async_add_entities(
|
||||
[AirOSRebootButton(config_entry.runtime_data.status, REBOOT_BUTTON)]
|
||||
)
|
||||
|
||||
|
||||
class AirOSRebootButton(AirOSEntity, ButtonEntity):
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
||||
DOMAIN = "airos"
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
UPDATE_SCAN_INTERVAL = timedelta(days=1)
|
||||
|
||||
MANUFACTURER = "Ubiquiti"
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from airos.airos6 import AirOS6, AirOS6Data
|
||||
from airos.airos8 import AirOS8, AirOS8Data
|
||||
@@ -19,20 +22,61 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .const import DOMAIN, SCAN_INTERVAL, UPDATE_SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
AirOSDataDetect = AirOS8Data | AirOS6Data
|
||||
type AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
type AirOSDataDetect = AirOS8Data | AirOS6Data
|
||||
type AirOSUpdateData = dict[str, Any]
|
||||
|
||||
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
|
||||
type AirOSConfigEntry = ConfigEntry[AirOSRuntimeData]
|
||||
|
||||
T = TypeVar("T", bound=AirOSDataDetect | AirOSUpdateData)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirOSRuntimeData:
|
||||
"""Data for AirOS config entry."""
|
||||
|
||||
status: AirOSDataUpdateCoordinator
|
||||
firmware: AirOSFirmwareUpdateCoordinator | None
|
||||
|
||||
|
||||
async def async_fetch_airos_data(
|
||||
airos_device: AirOSDeviceDetect,
|
||||
update_method: Callable[[], Awaitable[T]],
|
||||
) -> T:
|
||||
"""Fetch data from AirOS device."""
|
||||
try:
|
||||
await airos_device.login()
|
||||
return await update_method()
|
||||
except AirOSConnectionAuthenticationError as err:
|
||||
_LOGGER.exception("Error authenticating with airOS device")
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
_LOGGER.error("Error connecting to airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except AirOSDataMissingError as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_data_missing",
|
||||
) from err
|
||||
|
||||
|
||||
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
"""Class to manage fetching AirOS data from single endpoint."""
|
||||
"""Class to manage fetching AirOS status data from single endpoint."""
|
||||
|
||||
airos_device: AirOSDeviceDetect
|
||||
config_entry: AirOSConfigEntry
|
||||
|
||||
def __init__(
|
||||
@@ -54,28 +98,33 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AirOSDataDetect:
|
||||
"""Fetch data from AirOS."""
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
return await self.airos_device.status()
|
||||
except AirOSConnectionAuthenticationError as err:
|
||||
_LOGGER.exception("Error authenticating with airOS device")
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
_LOGGER.error("Error connecting to airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except AirOSDataMissingError as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_data_missing",
|
||||
) from err
|
||||
"""Fetch status data from AirOS."""
|
||||
return await async_fetch_airos_data(self.airos_device, self.airos_device.status)
|
||||
|
||||
|
||||
class AirOSFirmwareUpdateCoordinator(DataUpdateCoordinator[AirOSUpdateData]):
|
||||
"""Class to manage fetching AirOS firmware."""
|
||||
|
||||
config_entry: AirOSConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirOSConfigEntry,
|
||||
airos_device: AirOSDeviceDetect,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.airos_device = airos_device
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AirOSUpdateData:
|
||||
"""Fetch firmware data from AirOS."""
|
||||
return await async_fetch_airos_data(
|
||||
self.airos_device, self.airos_device.update_check
|
||||
)
|
||||
|
||||
@@ -29,5 +29,15 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
|
||||
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
|
||||
"data": {
|
||||
"status_data": async_redact_data(
|
||||
entry.runtime_data.status.data.to_dict(), TO_REDACT_AIROS
|
||||
),
|
||||
"firmware_data": async_redact_data(
|
||||
entry.runtime_data.firmware.data
|
||||
if entry.runtime_data.firmware is not None
|
||||
else {},
|
||||
TO_REDACT_AIROS,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the AirOS sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data.status
|
||||
|
||||
entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS]
|
||||
|
||||
|
||||
@@ -206,6 +206,12 @@
|
||||
},
|
||||
"reboot_failed": {
|
||||
"message": "The device did not accept the reboot request. Try again, or check your device web interface for errors."
|
||||
},
|
||||
"update_connection_authentication_error": {
|
||||
"message": "Authentication or connection failed during firmware update"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "Connection failed during firmware update"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
homeassistant/components/airos/update.py
Normal file
101
homeassistant/components/airos/update.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""AirOS update component for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.exceptions import AirOSConnectionAuthenticationError, AirOSException
|
||||
|
||||
from homeassistant.components.update import (
|
||||
UpdateDeviceClass,
|
||||
UpdateEntity,
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
AirOSConfigEntry,
|
||||
AirOSDataUpdateCoordinator,
|
||||
AirOSFirmwareUpdateCoordinator,
|
||||
)
|
||||
from .entity import AirOSEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirOSConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the AirOS update entity from a config entry."""
|
||||
runtime_data = config_entry.runtime_data
|
||||
|
||||
if runtime_data.firmware is None: # Unsupported device
|
||||
return
|
||||
async_add_entities([AirOSUpdateEntity(runtime_data.status, runtime_data.firmware)])
|
||||
|
||||
|
||||
class AirOSUpdateEntity(AirOSEntity, UpdateEntity):
|
||||
"""Update entity for AirOS firmware updates."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status: AirOSDataUpdateCoordinator,
|
||||
firmware: AirOSFirmwareUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the AirOS update entity."""
|
||||
super().__init__(status)
|
||||
self.status = status
|
||||
self.firmware = firmware
|
||||
|
||||
self._attr_unique_id = f"{status.data.derived.mac}_firmware_update"
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Return the installed firmware version."""
|
||||
return self.status.data.host.fwversion
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Return the latest firmware version."""
|
||||
if not self.firmware.data.get("update", False):
|
||||
return self.status.data.host.fwversion
|
||||
return self.firmware.data.get("version")
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
"""Return the release url of the latest firmware."""
|
||||
return self.firmware.data.get("changelog")
|
||||
|
||||
async def async_install(
|
||||
self,
|
||||
version: str | None,
|
||||
backup: bool,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Handle the firmware update installation."""
|
||||
_LOGGER.debug("Starting firmware update")
|
||||
try:
|
||||
await self.status.airos_device.login()
|
||||
await self.status.airos_device.download()
|
||||
await self.status.airos_device.install()
|
||||
except AirOSConnectionAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_connection_authentication_error",
|
||||
) from err
|
||||
except AirOSException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
) from err
|
||||
@@ -54,7 +54,16 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_LOGIN_DATA],
|
||||
)
|
||||
self.previous_devices: set[str] = set()
|
||||
device_registry = dr.async_get(hass)
|
||||
self.previous_devices: set[str] = {
|
||||
identifier
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
if device.entry_type != dr.DeviceEntryType.SERVICE
|
||||
for identifier_domain, identifier in device.identifiers
|
||||
if identifier_domain == DOMAIN
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.3.2"]
|
||||
"requirements": ["aioamazondevices==13.4.0"]
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"tracked_apps": "Apps",
|
||||
"tracked_custom_integrations": "Custom integrations",
|
||||
"tracked_custom_integrations": "Community integrations",
|
||||
"tracked_integrations": "Integrations"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_apps": "Select the apps you want to track",
|
||||
"tracked_custom_integrations": "Select the custom integrations you want to track",
|
||||
"tracked_custom_integrations": "Select the community integrations you want to track",
|
||||
"tracked_integrations": "Select the integrations you want to track"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
|
||||
},
|
||||
"custom_integrations": {
|
||||
"name": "{custom_integration_domain} (custom)",
|
||||
"name": "{custom_integration_domain} (community)",
|
||||
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
|
||||
},
|
||||
"total_active_installations": {
|
||||
|
||||
@@ -92,6 +92,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
_LOGGER.debug("Updating statistics for the first time")
|
||||
usage_sum = 0.0
|
||||
last_stats_time = None
|
||||
allow_update_last_stored_hour = False
|
||||
else:
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
@@ -107,6 +108,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
_LOGGER.debug("Getting statistics at %s", start)
|
||||
stats: dict[str, list[Any]] = {}
|
||||
for end in (start + timedelta(seconds=1), None):
|
||||
stats = await get_instance(self.hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
@@ -127,15 +129,28 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"Not found, trying to find oldest statistic after %s",
|
||||
start,
|
||||
)
|
||||
assert stats
|
||||
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
if not stats or not stats.get(usage_statistic_id):
|
||||
_LOGGER.debug(
|
||||
"Could not find existing statistics during period lookup for %s, "
|
||||
"falling back to last stored statistic",
|
||||
usage_statistic_id,
|
||||
)
|
||||
allow_update_last_stored_hour = True
|
||||
last_records = last_stat[usage_statistic_id]
|
||||
usage_sum = float(last_records[0].get("sum") or 0.0)
|
||||
last_stats_time = last_records[0]["start"]
|
||||
else:
|
||||
allow_update_last_stored_hour = False
|
||||
records = stats[usage_statistic_id]
|
||||
|
||||
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
|
||||
last_stats_time = stats[usage_statistic_id][0]["start"]
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
|
||||
usage_sum = _safe_get_sum(records)
|
||||
last_stats_time = records[0]["start"]
|
||||
|
||||
usage_statistics = []
|
||||
|
||||
@@ -148,7 +163,13 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||
if last_stats_time is not None and (
|
||||
start.timestamp() < last_stats_time
|
||||
or (
|
||||
start.timestamp() == last_stats_time
|
||||
and not allow_update_last_stored_hour
|
||||
)
|
||||
):
|
||||
continue
|
||||
usage_state = max(0, read["consumption"] / 1000)
|
||||
usage_sum = max(0, read["read"])
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import anthropic
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -24,12 +20,11 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Anthropic."""
|
||||
@@ -39,29 +34,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Set up Anthropic from a config entry."""
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=entry.data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
)
|
||||
try:
|
||||
await client.models.list(timeout=10.0)
|
||||
except anthropic.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
coordinator = AnthropicCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
LOGGER.debug("Available models: %s", coordinator.data)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import anthropic
|
||||
@@ -48,10 +47,12 @@ from .const import (
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -65,8 +66,11 @@ from .const import (
|
||||
DOMAIN,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import model_alias
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -101,34 +105,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
await client.models.list(timeout=10.0)
|
||||
|
||||
|
||||
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id != "claude-3-haiku-20240307"
|
||||
and model_info.id[-2:-1] != "-"
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
@@ -225,6 +201,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
|
||||
options: dict[str, Any]
|
||||
model_info: anthropic.types.ModelInfo
|
||||
|
||||
@property
|
||||
def _is_new(self) -> bool:
|
||||
@@ -338,15 +315,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage advanced options."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
|
||||
step_schema: VolDictType = {
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
default=DEFAULT[CONF_CHAT_MODEL],
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=await self._get_model_list(), custom_value=True
|
||||
)
|
||||
SelectSelectorConfig(options=self._get_model_list(), custom_value=True)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
@@ -356,11 +332,40 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
CONF_TEMPERATURE,
|
||||
default=DEFAULT[CONF_TEMPERATURE],
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_PROMPT_CACHING,
|
||||
default=DEFAULT[CONF_PROMPT_CACHING],
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[x.value for x in PromptCaching],
|
||||
translation_key=CONF_PROMPT_CACHING,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
self.options.update(user_input)
|
||||
|
||||
coordinator = self._get_entry().runtime_data
|
||||
self.model_info, status = coordinator.get_model_info(
|
||||
self.options[CONF_CHAT_MODEL]
|
||||
)
|
||||
if not status:
|
||||
# Couldn't find the model in the cached list, try to fetch it directly
|
||||
client = coordinator.client
|
||||
try:
|
||||
self.model_info = await client.models.retrieve(
|
||||
self.options[CONF_CHAT_MODEL], timeout=10.0
|
||||
)
|
||||
except anthropic.NotFoundError:
|
||||
errors[CONF_CHAT_MODEL] = "model_not_found"
|
||||
except anthropic.AnthropicError as err:
|
||||
errors[CONF_CHAT_MODEL] = "api_error"
|
||||
description_placeholders["message"] = (
|
||||
err.message if isinstance(err, anthropic.APIError) else str(err)
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return await self.async_step_model()
|
||||
|
||||
@@ -370,6 +375,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
vol.Schema(step_schema), self.options
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_model(
|
||||
@@ -454,6 +460,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
|
||||
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
|
||||
|
||||
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(
|
||||
CONF_TOOL_SEARCH,
|
||||
default=DEFAULT[CONF_TOOL_SEARCH],
|
||||
)
|
||||
] = bool
|
||||
else:
|
||||
self.options.pop(CONF_TOOL_SEARCH, None)
|
||||
|
||||
if not step_schema:
|
||||
user_input = {}
|
||||
|
||||
@@ -489,13 +505,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
http_client=get_async_client(self.hass),
|
||||
)
|
||||
return await get_model_list(client)
|
||||
coordinator = self._get_entry().runtime_data
|
||||
return [
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias(model_info.id),
|
||||
)
|
||||
for model_info in coordinator.data or []
|
||||
]
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Constants for the Anthropic integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
DOMAIN = "anthropic"
|
||||
@@ -13,9 +14,11 @@ CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_CODE_EXECUTION = "code_execution"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
CONF_PROMPT_CACHING = "prompt_caching"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
CONF_THINKING_EFFORT = "thinking_effort"
|
||||
CONF_TOOL_SEARCH = "tool_search"
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
|
||||
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
|
||||
@@ -24,20 +27,31 @@ CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
|
||||
class PromptCaching(StrEnum):
|
||||
"""Prompt caching options."""
|
||||
|
||||
OFF = "off"
|
||||
PROMPT = "prompt"
|
||||
AUTOMATIC = "automatic"
|
||||
|
||||
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_CODE_EXECUTION: False,
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT: "low",
|
||||
CONF_TOOL_SEARCH: False,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
}
|
||||
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
|
||||
NON_THINKING_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
@@ -81,6 +95,11 @@ PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3",
|
||||
"claude-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
115
homeassistant/components/anthropic/coordinator.py
Normal file
115
homeassistant/components/anthropic/coordinator.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Coordinator for the Anthropic integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import anthropic
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12)
|
||||
UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
|
||||
|
||||
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
|
||||
|
||||
|
||||
_model_short_form = re.compile(r"[^\d]-\d$")
|
||||
|
||||
|
||||
@callback
|
||||
def model_alias(model_id: str) -> str:
|
||||
"""Resolve alias from versioned model name."""
|
||||
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
|
||||
return model_id
|
||||
if model_id[-2:-1] != "-":
|
||||
model_id = model_id[:-9]
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
return model_id
|
||||
|
||||
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
|
||||
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
|
||||
|
||||
client: anthropic.AsyncAnthropic
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.title,
|
||||
update_interval=UPDATE_INTERVAL_CONNECTED,
|
||||
update_method=self.async_update_data,
|
||||
always_update=False,
|
||||
)
|
||||
self.client = anthropic.AsyncAnthropic(
|
||||
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None:
|
||||
"""Manually update data, notify listeners and update refresh interval."""
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
super().async_set_updated_data(data)
|
||||
|
||||
async def async_update_data(self) -> list[anthropic.types.ModelInfo]:
|
||||
"""Fetch data from the API."""
|
||||
try:
|
||||
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
|
||||
result = await self.client.models.list(timeout=10.0)
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
except anthropic.APITimeoutError as err:
|
||||
raise TimeoutError(err.message or str(err)) from err
|
||||
except anthropic.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.APIError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
return result.data
|
||||
|
||||
def mark_connection_error(self) -> None:
|
||||
"""Mark the connection as having an error and reschedule background check."""
|
||||
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
|
||||
if self.last_update_success:
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
if self._listeners and not self.hass.is_stopping:
|
||||
self._schedule_refresh()
|
||||
|
||||
@callback
|
||||
def get_model_info(self, model_id: str) -> tuple[anthropic.types.ModelInfo, bool]:
|
||||
"""Get model info for a given model ID."""
|
||||
# First try: exact name match
|
||||
for model in self.data or []:
|
||||
if model.id == model_id:
|
||||
return model, True
|
||||
# Second try: match by alias
|
||||
alias = model_alias(model_id)
|
||||
for model in self.data or []:
|
||||
if model_alias(model.id) == alias:
|
||||
return model, True
|
||||
# Model not found, return safe defaults
|
||||
return anthropic.types.ModelInfo(
|
||||
type="model",
|
||||
id=model_id,
|
||||
created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC),
|
||||
display_name=alias,
|
||||
), False
|
||||
@@ -58,6 +58,8 @@ from anthropic.types import (
|
||||
ToolChoiceAutoParam,
|
||||
ToolChoiceToolParam,
|
||||
ToolParam,
|
||||
ToolSearchToolBm25_20251119Param,
|
||||
ToolSearchToolResultBlock,
|
||||
ToolUnionParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
@@ -74,6 +76,9 @@ from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.tool_search_tool_result_block_param import (
|
||||
Content as ToolSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
@@ -82,19 +87,20 @@ from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -110,7 +116,9 @@ from .const import (
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
@@ -202,7 +210,7 @@ class ContentDetails:
|
||||
]
|
||||
|
||||
|
||||
def _convert_content(
|
||||
def _convert_content( # noqa: C901
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> tuple[list[MessageParam], str | None]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
@@ -255,6 +263,15 @@ def _convert_content(
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "tool_search":
|
||||
tool_result_block = {
|
||||
"type": "tool_search_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
ToolSearchToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
else:
|
||||
tool_result_block = {
|
||||
"type": "tool_result",
|
||||
@@ -385,6 +402,7 @@ def _convert_content(
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
"tool_search_tool_bm25",
|
||||
],
|
||||
tool_call.tool_name,
|
||||
),
|
||||
@@ -397,6 +415,7 @@ def _convert_content(
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
"tool_search_tool_bm25",
|
||||
]
|
||||
else ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
@@ -558,6 +577,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
ToolSearchToolResultBlock,
|
||||
),
|
||||
):
|
||||
if content_details:
|
||||
@@ -658,7 +678,7 @@ def _create_token_stats(
|
||||
}
|
||||
|
||||
|
||||
class AnthropicBaseLLMEntity(Entity):
|
||||
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
"""Anthropic base LLM entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -666,18 +686,24 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(entry.runtime_data)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
coordinator = entry.runtime_data
|
||||
self.model_info, _ = coordinator.get_model_info(
|
||||
subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
)
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
|
||||
model=self.model_info.display_name,
|
||||
model_id=self.model_info.id,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
async def _async_handle_chat_log( # noqa: C901
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
structure_name: str | None = None,
|
||||
@@ -687,21 +713,20 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
preloaded_tools = [
|
||||
"HassTurnOn",
|
||||
"HassTurnOff",
|
||||
"GetLiveContext",
|
||||
"code_execution",
|
||||
"web_search",
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="system_message_not_found"
|
||||
)
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=system.content,
|
||||
cache_control={"type": "ephemeral"},
|
||||
)
|
||||
]
|
||||
|
||||
messages, container_id = _convert_content(chat_log.content[1:])
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
@@ -710,11 +735,28 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
system=system_prompt,
|
||||
system=system.content,
|
||||
stream=True,
|
||||
container=container_id,
|
||||
)
|
||||
|
||||
if (
|
||||
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
|
||||
== PromptCaching.PROMPT
|
||||
):
|
||||
model_args["system"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": system.content,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
elif (
|
||||
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
|
||||
== PromptCaching.AUTOMATIC
|
||||
):
|
||||
model_args["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
|
||||
thinking_effort = options.get(
|
||||
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
|
||||
@@ -873,11 +915,27 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
),
|
||||
)
|
||||
)
|
||||
preloaded_tools.append(structure_name)
|
||||
|
||||
if tools:
|
||||
if (
|
||||
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
|
||||
and len(tools) > len(preloaded_tools) + 1
|
||||
):
|
||||
for tool in tools:
|
||||
if not tool["name"].endswith(tuple(preloaded_tools)):
|
||||
tool["defer_loading"] = True
|
||||
tools.append(
|
||||
ToolSearchToolBm25_20251119Param(
|
||||
type="tool_search_tool_bm25_20251119",
|
||||
name="tool_search_tool_bm25",
|
||||
)
|
||||
)
|
||||
|
||||
model_args["tools"] = tools
|
||||
|
||||
client = self.entry.runtime_data
|
||||
coordinator = self.entry.runtime_data
|
||||
client = coordinator.client
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(max_iterations):
|
||||
@@ -899,13 +957,25 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
)
|
||||
messages.extend(new_messages)
|
||||
except anthropic.AuthenticationError as err:
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
|
||||
await coordinator.async_request_refresh()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.APIConnectionError as err:
|
||||
LOGGER.info("Connection error while talking to Anthropic: %s", err)
|
||||
coordinator.mark_connection_error()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
# Non-connection error, mark connection as healthy
|
||||
coordinator.async_set_updated_data(coordinator.data)
|
||||
LOGGER.error("Error while talking to Anthropic: %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
@@ -917,6 +987,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
coordinator.async_set_updated_data(coordinator.data)
|
||||
break
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.92.0"]
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ rules:
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
@@ -81,7 +81,10 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities explicitly set `_attr_name` to `None`, so entity name translations are not used.
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anthropic
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
@@ -18,8 +19,8 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelectorConfig,
|
||||
)
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
|
||||
from .coordinator import model_alias
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -58,10 +59,10 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
if entry.entry_id in self._model_list_cache:
|
||||
model_list = self._model_list_cache[entry.entry_id]
|
||||
else:
|
||||
client = entry.runtime_data
|
||||
client = entry.runtime_data.client
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
for model_option in await self.get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
@@ -107,6 +108,22 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
},
|
||||
)
|
||||
|
||||
async def get_model_list(
|
||||
self, client: anthropic.AsyncAnthropic
|
||||
) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
models = (await client.models.list(timeout=10.0)).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
return [
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias(model_info.id),
|
||||
)
|
||||
for model_info in models
|
||||
]
|
||||
|
||||
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
|
||||
"""Yield entry/subentry pairs that use deprecated models."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"error": {
|
||||
"api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]",
|
||||
"model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure AI task",
|
||||
"user": "Add AI task"
|
||||
@@ -47,11 +51,13 @@
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
@@ -72,6 +78,7 @@
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
@@ -80,6 +87,7 @@
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
|
||||
@@ -94,6 +102,10 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
"error": {
|
||||
"api_error": "Unable to get model info: {message}",
|
||||
"model_not_found": "Model not found"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure conversation agent",
|
||||
"user": "Add conversation agent"
|
||||
@@ -103,11 +115,13 @@
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"prompt_caching": "Caching strategy",
|
||||
"temperature": "Temperature"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"max_tokens": "Limit the number of response tokens.",
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage.",
|
||||
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
@@ -132,6 +146,7 @@
|
||||
"code_execution": "Code execution",
|
||||
"thinking_budget": "Thinking budget",
|
||||
"thinking_effort": "Thinking effort",
|
||||
"tool_search": "Enable tool search tool",
|
||||
"user_location": "Include home location",
|
||||
"web_search": "Enable web search",
|
||||
"web_search_max_uses": "Maximum web searches"
|
||||
@@ -140,6 +155,7 @@
|
||||
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
|
||||
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
|
||||
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
|
||||
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
|
||||
"user_location": "Localize search results based on home location",
|
||||
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
|
||||
"web_search_max_uses": "Limit the number of searches performed per response"
|
||||
@@ -210,6 +226,13 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"prompt_caching": {
|
||||
"options": {
|
||||
"automatic": "Full",
|
||||
"off": "Disabled",
|
||||
"prompt": "System prompt"
|
||||
}
|
||||
},
|
||||
"thinking_effort": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.8.2"],
|
||||
"requirements": ["arcam-fmj==1.8.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -91,6 +91,7 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
and vp.colorspace is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"services": {
|
||||
"announce": {
|
||||
"description": "Lets a satellite announce a message.",
|
||||
"description": "Lets an Assist satellite announce a message.",
|
||||
"fields": {
|
||||
"media_id": {
|
||||
"description": "The media ID to announce instead of using text-to-speech.",
|
||||
@@ -94,10 +94,10 @@
|
||||
"name": "Preannounce media ID"
|
||||
}
|
||||
},
|
||||
"name": "Announce"
|
||||
"name": "Announce on satellite"
|
||||
},
|
||||
"ask_question": {
|
||||
"description": "Asks a question and gets the user's response.",
|
||||
"description": "Lets an Assist satellite ask a question and get the user's response.",
|
||||
"fields": {
|
||||
"answers": {
|
||||
"description": "Possible answers to the question.",
|
||||
@@ -124,10 +124,10 @@
|
||||
"name": "Question media ID"
|
||||
}
|
||||
},
|
||||
"name": "Ask question"
|
||||
"name": "Ask question on satellite"
|
||||
},
|
||||
"start_conversation": {
|
||||
"description": "Starts a conversation from a satellite.",
|
||||
"description": "Starts a conversation from an Assist satellite.",
|
||||
"fields": {
|
||||
"extra_system_prompt": {
|
||||
"description": "Provide background information to the AI about the request.",
|
||||
@@ -150,13 +150,13 @@
|
||||
"name": "Message"
|
||||
}
|
||||
},
|
||||
"name": "Start conversation"
|
||||
"name": "Start conversation on satellite"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"description": "Triggers after one or more Assist satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -165,7 +165,7 @@
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"description": "Triggers after one or more Assist satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -174,7 +174,7 @@
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"description": "Triggers after one or more Assist satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -183,7 +183,7 @@
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
|
||||
@@ -7,9 +7,9 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aurorapy.client import AuroraError, AuroraSerialClient
|
||||
import serial.tools.list_ports
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -57,9 +57,11 @@ def validate_and_connect(
|
||||
return ret
|
||||
|
||||
|
||||
def scan_comports() -> tuple[list[str] | None, str | None]:
|
||||
async def async_scan_comports(
|
||||
hass: HomeAssistant,
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Find and store available com ports for the GUI dropdown."""
|
||||
com_ports = serial.tools.list_ports.comports(include_links=True)
|
||||
com_ports = await usb.async_scan_serial_ports(hass)
|
||||
com_ports_list = []
|
||||
for port in com_ports:
|
||||
com_ports_list.append(port.device)
|
||||
@@ -87,7 +89,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
errors = {}
|
||||
if self._com_ports_list is None:
|
||||
result = await self.hass.async_add_executor_job(scan_comports)
|
||||
result = await async_scan_comports(self.hass)
|
||||
self._com_ports_list, self._default_com_port = result
|
||||
if self._default_com_port is None:
|
||||
return self.async_abort(reason="no_serial_ports")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Aurora ABB PowerOne Solar PV",
|
||||
"codeowners": ["@davet2001"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -8,7 +8,7 @@ from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DEFAULT_HOST
|
||||
@@ -31,8 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) ->
|
||||
try:
|
||||
await api.connect()
|
||||
except InvalidAuth as err:
|
||||
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
|
||||
raise ConfigEntryError(
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed, please check credentials"
|
||||
) from err
|
||||
except CannotConnect as err:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
@@ -39,12 +40,39 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Autoskope."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _async_validate_credentials(
|
||||
self, host: str, username: str, password: str, errors: dict[str, str]
|
||||
) -> bool:
|
||||
"""Validate credentials against the Autoskope API."""
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=password,
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -63,18 +91,9 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(f"{username}@{host}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
if await self._async_validate_credentials(
|
||||
host, username, user_input[CONF_PASSWORD], errors
|
||||
):
|
||||
return self.async_create_entry(
|
||||
title=f"Autoskope ({username})",
|
||||
data={
|
||||
@@ -87,3 +106,35 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initiation of re-authentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication with new credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if await self._async_validate_credentials(
|
||||
reauth_entry.data[CONF_HOST],
|
||||
reauth_entry.data[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
errors,
|
||||
):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -39,10 +39,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Reauthentication flow removed for initial PR, will be added in follow-up.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,6 +11,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The new password for your Autoskope account."
|
||||
},
|
||||
"description": "Please re-enter your password for your Autoskope account."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==67"],
|
||||
"requirements": ["axis==68"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"message": "Storage account {account_name} not found"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can not connect to storage account {account_name}"
|
||||
"message": "Cannot connect to storage account {account_name}"
|
||||
},
|
||||
"container_not_found": {
|
||||
"message": "Storage container {container_name} not found"
|
||||
|
||||
@@ -74,6 +74,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except exception.BadRequest as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bad_request",
|
||||
translation_placeholders={"error_message": str(err)},
|
||||
) from err
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -101,8 +101,7 @@ def handle_b2_errors[T](
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except B2Error as err:
|
||||
error_msg = f"Failed during {func.__name__}"
|
||||
raise BackupAgentError(error_msg) from err
|
||||
raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -170,8 +169,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
async def _cleanup_failed_upload(self, filename: str) -> None:
|
||||
"""Clean up a partially uploaded file after upload failure."""
|
||||
_LOGGER.warning(
|
||||
"Attempting to delete partially uploaded main backup file %s "
|
||||
"due to metadata upload failure",
|
||||
"Attempting to delete partially uploaded backup file %s",
|
||||
filename,
|
||||
)
|
||||
try:
|
||||
@@ -180,11 +178,10 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
)
|
||||
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
|
||||
except B2Error:
|
||||
_LOGGER.debug(
|
||||
"Failed to clean up partially uploaded main backup file %s. "
|
||||
"Manual intervention may be required to delete it from Backblaze B2",
|
||||
_LOGGER.warning(
|
||||
"Failed to clean up partially uploaded backup file %s;"
|
||||
" manual deletion from Backblaze B2 may be required",
|
||||
filename,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
@@ -256,9 +253,10 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
prefixed_metadata_filename,
|
||||
)
|
||||
|
||||
upload_successful = False
|
||||
tar_uploaded = False
|
||||
try:
|
||||
await self._upload_backup_file(prefixed_tar_filename, open_stream, {})
|
||||
tar_uploaded = True
|
||||
_LOGGER.debug(
|
||||
"Main backup file upload finished for %s", prefixed_tar_filename
|
||||
)
|
||||
@@ -270,15 +268,14 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
_LOGGER.debug(
|
||||
"Metadata file upload finished for %s", prefixed_metadata_filename
|
||||
)
|
||||
upload_successful = True
|
||||
finally:
|
||||
if upload_successful:
|
||||
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
|
||||
self._invalidate_caches(
|
||||
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename)
|
||||
self._invalidate_caches(
|
||||
backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename
|
||||
)
|
||||
except B2Error:
|
||||
if tar_uploaded:
|
||||
await self._cleanup_failed_upload(prefixed_tar_filename)
|
||||
raise
|
||||
|
||||
def _upload_metadata_file_sync(
|
||||
self, metadata_content: bytes, filename: str
|
||||
|
||||
@@ -174,6 +174,14 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
|
||||
)
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except exception.BadRequest as err:
|
||||
_LOGGER.error(
|
||||
"Backblaze B2 API rejected the request for Key ID '%s': %s",
|
||||
user_input[CONF_KEY_ID],
|
||||
err,
|
||||
)
|
||||
errors["base"] = "bad_request"
|
||||
placeholders["error_message"] = str(err)
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["b2sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.10.1"]
|
||||
"requirements": ["b2sdk==2.10.4"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_request": "The Backblaze B2 API rejected the request: {error_message}",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]",
|
||||
@@ -60,6 +61,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"bad_request": {
|
||||
"message": "The Backblaze B2 API rejected the request: {error_message}"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ from . import util
|
||||
from .agent import BackupAgent
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import BackupManager
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
|
||||
|
||||
|
||||
@callback
|
||||
@@ -195,6 +195,11 @@ class UploadBackupView(HomeAssistantView):
|
||||
backup_id = await manager.async_receive_backup(
|
||||
contents=contents, agent_ids=agent_ids
|
||||
)
|
||||
except InvalidBackupFilename as err:
|
||||
return Response(
|
||||
body=str(err),
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except OSError as err:
|
||||
return Response(
|
||||
body=f"Can't write backup file: {err}",
|
||||
|
||||
@@ -68,6 +68,7 @@ from .models import (
|
||||
BackupReaderWriterError,
|
||||
BaseBackup,
|
||||
Folder,
|
||||
InvalidBackupFilename,
|
||||
)
|
||||
from .store import BackupStore
|
||||
from .util import (
|
||||
@@ -1006,6 +1007,14 @@ class BackupManager:
|
||||
) -> str:
|
||||
"""Receive and store a backup file from upload."""
|
||||
contents.chunk_size = BUF_SIZE
|
||||
suggested_filename = contents.filename or "backup.tar"
|
||||
safe_filename = PureWindowsPath(suggested_filename).name
|
||||
if (
|
||||
not safe_filename
|
||||
or safe_filename != suggested_filename
|
||||
or safe_filename == ".."
|
||||
):
|
||||
raise InvalidBackupFilename(f"Invalid filename: {suggested_filename}")
|
||||
self.async_on_backup_event(
|
||||
ReceiveBackupEvent(
|
||||
reason=None,
|
||||
@@ -1016,7 +1025,7 @@ class BackupManager:
|
||||
written_backup = await self._reader_writer.async_receive_backup(
|
||||
agent_ids=agent_ids,
|
||||
stream=contents,
|
||||
suggested_filename=contents.filename or "backup.tar",
|
||||
suggested_filename=suggested_filename,
|
||||
)
|
||||
self.async_on_backup_event(
|
||||
ReceiveBackupEvent(
|
||||
@@ -1957,10 +1966,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
suggested_filename: str,
|
||||
) -> WrittenBackup:
|
||||
"""Receive a backup."""
|
||||
safe_filename = PureWindowsPath(suggested_filename).name
|
||||
if not safe_filename or safe_filename == "..":
|
||||
safe_filename = "backup.tar"
|
||||
temp_file = Path(self.temp_backup_dir, safe_filename)
|
||||
temp_file = Path(self.temp_backup_dir, suggested_filename)
|
||||
|
||||
async_add_executor_job = self._hass.async_add_executor_job
|
||||
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
|
||||
"requirements": ["cronsim==2.7", "securetar==2026.4.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ class BackupReaderWriterError(BackupError):
|
||||
error_code = "backup_reader_writer_error"
|
||||
|
||||
|
||||
class InvalidBackupFilename(BackupManagerError):
|
||||
"""Raised when a backup filename is invalid."""
|
||||
|
||||
error_code = "invalid_backup_filename"
|
||||
|
||||
|
||||
class BackupNotFound(BackupAgentError, BackupManagerError):
|
||||
"""Raised when a backup is not found."""
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from securetar import (
|
||||
SecureTarFile,
|
||||
SecureTarReadError,
|
||||
SecureTarRootKeyContext,
|
||||
get_archive_max_ciphertext_size,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -383,9 +384,12 @@ def _encrypt_backup(
|
||||
if prefix not in expected_archives:
|
||||
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
|
||||
continue
|
||||
output_archive.import_tar(
|
||||
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
|
||||
)
|
||||
if (fileobj := input_tar.extractfile(obj)) is None:
|
||||
LOGGER.debug(
|
||||
"Non regular inner tar file %s will not be encrypted", obj.name
|
||||
)
|
||||
continue
|
||||
output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx)
|
||||
inner_tar_idx += 1
|
||||
|
||||
|
||||
@@ -419,7 +423,7 @@ class _CipherBackupStreamer:
|
||||
hass: HomeAssistant,
|
||||
backup: AgentBackup,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
password: str | None,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._workers: list[_CipherWorkerStatus] = []
|
||||
@@ -431,7 +435,9 @@ class _CipherBackupStreamer:
|
||||
|
||||
def size(self) -> int:
|
||||
"""Return the maximum size of the decrypted or encrypted backup."""
|
||||
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
|
||||
return get_archive_max_ciphertext_size(
|
||||
self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files()
|
||||
)
|
||||
|
||||
def _num_tar_files(self) -> int:
|
||||
"""Return the number of inner tar files."""
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.11.1"
|
||||
"dbus-fast==4.0.4",
|
||||
"habluetooth==6.0.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
|
||||
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
|
||||
"title": "The BMW Connected Drive integration has been removed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,14 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
PRESET_BUTTON = BondButtonEntityDescription(
|
||||
key=Action.PRESET,
|
||||
name="Preset",
|
||||
translation_key="preset",
|
||||
mutually_exclusive=None,
|
||||
argument=None,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -285,6 +293,8 @@ async def async_setup_entry(
|
||||
# we only add the stop action button if we add actions
|
||||
# since its not so useful if there are no actions to stop
|
||||
device_entities.append(BondButtonEntity(data, device, STOP_BUTTON))
|
||||
if device.has_action(PRESET_BUTTON.key):
|
||||
device_entities.append(BondButtonEntity(data, device, PRESET_BUTTON))
|
||||
entities.extend(device_entities)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["brother==6.0.0"],
|
||||
"requirements": ["brother==6.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "brother*",
|
||||
|
||||
@@ -10,6 +10,7 @@ from bsblan import (
|
||||
BSBLAN,
|
||||
BSBLANAuthError,
|
||||
BSBLANConnectionError,
|
||||
BSBLANError,
|
||||
HotWaterConfig,
|
||||
HotWaterSchedule,
|
||||
HotWaterState,
|
||||
@@ -50,7 +51,7 @@ class BSBLanFastData:
|
||||
|
||||
state: State
|
||||
sensor: Sensor
|
||||
dhw: HotWaterState
|
||||
dhw: HotWaterState | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,7 +112,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
# This reduces response time significantly (~0.2s per parameter)
|
||||
state = await self.client.state(include=STATE_INCLUDE)
|
||||
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
|
||||
except BSBLANAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -126,6 +126,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
translation_placeholders={"host": host},
|
||||
) from err
|
||||
|
||||
# Fetch DHW state separately - device may not support hot water
|
||||
dhw: HotWaterState | None = None
|
||||
try:
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
except BSBLANError:
|
||||
# Preserve last known DHW state if available (entity may depend on it)
|
||||
if self.data:
|
||||
dhw = self.data.dhw
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) state not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
return BSBLanFastData(
|
||||
state=state,
|
||||
sensor=sensor,
|
||||
@@ -159,13 +172,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
|
||||
dhw_schedule = await self.client.hot_water_schedule()
|
||||
|
||||
except AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
except (BSBLANConnectionError, BSBLANAuthError) as err:
|
||||
# If config update fails, keep existing data
|
||||
LOGGER.debug(
|
||||
@@ -177,6 +183,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
return self.data
|
||||
# First fetch failed, return empty data
|
||||
return BSBLanSlowData()
|
||||
except BSBLANError, AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
|
||||
return BSBLanSlowData(
|
||||
dhw_config=dhw_config,
|
||||
|
||||
@@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"fast_coordinator_data": {
|
||||
"state": data.fast_coordinator.data.state.model_dump(),
|
||||
"sensor": data.fast_coordinator.data.sensor.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump()
|
||||
if data.fast_coordinator.data.dhw
|
||||
else None,
|
||||
},
|
||||
"static": data.static.model_dump() if data.static is not None else None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -10,7 +13,7 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import BSBLanData
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
|
||||
|
||||
@@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
|
||||
"""Initialize BSBLan entity with device info."""
|
||||
super().__init__(coordinator)
|
||||
host = coordinator.config_entry.data["host"]
|
||||
host = coordinator.config_entry.data[CONF_HOST]
|
||||
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
|
||||
mac = data.device.MAC
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
@@ -44,7 +48,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
else None
|
||||
),
|
||||
sw_version=data.device.version,
|
||||
configuration_url=f"http://{host}",
|
||||
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.3"],
|
||||
"requirements": ["python-bsblan==5.1.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bsblan import BSBLANError, SetHotWaterParam
|
||||
from bsblan import BSBLANError, HotWaterState, SetHotWaterParam
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
@@ -46,8 +46,10 @@ async def async_setup_entry(
|
||||
data = entry.runtime_data
|
||||
|
||||
# Only create water heater entity if DHW (Domestic Hot Water) is available
|
||||
# Check if we have any DHW-related data indicating water heater support
|
||||
dhw_data = data.fast_coordinator.data.dhw
|
||||
if dhw_data is None:
|
||||
# Device does not support DHW, skip water heater setup
|
||||
return
|
||||
if (
|
||||
dhw_data.operating_mode is None
|
||||
and dhw_data.nominal_setpoint is None
|
||||
@@ -107,11 +109,21 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
else:
|
||||
self._attr_max_temp = 65.0 # Default maximum
|
||||
|
||||
@property
|
||||
def _dhw(self) -> HotWaterState:
|
||||
"""Return DHW state data.
|
||||
|
||||
This entity is only created when DHW data is available.
|
||||
"""
|
||||
dhw = self.coordinator.data.dhw
|
||||
assert dhw is not None
|
||||
return dhw
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return current operation."""
|
||||
if (
|
||||
operating_mode := self.coordinator.data.dhw.operating_mode
|
||||
operating_mode := self._dhw.operating_mode
|
||||
) is None or operating_mode.value is None:
|
||||
return None
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
|
||||
@@ -119,16 +131,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if (
|
||||
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
|
||||
) is None:
|
||||
if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None:
|
||||
return None
|
||||
return current_temp.value
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
|
||||
if (target_temp := self._dhw.nominal_setpoint) is None:
|
||||
return None
|
||||
return target_temp.value
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import frontend, http, websocket_api
|
||||
from homeassistant.components.websocket_api import (
|
||||
ERR_INVALID_FORMAT,
|
||||
ERR_NOT_FOUND,
|
||||
ERR_NOT_SUPPORTED,
|
||||
ActiveConnection,
|
||||
@@ -33,6 +34,7 @@ from homeassistant.core import (
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
@@ -76,6 +78,7 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = datetime.timedelta(seconds=60)
|
||||
EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds
|
||||
|
||||
# Don't support rrules more often than daily
|
||||
VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
|
||||
@@ -320,6 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_create)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_delete)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_update)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_subscribe)
|
||||
|
||||
component.async_register_entity_service(
|
||||
CREATE_EVENT_SERVICE,
|
||||
@@ -517,6 +521,17 @@ class CalendarEntity(Entity):
|
||||
_entity_component_unrecorded_attributes = frozenset({"description"})
|
||||
|
||||
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
|
||||
_event_listeners: (
|
||||
list[
|
||||
tuple[
|
||||
datetime.datetime,
|
||||
datetime.datetime,
|
||||
Callable[[list[JsonValueType] | None], None],
|
||||
]
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
_event_listener_debouncer: Debouncer[None] | None = None
|
||||
|
||||
_attr_initial_color: str | None
|
||||
|
||||
@@ -585,6 +600,10 @@ class CalendarEntity(Entity):
|
||||
the current or upcoming event.
|
||||
"""
|
||||
super()._async_write_ha_state()
|
||||
|
||||
# Notify websocket subscribers of event changes (debounced)
|
||||
if self._event_listeners and self._event_listener_debouncer:
|
||||
self._event_listener_debouncer.async_schedule_call()
|
||||
if self._alarm_unsubs is None:
|
||||
self._alarm_unsubs = []
|
||||
_LOGGER.debug(
|
||||
@@ -625,6 +644,13 @@ class CalendarEntity(Entity):
|
||||
event.end_datetime_local,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_cancel_event_listener_debouncer(self) -> None:
|
||||
"""Cancel and clear the event listener debouncer."""
|
||||
if self._event_listener_debouncer:
|
||||
self._event_listener_debouncer.async_cancel()
|
||||
self._event_listener_debouncer = None
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass.
|
||||
|
||||
@@ -633,6 +659,90 @@ class CalendarEntity(Entity):
|
||||
for unsub in self._alarm_unsubs or ():
|
||||
unsub()
|
||||
self._alarm_unsubs = None
|
||||
self._async_cancel_event_listener_debouncer()
|
||||
|
||||
@final
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
event_listener: Callable[[list[JsonValueType] | None], None],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Subscribe to calendar event updates.
|
||||
|
||||
Called by websocket API.
|
||||
"""
|
||||
if self._event_listeners is None:
|
||||
self._event_listeners = []
|
||||
|
||||
if self._event_listener_debouncer is None:
|
||||
self._event_listener_debouncer = Debouncer(
|
||||
self.hass,
|
||||
_LOGGER,
|
||||
cooldown=EVENT_LISTENER_DEBOUNCE_COOLDOWN,
|
||||
immediate=True,
|
||||
function=self.async_update_event_listeners,
|
||||
)
|
||||
|
||||
listener_data = (start_date, end_date, event_listener)
|
||||
self._event_listeners.append(listener_data)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
if self._event_listeners:
|
||||
self._event_listeners.remove(listener_data)
|
||||
if not self._event_listeners:
|
||||
self._async_cancel_event_listener_debouncer()
|
||||
|
||||
return unsubscribe
|
||||
|
||||
@final
|
||||
@callback
|
||||
def async_update_event_listeners(self) -> None:
|
||||
"""Push updated calendar events to all listeners."""
|
||||
if not self._event_listeners:
|
||||
return
|
||||
|
||||
for start_date, end_date, listener in self._event_listeners:
|
||||
self.async_update_single_event_listener(start_date, end_date, listener)
|
||||
|
||||
@final
|
||||
@callback
|
||||
def async_update_single_event_listener(
|
||||
self,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
listener: Callable[[list[JsonValueType] | None], None],
|
||||
) -> None:
|
||||
"""Schedule an event fetch and push to a single listener."""
|
||||
self.hass.async_create_task(
|
||||
self._async_update_listener(start_date, end_date, listener)
|
||||
)
|
||||
|
||||
async def _async_update_listener(
|
||||
self,
|
||||
start_date: datetime.datetime,
|
||||
end_date: datetime.datetime,
|
||||
listener: Callable[[list[JsonValueType] | None], None],
|
||||
) -> None:
|
||||
"""Fetch events and push to a single listener."""
|
||||
try:
|
||||
events = await self.async_get_events(self.hass, start_date, end_date)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.debug(
|
||||
"Error fetching calendar events for %s: %s",
|
||||
self.entity_id,
|
||||
err,
|
||||
)
|
||||
listener(None)
|
||||
return
|
||||
|
||||
event_list: list[JsonValueType] = [
|
||||
dataclasses.asdict(event, dict_factory=_list_events_dict_factory)
|
||||
for event in events
|
||||
]
|
||||
listener(event_list)
|
||||
|
||||
async def async_get_events(
|
||||
self,
|
||||
@@ -867,6 +977,65 @@ async def handle_calendar_event_update(
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "calendar/event/subscribe",
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
vol.Required("start"): cv.datetime,
|
||||
vol.Required("end"): cv.datetime,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def handle_calendar_event_subscribe(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Subscribe to calendar event updates."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
ERR_NOT_FOUND,
|
||||
f"Calendar entity not found: {entity_id}",
|
||||
)
|
||||
return
|
||||
|
||||
start_date = dt_util.as_local(msg["start"])
|
||||
end_date = dt_util.as_local(msg["end"])
|
||||
|
||||
if start_date >= end_date:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
ERR_INVALID_FORMAT,
|
||||
"Start must be before end",
|
||||
)
|
||||
return
|
||||
|
||||
subscription_id = msg["id"]
|
||||
|
||||
@callback
|
||||
def event_listener(events: list[JsonValueType] | None) -> None:
|
||||
"""Push updated calendar events to websocket."""
|
||||
if subscription_id not in connection.subscriptions:
|
||||
return
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
subscription_id,
|
||||
{
|
||||
"events": events,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
connection.subscriptions[subscription_id] = entity.async_subscribe_events(
|
||||
start_date, end_date, event_listener
|
||||
)
|
||||
connection.send_result(subscription_id)
|
||||
|
||||
# Push initial events only to the new subscriber
|
||||
entity.async_update_single_event_listener(start_date, end_date, event_listener)
|
||||
|
||||
|
||||
def _validate_timespan(
|
||||
values: dict[str, Any],
|
||||
) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]:
|
||||
|
||||
@@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -21,7 +25,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the binary sensor platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)])
|
||||
async_add_entities(
|
||||
[
|
||||
CasperGlowPausedBinarySensor(entry.runtime_data),
|
||||
CasperGlowChargingBinarySensor(entry.runtime_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
|
||||
@@ -46,6 +55,34 @@ class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity):
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.is_paused is not None:
|
||||
if state.is_paused is not None and state.is_paused != self._attr_is_on:
|
||||
self._attr_is_on = state.is_paused
|
||||
self.async_write_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class CasperGlowChargingBinarySensor(CasperGlowEntity, BinarySensorEntity):
|
||||
"""Binary sensor indicating whether the Casper Glow is charging."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the charging binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_charging"
|
||||
if coordinator.device.state.is_charging is not None:
|
||||
self._attr_is_on = coordinator.device.state.is_charging
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.is_charging is not None and state.is_charging != self._attr_is_on:
|
||||
self._attr_is_on = state.is_charging
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -51,18 +51,24 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: No applicable device classes for binary_sensor, button, light, or select entities.
|
||||
entity-disabled-by-default: todo
|
||||
comment: Each config entry represents a single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: No user-configurable settings in the configuration flow.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not register repair issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Each config entry represents a single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
134
homeassistant/components/casper_glow/sensor.py
Normal file
134
homeassistant/components/casper_glow/sensor.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Casper Glow integration sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform for Casper Glow."""
|
||||
async_add_entities(
|
||||
[
|
||||
CasperGlowBatterySensor(entry.runtime_data),
|
||||
CasperGlowDimmingEndTimeSensor(entry.runtime_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
|
||||
"""Sensor entity for Casper Glow battery level."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the battery sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_battery"
|
||||
if coordinator.device.state.battery_level is not None:
|
||||
self._attr_native_value = coordinator.device.state.battery_level.percentage
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.battery_level is not None:
|
||||
new_value = state.battery_level.percentage
|
||||
if new_value != self._attr_native_value:
|
||||
self._attr_native_value = new_value
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity):
|
||||
"""Sensor entity for Casper Glow dimming end time."""
|
||||
|
||||
_attr_translation_key = "dimming_end_time"
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the dimming end time sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = (
|
||||
f"{format_mac(coordinator.device.address)}_dimming_end_time"
|
||||
)
|
||||
self._is_paused = False
|
||||
self._projected_end_time = ignore_variance(
|
||||
self._calculate_end_time,
|
||||
timedelta(minutes=1, seconds=30),
|
||||
)
|
||||
self._update_from_state(coordinator.device.state)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_end_time(remaining_ms: int) -> datetime:
|
||||
"""Calculate projected dimming end time from remaining milliseconds."""
|
||||
return utcnow() + timedelta(milliseconds=remaining_ms)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
def _reset_projected_end_time(self) -> None:
|
||||
"""Clear the projected end time and reset the variance filter."""
|
||||
self._attr_native_value = None
|
||||
self._projected_end_time = ignore_variance(
|
||||
self._calculate_end_time,
|
||||
timedelta(minutes=1, seconds=30),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_state(self, state: GlowState) -> None:
|
||||
"""Update entity attributes from device state."""
|
||||
if state.is_paused is not None:
|
||||
self._is_paused = state.is_paused
|
||||
|
||||
if self._is_paused:
|
||||
self._reset_projected_end_time()
|
||||
return
|
||||
|
||||
remaining_ms = state.dimming_time_remaining_ms
|
||||
if not remaining_ms:
|
||||
if remaining_ms == 0 or state.is_on is False:
|
||||
self._reset_projected_end_time()
|
||||
return
|
||||
self._attr_native_value = self._projected_end_time(remaining_ms)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
self._update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
@@ -44,6 +44,11 @@
|
||||
"dimming_time": {
|
||||
"name": "Dimming time"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"dimming_end_time": {
|
||||
"name": "Dimming end time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -44,10 +44,10 @@
|
||||
},
|
||||
"services": {
|
||||
"show_lovelace_view": {
|
||||
"description": "Shows a dashboard view on a Chromecast device.",
|
||||
"description": "Shows a dashboard view on a Google Cast device.",
|
||||
"fields": {
|
||||
"dashboard_path": {
|
||||
"description": "The URL path of the dashboard to show, defaults to lovelace if not specified.",
|
||||
"description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.",
|
||||
"name": "Dashboard path"
|
||||
},
|
||||
"entity_id": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "View path"
|
||||
}
|
||||
},
|
||||
"name": "Show dashboard view"
|
||||
"name": "Show dashboard view via Google Cast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
await self.async_set_unique_id(str(user.player_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=user.name, data=user_input)
|
||||
return self.async_create_entry(
|
||||
title=user.name or user.username, data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
|
||||
},
|
||||
"low_temp_higher_than_high_temp": {
|
||||
"message": "'Lower target temperature' can not be higher than 'Upper target temperature'."
|
||||
"message": "'Lower target temperature' cannot be higher than 'Upper target temperature'."
|
||||
},
|
||||
"missing_target_temperature_entity_feature": {
|
||||
"message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it."
|
||||
|
||||
@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
|
||||
if self._area.human_status == AlarmAreaState.UNKNOWN:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
|
||||
if code != str(self.coordinator.api.device_pin):
|
||||
return
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[DISABLE]
|
||||
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
|
||||
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[AWAY]
|
||||
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
|
||||
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[HOME]
|
||||
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
|
||||
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[NIGHT]
|
||||
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""Support for sensors."""
|
||||
"""Support for binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from aiocomelit.api import ComelitVedoZoneObject
|
||||
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
|
||||
from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -23,12 +26,68 @@ from .utils import new_device_listener
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ComelitBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Comelit binary sensor entity description."""
|
||||
|
||||
object_type: str
|
||||
is_on_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool]
|
||||
available_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] = (
|
||||
lambda obj: True
|
||||
)
|
||||
|
||||
|
||||
BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = (
|
||||
ComelitBinarySensorEntityDescription(
|
||||
key="anomaly",
|
||||
translation_key="anomaly",
|
||||
object_type=ALARM_AREA,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly,
|
||||
available_fn=lambda obj: (
|
||||
cast(ComelitVedoAreaObject, obj).human_status != AlarmAreaState.UNKNOWN
|
||||
),
|
||||
),
|
||||
ComelitBinarySensorEntityDescription(
|
||||
key="presence",
|
||||
translation_key="motion",
|
||||
object_type=ALARM_ZONE,
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda obj: cast(ComelitVedoZoneObject, obj).status_api == "0001",
|
||||
available_fn=lambda obj: (
|
||||
cast(ComelitVedoZoneObject, obj).human_status
|
||||
not in {
|
||||
AlarmZoneState.FAULTY,
|
||||
AlarmZoneState.UNAVAILABLE,
|
||||
AlarmZoneState.UNKNOWN,
|
||||
}
|
||||
),
|
||||
),
|
||||
ComelitBinarySensorEntityDescription(
|
||||
key="faulty",
|
||||
translation_key="faulty",
|
||||
object_type=ALARM_ZONE,
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=lambda obj: (
|
||||
cast(ComelitVedoZoneObject, obj).human_status == AlarmZoneState.FAULTY
|
||||
),
|
||||
available_fn=lambda obj: (
|
||||
cast(ComelitVedoZoneObject, obj).human_status
|
||||
not in {
|
||||
AlarmZoneState.UNAVAILABLE,
|
||||
AlarmZoneState.UNKNOWN,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ComelitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO presence sensors."""
|
||||
"""Set up Comelit VEDO binary sensors."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
@@ -42,13 +101,23 @@ async def async_setup_entry(
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator,
|
||||
device,
|
||||
config_entry.entry_id,
|
||||
description,
|
||||
)
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if description.object_type == dev_type
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, ALARM_AREA)
|
||||
)
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
|
||||
)
|
||||
@@ -59,42 +128,47 @@ class ComelitVedoBinarySensorEntity(
|
||||
):
|
||||
"""Sensor device."""
|
||||
|
||||
entity_description: ComelitBinarySensorEntityDescription
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.MOTION
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
zone: ComelitVedoZoneObject,
|
||||
object_data: ComelitVedoAreaObject | ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
description: ComelitBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Init sensor entity."""
|
||||
self._zone_index = zone.index
|
||||
self.entity_description = description
|
||||
self._object_index = object_data.index
|
||||
self._object_type = description.object_type
|
||||
super().__init__(coordinator)
|
||||
# Use config_entry.entry_id as base for unique_id
|
||||
# because no serial number or mac is available
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
|
||||
self._attr_unique_id = (
|
||||
f"{config_entry_entry_id}-{description.key}-{self._object_index}"
|
||||
)
|
||||
self._attr_device_info = coordinator.platform_device_info(
|
||||
object_data, "area" if self._object_type == ALARM_AREA else "zone"
|
||||
)
|
||||
|
||||
@property
|
||||
def _zone(self) -> ComelitVedoZoneObject:
|
||||
"""Return zone object."""
|
||||
def _object(self) -> ComelitVedoAreaObject | ComelitVedoZoneObject:
|
||||
"""Return alarm object."""
|
||||
return cast(
|
||||
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
|
||||
ComelitVedoAreaObject | ComelitVedoZoneObject,
|
||||
self.coordinator.data[self._object_type][self._object_index],
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._zone.human_status in [
|
||||
AlarmZoneState.FAULTY,
|
||||
AlarmZoneState.UNAVAILABLE,
|
||||
AlarmZoneState.UNKNOWN,
|
||||
]:
|
||||
"""Return True if object is available."""
|
||||
if not self.entity_description.available_fn(self._object):
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Presence detected."""
|
||||
return self._zone.status_api == "0001"
|
||||
"""Return object binary sensor state."""
|
||||
return self.entity_description.is_on_fn(self._object)
|
||||
|
||||
@@ -65,6 +65,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
)
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
configuration_url=self.api.base_url,
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
model=device,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.2"]
|
||||
}
|
||||
|
||||
@@ -64,6 +64,17 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"anomaly": {
|
||||
"name": "Anomaly"
|
||||
},
|
||||
"faulty": {
|
||||
"name": "Faulty"
|
||||
},
|
||||
"motion": {
|
||||
"name": "Motion"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"thermostat": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -10,8 +10,6 @@ from crownstone_cloud.exceptions import (
|
||||
CrownstoneAuthenticationError,
|
||||
CrownstoneUnknownError,
|
||||
)
|
||||
import serial.tools.list_ports
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -61,9 +59,11 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Set up a Crownstone USB dongle."""
|
||||
list_of_ports = await self.hass.async_add_executor_job(
|
||||
serial.tools.list_ports.comports
|
||||
)
|
||||
list_of_ports = [
|
||||
p
|
||||
for p in await usb.async_scan_serial_ports(self.hass)
|
||||
if isinstance(p, usb.USBDevice)
|
||||
]
|
||||
if self.flow_type == CONFIG_FLOW:
|
||||
ports_as_string = list_ports_as_str(list_of_ports)
|
||||
else:
|
||||
@@ -82,10 +82,8 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
|
||||
else:
|
||||
index = ports_as_string.index(selection) - 1
|
||||
|
||||
selected_port: ListPortInfo = list_of_ports[index]
|
||||
self.usb_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, selected_port.device
|
||||
)
|
||||
selected_port = list_of_ports[index]
|
||||
self.usb_path = selected_port.device
|
||||
return await self.async_step_usb_sphere_config()
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -5,15 +5,14 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.usb import USBDevice
|
||||
|
||||
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
|
||||
|
||||
|
||||
def list_ports_as_str(
|
||||
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
|
||||
serial_ports: Sequence[USBDevice], no_usb_option: bool = True
|
||||
) -> list[str]:
|
||||
"""Represent currently available serial ports as string.
|
||||
|
||||
@@ -31,8 +30,8 @@ def list_ports_as_str(
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None,
|
||||
f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
for port in serial_ports
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "crownstone",
|
||||
"name": "Crownstone",
|
||||
"after_dependencies": ["usb"],
|
||||
"codeowners": ["@Crownstone", "@RicArch97"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/crownstone",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": [
|
||||
@@ -15,7 +15,6 @@
|
||||
"requirements": [
|
||||
"crownstone-cloud==1.4.11",
|
||||
"crownstone-sse==2.0.5",
|
||||
"crownstone-uart==2.1.0",
|
||||
"pyserial==3.5"
|
||||
"crownstone-uart==2.1.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_DEV_ID,
|
||||
ATTR_GPS,
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import final
|
||||
from typing import Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
@@ -33,6 +33,7 @@ from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
@@ -223,6 +224,9 @@ class TrackerEntity(
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -256,6 +260,18 @@ class TrackerEntity(
|
||||
"""Return longitude value of the device."""
|
||||
return self._attr_longitude
|
||||
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
@@ -263,9 +279,7 @@ class TrackerEntity(
|
||||
return self.location_name
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
zone_state = zone.async_active_zone(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
zone_state = self.__active_zone
|
||||
if zone_state is None:
|
||||
state = STATE_NOT_HOME
|
||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||
@@ -278,12 +292,13 @@ class TrackerEntity(
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, StateType] = {}
|
||||
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
attr[ATTR_IN_ZONES] = self.__in_zones or []
|
||||
attr[ATTR_LATITUDE] = self.latitude
|
||||
attr[ATTR_LONGITUDE] = self.longitude
|
||||
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
||||
|
||||
@@ -43,6 +43,7 @@ ATTR_BATTERY: Final = "battery"
|
||||
ATTR_DEV_ID: Final = "dev_id"
|
||||
ATTR_GPS: Final = "gps"
|
||||
ATTR_HOST_NAME: Final = "host_name"
|
||||
ATTR_IN_ZONES: Final = "in_zones"
|
||||
ATTR_LOCATION_NAME: Final = "location_name"
|
||||
ATTR_MAC: Final = "mac"
|
||||
ATTR_SOURCE_TYPE: Final = "source_type"
|
||||
|
||||
64
homeassistant/components/dropbox/__init__.py
Normal file
64
homeassistant/components/dropbox/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""The Dropbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .auth import DropboxConfigEntryAuth
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Set up Dropbox from a config entry."""
|
||||
try:
|
||||
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
|
||||
|
||||
auth = DropboxConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), oauth2_session
|
||||
)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
|
||||
try:
|
||||
await client.get_account_info()
|
||||
except DropboxAuthException as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (DropboxUnknownException, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user