mirror of
https://github.com/home-assistant/core.git
synced 2025-11-30 04:58:01 +00:00
Compare commits
816 Commits
reolink_mo
...
enforce_en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df446f5240 | ||
|
|
c92873bbff | ||
|
|
5fea4915ef | ||
|
|
8fa016059d | ||
|
|
61a29db72c | ||
|
|
5a3aa7874d | ||
|
|
12e2493c42 | ||
|
|
659cd42739 | ||
|
|
7fcea17e83 | ||
|
|
30a85c40da | ||
|
|
57a8f1e0cc | ||
|
|
78aeae577d | ||
|
|
3f95cb37e6 | ||
|
|
12aef4aae5 | ||
|
|
2e12db001d | ||
|
|
573325be97 | ||
|
|
7021fe7495 | ||
|
|
b7999755bd | ||
|
|
99f7a031d6 | ||
|
|
8fc31283b7 | ||
|
|
5ff698c78d | ||
|
|
9469c6ad1c | ||
|
|
35f0505c7b | ||
|
|
a180cabea9 | ||
|
|
4f7348b8bc | ||
|
|
ddf56f053b | ||
|
|
9719d2ef2b | ||
|
|
2afe475234 | ||
|
|
23c304fc75 | ||
|
|
84645d0ca6 | ||
|
|
2bdfc8cf5e | ||
|
|
603e277a5b | ||
|
|
38a7b21052 | ||
|
|
bf74ba990a | ||
|
|
70856bd92a | ||
|
|
be6b624081 | ||
|
|
217fbb2849 | ||
|
|
22a14da19c | ||
|
|
20f5d85800 | ||
|
|
88feb5139b | ||
|
|
90cbe272a0 | ||
|
|
511b739bf6 | ||
|
|
9961a499ee | ||
|
|
d8c7ed473b | ||
|
|
2c30a5a14c | ||
|
|
5e3fc858d8 | ||
|
|
f03af213d4 | ||
|
|
1e3ebd5650 | ||
|
|
53936ab062 | ||
|
|
b52a248def | ||
|
|
ea70229426 | ||
|
|
741a3d5009 | ||
|
|
ee8830cc77 | ||
|
|
7fbf25e862 | ||
|
|
e642cd45ae | ||
|
|
179e1c2b00 | ||
|
|
52a99aea0c | ||
|
|
c7b2f236be | ||
|
|
a6e3da43ca | ||
|
|
4d58024d5d | ||
|
|
c7603b39ec | ||
|
|
c17ee0d123 | ||
|
|
97c1e21a69 | ||
|
|
c9a6b1fd45 | ||
|
|
05ceee568e | ||
|
|
08a6b38699 | ||
|
|
4add346272 | ||
|
|
369c8d1e0d | ||
|
|
25ab47a587 | ||
|
|
617ea1925c | ||
|
|
8bacab4f9c | ||
|
|
6d28b99344 | ||
|
|
bbd1cbf5c9 | ||
|
|
43450d4489 | ||
|
|
f8c052e0ce | ||
|
|
1f3bdfc7b7 | ||
|
|
0652bffd68 | ||
|
|
8322611099 | ||
|
|
134967b817 | ||
|
|
39abae36f0 | ||
|
|
227760f203 | ||
|
|
969809456e | ||
|
|
d2e8a48b2c | ||
|
|
ea6332ee42 | ||
|
|
91c3b43d7f | ||
|
|
1d82d44794 | ||
|
|
571376badc | ||
|
|
32236b2f4d | ||
|
|
18c1953bc5 | ||
|
|
d874c28dc9 | ||
|
|
19d89c8952 | ||
|
|
e3ba1f34ca | ||
|
|
b630fb0520 | ||
|
|
5129f89086 | ||
|
|
0be0e22e76 | ||
|
|
b8500b338a | ||
|
|
4cab3a0465 | ||
|
|
ff711324d5 | ||
|
|
113e7dc003 | ||
|
|
2120ff6a0a | ||
|
|
8ee5c30754 | ||
|
|
a1518b96c4 | ||
|
|
bba7f5c3f0 | ||
|
|
8a5671af76 | ||
|
|
8a18dea8c7 | ||
|
|
4b02f22724 | ||
|
|
7229c2ca2c | ||
|
|
d83eddf13b | ||
|
|
4a192a7b09 | ||
|
|
58c434887e | ||
|
|
78c2405e61 | ||
|
|
8cc4105984 | ||
|
|
917f1e4c6f | ||
|
|
3879f6d2ef | ||
|
|
78060e4833 | ||
|
|
fda66c4be4 | ||
|
|
21131d00b3 | ||
|
|
a84313de33 | ||
|
|
c73346e6b3 | ||
|
|
55a37a2936 | ||
|
|
e481f14335 | ||
|
|
1ca03c8ae9 | ||
|
|
61b43ca1fc | ||
|
|
1b2be083c2 | ||
|
|
4bdf3d6f30 | ||
|
|
43535ede8b | ||
|
|
9bd0762799 | ||
|
|
1bb653b4f7 | ||
|
|
2655edcfc8 | ||
|
|
7a08edc3dd | ||
|
|
b3131355b0 | ||
|
|
06d04c001d | ||
|
|
babecdf32c | ||
|
|
17cd39748b | ||
|
|
c2f1e86a4e | ||
|
|
61a32466b6 | ||
|
|
aef08091f8 | ||
|
|
1416f0f1e0 | ||
|
|
af7b1a76bc | ||
|
|
bf88fcd5bf | ||
|
|
35478e3162 | ||
|
|
69af74a593 | ||
|
|
b4dd912bee | ||
|
|
b5821ef499 | ||
|
|
1a92d4530e | ||
|
|
7b80c1c693 | ||
|
|
e7cc03c1d9 | ||
|
|
69f0b6244a | ||
|
|
01205f8a14 | ||
|
|
68924d23ab | ||
|
|
40f553a007 | ||
|
|
bc46894b74 | ||
|
|
6f4615f012 | ||
|
|
4244d2f66f | ||
|
|
a73dafe097 | ||
|
|
be49296547 | ||
|
|
d55ecd885e | ||
|
|
076248c455 | ||
|
|
13ce27c94c | ||
|
|
4b9b08ece5 | ||
|
|
79df38eff2 | ||
|
|
fb133664e4 | ||
|
|
38669ce96c | ||
|
|
651b33d49b | ||
|
|
3b64db5f76 | ||
|
|
0f95fe566c | ||
|
|
6290facffb | ||
|
|
f0a78aadbe | ||
|
|
345ec97dd5 | ||
|
|
1286b5d9d8 | ||
|
|
26e3caea9a | ||
|
|
2b5f5f641d | ||
|
|
99079d2980 | ||
|
|
2800921a5d | ||
|
|
3268b9ee18 | ||
|
|
02c3cdd5d4 | ||
|
|
f34f17bc24 | ||
|
|
1fb587bf03 | ||
|
|
d8258924f7 | ||
|
|
c05d8aab1c | ||
|
|
e210681751 | ||
|
|
809aced9cc | ||
|
|
977e8adbfb | ||
|
|
c54ce7eabd | ||
|
|
c5f8acfe93 | ||
|
|
8393f17bb3 | ||
|
|
8918b0d7a9 | ||
|
|
c447729ce4 | ||
|
|
12812049ea | ||
|
|
47811e13a6 | ||
|
|
7587fc985f | ||
|
|
716ec1eef2 | ||
|
|
b95af2d86b | ||
|
|
bca7502611 | ||
|
|
1e4fbebf49 | ||
|
|
c9e9575a3d | ||
|
|
f897a728f1 | ||
|
|
0bbb168862 | ||
|
|
0a884c7253 | ||
|
|
58e60fdfac | ||
|
|
33bd35bff4 | ||
|
|
f4b95ff5f1 | ||
|
|
f800248c10 | ||
|
|
d0b2d1dc92 | ||
|
|
85e9919bbd | ||
|
|
51fb1ab8b6 | ||
|
|
066e840e06 | ||
|
|
7031167895 | ||
|
|
69bf79d3bd | ||
|
|
909d950b50 | ||
|
|
51da1bc25a | ||
|
|
f22b623968 | ||
|
|
2bcdc03661 | ||
|
|
10d1affd81 | ||
|
|
91e7b75a44 | ||
|
|
42aaa888a1 | ||
|
|
7b8ebb0803 | ||
|
|
c270ea4e0c | ||
|
|
c93e45c0f2 | ||
|
|
19b773df85 | ||
|
|
9e7c7ec97e | ||
|
|
f735331699 | ||
|
|
5a20ef3f3f | ||
|
|
5ef054f2e0 | ||
|
|
b9fc198a7e | ||
|
|
ad4fae7f59 | ||
|
|
265de91fba | ||
|
|
7322fe40da | ||
|
|
8eb906fad9 | ||
|
|
4d9843172b | ||
|
|
e8a534be9c | ||
|
|
3148719864 | ||
|
|
abfb7afcb7 | ||
|
|
fe4ff4f835 | ||
|
|
cefc8822b6 | ||
|
|
3dc8676b99 | ||
|
|
0f112bb9c4 | ||
|
|
54e5107c34 | ||
|
|
657a068087 | ||
|
|
af6c2b5c8a | ||
|
|
d5a8fa9c5c | ||
|
|
cefde21140 | ||
|
|
160163b0cc | ||
|
|
6ce594539f | ||
|
|
4ca39ec7c3 | ||
|
|
cfdd7fbbce | ||
|
|
1cb36f4c18 | ||
|
|
602c1c64b3 | ||
|
|
3b8d6eb851 | ||
|
|
9bb98eb514 | ||
|
|
39c431c55c | ||
|
|
0171b527d8 | ||
|
|
7cccdf2205 | ||
|
|
97f3bb3da5 | ||
|
|
fc62a6cd89 | ||
|
|
23b90f5984 | ||
|
|
63ac14a19b | ||
|
|
94fd9d1657 | ||
|
|
2f89317fed | ||
|
|
38c7eaf70a | ||
|
|
02e33c3551 | ||
|
|
d5187a6a40 | ||
|
|
703032ab27 | ||
|
|
438aa3486d | ||
|
|
f2944f4d8e | ||
|
|
b8044f60fc | ||
|
|
c67b497f30 | ||
|
|
aefd9c9b41 | ||
|
|
e5d19baf3e | ||
|
|
121239bcf7 | ||
|
|
eff35e93bd | ||
|
|
0cf7952964 | ||
|
|
56f4039ac2 | ||
|
|
6641cb3799 | ||
|
|
c671ff3cf1 | ||
|
|
646ddf9c2d | ||
|
|
95abd69cc6 | ||
|
|
ab0ea753e9 | ||
|
|
9b915e996b | ||
|
|
dc948e3b6c | ||
|
|
8b6205be25 | ||
|
|
7f99cd2d2b | ||
|
|
b4fe6f3843 | ||
|
|
c29879274a | ||
|
|
512449a76d | ||
|
|
fc91047d8d | ||
|
|
6af290eb74 | ||
|
|
dd3d6f116e | ||
|
|
b4af9a31cb | ||
|
|
2862f76fca | ||
|
|
3806e5b65c | ||
|
|
673a2e35ad | ||
|
|
773c25041a | ||
|
|
e494f66c02 | ||
|
|
2833e97625 | ||
|
|
442fb88011 | ||
|
|
6b242fd277 | ||
|
|
06ed452d8f | ||
|
|
a7de947f00 | ||
|
|
dfa3fddd35 | ||
|
|
ce115cbfe1 | ||
|
|
e1d5d312b8 | ||
|
|
27565df86f | ||
|
|
7eaa60b17c | ||
|
|
7bb9936e81 | ||
|
|
ccbc5ed65b | ||
|
|
e98ec38ad8 | ||
|
|
a11e274434 | ||
|
|
f8267b13d7 | ||
|
|
3798e99ac8 | ||
|
|
fa71c40ff5 | ||
|
|
8e6edf5e34 | ||
|
|
c1e32aa9b7 | ||
|
|
b48ebeaa8a | ||
|
|
0c08b4fc8b | ||
|
|
9ae3129f16 | ||
|
|
d38c880c45 | ||
|
|
7ec2e0c524 | ||
|
|
570315687f | ||
|
|
dfd42863bb | ||
|
|
105618734c | ||
|
|
9d0701198f | ||
|
|
f9d5cb957f | ||
|
|
f7d9334445 | ||
|
|
60be2cb168 | ||
|
|
ddf8e0de4b | ||
|
|
7cc6e28916 | ||
|
|
802fcab1c6 | ||
|
|
3534396028 | ||
|
|
458aa3cc22 | ||
|
|
39b64b0af3 | ||
|
|
c66d411826 | ||
|
|
0b383b7493 | ||
|
|
1a3384e8b4 | ||
|
|
2c357265b0 | ||
|
|
c395c77cd3 | ||
|
|
57eceeea38 | ||
|
|
f75ba9172c | ||
|
|
a15d722f0e | ||
|
|
a07531d0e7 | ||
|
|
96d6cacae4 | ||
|
|
766ddfaacc | ||
|
|
5ea6cb3846 | ||
|
|
912c4804cb | ||
|
|
8f13520a1c | ||
|
|
d2d5b29e2b | ||
|
|
94a2642ce9 | ||
|
|
d0060a2b21 | ||
|
|
9b744e2fef | ||
|
|
d66dee5411 | ||
|
|
da97756157 | ||
|
|
a7b2f800f8 | ||
|
|
05831493e2 | ||
|
|
8e685b1626 | ||
|
|
87ecf552dc | ||
|
|
a6e6b6db5a | ||
|
|
d684360ebd | ||
|
|
01a133a2b8 | ||
|
|
b249ae408f | ||
|
|
04b3227b9b | ||
|
|
0a8d117129 | ||
|
|
83f26f7393 | ||
|
|
9ed6f226c6 | ||
|
|
2ba9cb1510 | ||
|
|
2e155831e6 | ||
|
|
756b858840 | ||
|
|
b2520394f4 | ||
|
|
2a97b128c3 | ||
|
|
d06da8c2da | ||
|
|
2bfb09cb11 | ||
|
|
bf733fdec5 | ||
|
|
3b4eb7c749 | ||
|
|
1119716c32 | ||
|
|
0ab23ccb51 | ||
|
|
436fcb7e85 | ||
|
|
4d2f0f2de6 | ||
|
|
82c1751f85 | ||
|
|
a2785a86dc | ||
|
|
741e89383b | ||
|
|
f64533e9e0 | ||
|
|
b13dd4e6ca | ||
|
|
35f310748e | ||
|
|
69d2cd0ac0 | ||
|
|
10c573bbc3 | ||
|
|
93030ad48d | ||
|
|
a7290f92cf | ||
|
|
b47706f360 | ||
|
|
25968925e7 | ||
|
|
fcba1183f8 | ||
|
|
75946065f2 | ||
|
|
3734c4e91d | ||
|
|
7d421bf223 | ||
|
|
41e53297c2 | ||
|
|
d4e7667ea0 | ||
|
|
daa4ddabfe | ||
|
|
8cead00bc7 | ||
|
|
db3090078b | ||
|
|
66e2fd997b | ||
|
|
a102eaf0cd | ||
|
|
f3533dff44 | ||
|
|
c453eed32d | ||
|
|
79a9f34150 | ||
|
|
7442f7af28 | ||
|
|
2e5de732a7 | ||
|
|
9bcd74c449 | ||
|
|
ace18e540b | ||
|
|
65f897793d | ||
|
|
435c08685d | ||
|
|
95f292c43d | ||
|
|
9346c584c3 | ||
|
|
6738085391 | ||
|
|
d9e5bad55e | ||
|
|
f7429f3431 | ||
|
|
46aea5d9dc | ||
|
|
33bde48c9c | ||
|
|
1b73acc025 | ||
|
|
e28965770e | ||
|
|
f9d4bde0f6 | ||
|
|
a493bdc208 | ||
|
|
9ae9ad1e43 | ||
|
|
1b60ea8951 | ||
|
|
313eaff14e | ||
|
|
7dfd68f8c0 | ||
|
|
544fd2a4a6 | ||
|
|
cd51070219 | ||
|
|
3c91c78383 | ||
|
|
96e0d1f5c6 | ||
|
|
2859e7de9b | ||
|
|
88683a318d | ||
|
|
84e9422254 | ||
|
|
fde36d5034 | ||
|
|
8c1e43c07c | ||
|
|
05343392a7 | ||
|
|
32314dbb13 | ||
|
|
8f661fc5cf | ||
|
|
e315cb9859 | ||
|
|
d0e77eb1e2 | ||
|
|
e23cac8bef | ||
|
|
973700542b | ||
|
|
2e21493c19 | ||
|
|
73bed96a0f | ||
|
|
0a5d13f104 | ||
|
|
d16ec81727 | ||
|
|
11564e3df5 | ||
|
|
341d9f15f0 | ||
|
|
2c13c70e12 | ||
|
|
73d0d87705 | ||
|
|
b8dfb2c850 | ||
|
|
cf67a68454 | ||
|
|
b003429912 | ||
|
|
4aff032442 | ||
|
|
da3d8a6332 | ||
|
|
7a5c088149 | ||
|
|
31eec6f471 | ||
|
|
c602a0e279 | ||
|
|
513045e489 | ||
|
|
0db6520802 | ||
|
|
5bc2e271d2 | ||
|
|
77dca49c75 | ||
|
|
1baba8b880 | ||
|
|
875d81cab2 | ||
|
|
956f726ef3 | ||
|
|
fada81e1ce | ||
|
|
6a16424bb4 | ||
|
|
f90a740429 | ||
|
|
3dba7e5bd2 | ||
|
|
8d8ff011fc | ||
|
|
6befd065a1 | ||
|
|
9adf493acd | ||
|
|
a29d5fb56c | ||
|
|
bcb87cf812 | ||
|
|
d01758cea8 | ||
|
|
5487bfe1d9 | ||
|
|
fec65f40fc | ||
|
|
596951ea9f | ||
|
|
75d6b885cf | ||
|
|
3fad76dfa1 | ||
|
|
43d8a151ab | ||
|
|
07110e288d | ||
|
|
ba2aac4614 | ||
|
|
3449dae7a2 | ||
|
|
b8cd3f3635 | ||
|
|
be53ad5449 | ||
|
|
ffd940e07c | ||
|
|
5e31b5ac4f | ||
|
|
81257f9d57 | ||
|
|
ce1678719a | ||
|
|
fc6844b3c9 | ||
|
|
8e82e3aa3a | ||
|
|
3bc68941e6 | ||
|
|
e69b38ab2c | ||
|
|
ed9503324d | ||
|
|
22a06a6c2e | ||
|
|
3b611b9b03 | ||
|
|
79cc3bffc6 | ||
|
|
5c455304a5 | ||
|
|
058f860be7 | ||
|
|
ef319c966d | ||
|
|
adc4e9fdc1 | ||
|
|
40a00fb790 | ||
|
|
0926b16095 | ||
|
|
308c89af4a | ||
|
|
b0c2a47288 | ||
|
|
c446cce2cc | ||
|
|
e02267ad89 | ||
|
|
36381e6753 | ||
|
|
6533562f4e | ||
|
|
1bc6ea98ce | ||
|
|
bab34b844b | ||
|
|
ad3dac0373 | ||
|
|
c5d93e5456 | ||
|
|
ef9b46dce5 | ||
|
|
6f3ceb83c2 | ||
|
|
589577a04c | ||
|
|
cb21bb6542 | ||
|
|
ad64139b8e | ||
|
|
9ae0cfc7e5 | ||
|
|
dffaf49eca | ||
|
|
4add783108 | ||
|
|
421251308f | ||
|
|
cce878213f | ||
|
|
664441eaec | ||
|
|
d4686a3cce | ||
|
|
6e92247799 | ||
|
|
f5355c833e | ||
|
|
add9f4c5ab | ||
|
|
38973fe64a | ||
|
|
d657964729 | ||
|
|
25c408484c | ||
|
|
c335b5b37c | ||
|
|
61b00892c3 | ||
|
|
e47e2c92fe | ||
|
|
3283965b45 | ||
|
|
4a9cbc79f2 | ||
|
|
33978ce59e | ||
|
|
d5262231a1 | ||
|
|
b563f9078a | ||
|
|
e8667dfbe0 | ||
|
|
8d4f5d78ff | ||
|
|
e354a850c9 | ||
|
|
5ea026d369 | ||
|
|
ddfe17d0a4 | ||
|
|
85aa7bef1e | ||
|
|
8498928e47 | ||
|
|
fa21269f0d | ||
|
|
5f5869ffc6 | ||
|
|
7a2d99a450 | ||
|
|
6b669ce40c | ||
|
|
fdf4ed2aa5 | ||
|
|
1361d10cd7 | ||
|
|
8c7ba11493 | ||
|
|
29ce17abf4 | ||
|
|
c988d1ce36 | ||
|
|
ec02f6d010 | ||
|
|
9f19c4250a | ||
|
|
d7b583ae51 | ||
|
|
152e5254e2 | ||
|
|
3f8f7cd578 | ||
|
|
ed3fb62ffc | ||
|
|
1d14e1f018 | ||
|
|
2ac8901a0d | ||
|
|
6204fd5363 | ||
|
|
ce52ef64db | ||
|
|
059c12798d | ||
|
|
56aa809074 | ||
|
|
3d2dca5f0c | ||
|
|
cdb2b407be | ||
|
|
186ed451a9 | ||
|
|
a75646d047 | ||
|
|
25d1480f2a | ||
|
|
2175754a1f | ||
|
|
761a0877e6 | ||
|
|
df5f253146 | ||
|
|
a017d9415b | ||
|
|
e89c3b1e92 | ||
|
|
d4ffeedc87 | ||
|
|
cb74b2663f | ||
|
|
4ec711bd63 | ||
|
|
e81c8ce44d | ||
|
|
c2cf348255 | ||
|
|
e048a3da38 | ||
|
|
7cf3116f5b | ||
|
|
5cd7ea06ad | ||
|
|
52c62b31fd | ||
|
|
b2bb0aeb64 | ||
|
|
f0fc87e2b6 | ||
|
|
e7a88e99f9 | ||
|
|
c3e3a36b4c | ||
|
|
91bc56b15c | ||
|
|
d1e2c62433 | ||
|
|
524c16fbe1 | ||
|
|
2fdd3d66bc | ||
|
|
6a1e3b60ee | ||
|
|
434cd95a66 | ||
|
|
1a5bc2c7e0 | ||
|
|
a66e9a1a2c | ||
|
|
d880ce6bb4 | ||
|
|
c96023dcae | ||
|
|
2f8ad4d5bf | ||
|
|
038a848d53 | ||
|
|
ff17d79e73 | ||
|
|
a8201009f3 | ||
|
|
a349653282 | ||
|
|
355ee1178e | ||
|
|
30c5df3eaa | ||
|
|
10874af19a | ||
|
|
704118b3d0 | ||
|
|
7c575d0316 | ||
|
|
ab3f11bfe7 | ||
|
|
f0357539ad | ||
|
|
e70a2dd257 | ||
|
|
5ef99a15a5 | ||
|
|
6421973cd6 | ||
|
|
7201171eb5 | ||
|
|
1fb438fa6c | ||
|
|
7aa6c8b941 | ||
|
|
54d8d71de5 | ||
|
|
fb4c77d43b | ||
|
|
cada2f84a9 | ||
|
|
dc4627f413 | ||
|
|
02524b8b9b | ||
|
|
60b8230ecc | ||
|
|
75e6f23a82 | ||
|
|
1f221712a2 | ||
|
|
43797c03cc | ||
|
|
89637a618e | ||
|
|
fd605e0abe | ||
|
|
e73bcc73b5 | ||
|
|
c02707a90f | ||
|
|
232f853d68 | ||
|
|
91e296a0c8 | ||
|
|
bcedb06862 | ||
|
|
2ab32220ed | ||
|
|
273ccb3929 | ||
|
|
caaa4d5f35 | ||
|
|
0cf1fd1d41 | ||
|
|
5ee39df330 | ||
|
|
cc972d20f6 | ||
|
|
e0f32cfd54 | ||
|
|
6384c800c3 | ||
|
|
82de2ed8e1 | ||
|
|
af72d1854f | ||
|
|
0cff7cbccd | ||
|
|
6f4e16eed1 | ||
|
|
66be2f9240 | ||
|
|
b6c8718ae4 | ||
|
|
c8b70cc0fb | ||
|
|
6d1f621e55 | ||
|
|
671a33b31c | ||
|
|
7afc469306 | ||
|
|
8fd52248b7 | ||
|
|
69ba2aab11 | ||
|
|
f1df6dcda5 | ||
|
|
43e16bb913 | ||
|
|
4147211f94 | ||
|
|
63e49c5d3c | ||
|
|
35580c0849 | ||
|
|
8949a595fe | ||
|
|
bf8ef0a767 | ||
|
|
39962a3f48 | ||
|
|
4964621014 | ||
|
|
18e1a26da1 | ||
|
|
1d91ca5716 | ||
|
|
1040646610 | ||
|
|
fcd71931e7 | ||
|
|
bdbb74aff1 | ||
|
|
6f4029983a | ||
|
|
b2d25b1883 | ||
|
|
ba19d4f043 | ||
|
|
b222fe5afa | ||
|
|
f945defa2b | ||
|
|
4f0e4bc1ca | ||
|
|
41abc8404d | ||
|
|
2b08c4c344 | ||
|
|
97d91ddddb | ||
|
|
ec30b12fd1 | ||
|
|
9997fc11b1 | ||
|
|
c6ff0e6492 | ||
|
|
a3220ecae6 | ||
|
|
218864d08c | ||
|
|
3d0d70ece6 | ||
|
|
f629731930 | ||
|
|
e7a7b2417b | ||
|
|
0b24a9abc3 | ||
|
|
ca77b5210f | ||
|
|
0874f1c350 | ||
|
|
d89b99f42b | ||
|
|
7bd6ec68a8 | ||
|
|
bfe2eeb833 | ||
|
|
e97ab1fe3c | ||
|
|
b3ee2a8885 | ||
|
|
80b09e3212 | ||
|
|
0eb3714abc | ||
|
|
7991977443 | ||
|
|
5e5431c9f9 | ||
|
|
1fc05d1a30 | ||
|
|
21833e7c31 | ||
|
|
79daeb23a9 | ||
|
|
761c2578fb | ||
|
|
4d3145e559 | ||
|
|
91e29a3bf1 | ||
|
|
f6a4486c65 | ||
|
|
fc8b512931 | ||
|
|
e5dd15da82 | ||
|
|
e4140d71ab | ||
|
|
8312780c47 | ||
|
|
5accc3dec2 | ||
|
|
d875989866 | ||
|
|
38c92a2338 | ||
|
|
ce76b5db16 | ||
|
|
dfc4889d45 | ||
|
|
41431282ee | ||
|
|
5821b2f03c | ||
|
|
78d2bf736c | ||
|
|
6c098c3e0a | ||
|
|
bfb140d2e9 | ||
|
|
f71a1a7a89 | ||
|
|
e8aab39620 | ||
|
|
1d578d8563 | ||
|
|
abfd443541 | ||
|
|
81cbb6e5cf | ||
|
|
010c5cab87 | ||
|
|
415858119a | ||
|
|
1838a731d6 | ||
|
|
1e304fad65 | ||
|
|
999c9b3dc5 | ||
|
|
e15edbd54b | ||
|
|
e5cb77d168 | ||
|
|
cf521d4c7c | ||
|
|
6f09474193 | ||
|
|
7626933352 | ||
|
|
9e1d8c2fc6 | ||
|
|
6defed2915 | ||
|
|
d729eed7c2 | ||
|
|
f280032dcf | ||
|
|
7e85137012 | ||
|
|
88f2c3abd3 | ||
|
|
1a21e01f85 | ||
|
|
d302e817c8 | ||
|
|
1e1b0424d7 | ||
|
|
03f028b7e2 | ||
|
|
b1d35de8e4 | ||
|
|
ea6b9e5260 | ||
|
|
06d869aaa5 | ||
|
|
907cebdd6d | ||
|
|
745902bc7e | ||
|
|
ef0b3c9f9c | ||
|
|
532c077ddf | ||
|
|
cd905a6593 | ||
|
|
d0bf9d9bfb | ||
|
|
ddc79a631d | ||
|
|
6015f60db4 | ||
|
|
a6608bd7ea | ||
|
|
fb2d8c6406 | ||
|
|
c84ffb54d2 | ||
|
|
306bbdc697 | ||
|
|
9879ecad85 | ||
|
|
f0fcef5744 | ||
|
|
aa8a6058b5 | ||
|
|
48103bd244 | ||
|
|
600ac17a5f | ||
|
|
d46f28792c | ||
|
|
0f7379c941 | ||
|
|
4317fad798 | ||
|
|
5cfccb7e1d | ||
|
|
097eecd78a | ||
|
|
64b4642c49 | ||
|
|
0e87d14ca8 | ||
|
|
4d22b35a9f | ||
|
|
26586b4514 | ||
|
|
95fb2a7d7f | ||
|
|
fa66ea31d3 | ||
|
|
e0d3b819e5 | ||
|
|
17a0b4f3d0 | ||
|
|
d0d228d9f4 | ||
|
|
309acb961b | ||
|
|
12f8ebb3ea | ||
|
|
612861061c | ||
|
|
83af5ec36b | ||
|
|
74102d0319 | ||
|
|
fbd05a0fcf | ||
|
|
a53c786fe0 | ||
|
|
eb2728e5b9 | ||
|
|
3f17223387 | ||
|
|
74104cf107 | ||
|
|
13b4879723 | ||
|
|
f1ec0b2c59 | ||
|
|
6d44daf599 | ||
|
|
644a6f5569 | ||
|
|
fb83396522 | ||
|
|
e825bd0bdb | ||
|
|
61823ec7e2 | ||
|
|
cd133cbbe3 | ||
|
|
0e7a1bb76c | ||
|
|
f86bf69ebc | ||
|
|
adddf330fd | ||
|
|
10adb57b83 | ||
|
|
3160fe9abc | ||
|
|
6adb27d173 | ||
|
|
6e6aae2ea3 | ||
|
|
41a140d16c | ||
|
|
8880ab6498 | ||
|
|
389becc4f6 | ||
|
|
923530972a | ||
|
|
b84850df9f | ||
|
|
9e7dc1d11d | ||
|
|
2830ed6147 | ||
|
|
bfa919d078 | ||
|
|
f09c28e61f | ||
|
|
bfdba7713e | ||
|
|
d6cadc1e3f | ||
|
|
20a6a3f195 | ||
|
|
f60de45b52 | ||
|
|
77031d1ae4 | ||
|
|
9483a88ee1 | ||
|
|
3438a4f063 |
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,15 +1,14 @@
|
||||
name: Report an issue with Home Assistant Core
|
||||
description: Report an issue with Home Assistant Core.
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue form is for reporting bugs only!
|
||||
|
||||
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||
If you have a feature or enhancement request, please [request them here instead][fr].
|
||||
|
||||
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -10,8 +10,8 @@ contact_links:
|
||||
url: https://www.home-assistant.io/help
|
||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||
- name: Feature Request
|
||||
url: https://community.home-assistant.io/c/feature-requests
|
||||
about: Please use our Community Forum for making feature requests.
|
||||
url: https://github.com/orgs/home-assistant/discussions
|
||||
about: Please use this link to request new features or enhancements to existing features.
|
||||
- name: I'm unsure where to go
|
||||
url: https://www.home-assistant.io/join-chat
|
||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||
|
||||
1235
.github/copilot-instructions.md
vendored
1235
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@v10
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -105,10 +105,10 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@v10
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/intents-package
|
||||
repo: OHF-Voice/intents-package
|
||||
branch: main
|
||||
workflow: nightly.yaml
|
||||
workflow_conclusion: success
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.8.2
|
||||
uses: sigstore/cosign-installer@v3.9.1
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,10 +37,10 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 2
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.7"
|
||||
HA_SHORT_VERSION: "2025.8"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.29.0
|
||||
uses: github/codeql-action/init@v3.29.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.0
|
||||
uses: github/codeql-action/analyze@v3.29.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -137,4 +137,8 @@ tmp_cache
|
||||
.ropeproject
|
||||
|
||||
# Will be created from script/split_tests.py
|
||||
pytest_buckets.txt
|
||||
pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.12
|
||||
rev: v0.12.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -67,6 +67,7 @@ homeassistant.components.alert.*
|
||||
homeassistant.components.alexa.*
|
||||
homeassistant.components.alexa_devices.*
|
||||
homeassistant.components.alpha_vantage.*
|
||||
homeassistant.components.altruist.*
|
||||
homeassistant.components.amazon_polly.*
|
||||
homeassistant.components.amberelectric.*
|
||||
homeassistant.components.ambient_network.*
|
||||
@@ -502,6 +503,7 @@ homeassistant.components.tautulli.*
|
||||
homeassistant.components.tcp.*
|
||||
homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
homeassistant.components.threshold.*
|
||||
|
||||
22
CODEOWNERS
generated
22
CODEOWNERS
generated
@@ -57,6 +57,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/aemet/ @Noltari
|
||||
/homeassistant/components/agent_dvr/ @ispysoftware
|
||||
/tests/components/agent_dvr/ @ispysoftware
|
||||
/homeassistant/components/ai_task/ @home-assistant/core
|
||||
/tests/components/ai_task/ @home-assistant/core
|
||||
/homeassistant/components/air_quality/ @home-assistant/core
|
||||
/tests/components/air_quality/ @home-assistant/core
|
||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||
@@ -91,6 +93,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||
/homeassistant/components/alexa_devices/ @chemelli74
|
||||
/tests/components/alexa_devices/ @chemelli74
|
||||
/homeassistant/components/altruist/ @airalab @LoSk-p
|
||||
/tests/components/altruist/ @airalab @LoSk-p
|
||||
/homeassistant/components/amazon_polly/ @jschlyter
|
||||
/homeassistant/components/amberelectric/ @madpilot
|
||||
/tests/components/amberelectric/ @madpilot
|
||||
@@ -327,8 +331,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/demo/ @home-assistant/core
|
||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||
/homeassistant/components/derivative/ @afaucogney
|
||||
/tests/components/derivative/ @afaucogney
|
||||
/homeassistant/components/derivative/ @afaucogney @karwosts
|
||||
/tests/components/derivative/ @afaucogney @karwosts
|
||||
/homeassistant/components/devialet/ @fwestenberg
|
||||
/tests/components/devialet/ @fwestenberg
|
||||
/homeassistant/components/device_automation/ @home-assistant/core
|
||||
@@ -784,8 +788,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||
/homeassistant/components/jewish_calendar/ @tsvi
|
||||
/tests/components/jewish_calendar/ @tsvi
|
||||
/homeassistant/components/juicenet/ @jesserockz
|
||||
/tests/components/juicenet/ @jesserockz
|
||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||
/tests/components/justnimbus/ @kvanzuijlen
|
||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||
@@ -1167,6 +1169,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
/tests/components/plaato/ @JohNan
|
||||
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
|
||||
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
|
||||
/homeassistant/components/plex/ @jjlawren
|
||||
/tests/components/plex/ @jjlawren
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||
@@ -1274,8 +1278,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rehlko/ @bdraco @peterager
|
||||
/homeassistant/components/remote/ @home-assistant/core
|
||||
/tests/components/remote/ @home-assistant/core
|
||||
/homeassistant/components/remote_calendar/ @Thomas55555
|
||||
/tests/components/remote_calendar/ @Thomas55555
|
||||
/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter
|
||||
/tests/components/remote_calendar/ @Thomas55555 @allenporter
|
||||
/homeassistant/components/renault/ @epenet
|
||||
/tests/components/renault/ @epenet
|
||||
/homeassistant/components/renson/ @jimmyd-be
|
||||
@@ -1549,6 +1553,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/technove/ @Moustachauve
|
||||
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||
/tests/components/tedee/ @patrickhilker @zweckj
|
||||
/homeassistant/components/telegram_bot/ @hanwg
|
||||
/tests/components/telegram_bot/ @hanwg
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
@@ -1578,6 +1584,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/tile/ @bachya
|
||||
/homeassistant/components/tilt_ble/ @apt-itude
|
||||
/tests/components/tilt_ble/ @apt-itude
|
||||
/homeassistant/components/tilt_pi/ @michaelheyman
|
||||
/tests/components/tilt_pi/ @michaelheyman
|
||||
/homeassistant/components/time/ @home-assistant/core
|
||||
/tests/components/time/ @home-assistant/core
|
||||
/homeassistant/components/time_date/ @fabaff
|
||||
@@ -1666,6 +1674,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
||||
/homeassistant/components/valve/ @home-assistant/core
|
||||
/tests/components/valve/ @home-assistant/core
|
||||
/homeassistant/components/vegehub/ @ghowevege
|
||||
/tests/components/vegehub/ @ghowevege
|
||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||
/tests/components/velbus/ @Cereal2nd @brefra
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||
|
||||
@@ -38,8 +38,7 @@ def validate_python() -> None:
|
||||
|
||||
def ensure_config_path(config_dir: str) -> None:
|
||||
"""Validate the configuration directory."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config as config_util
|
||||
from . import config as config_util # noqa: PLC0415
|
||||
|
||||
lib_dir = os.path.join(config_dir, "deps")
|
||||
|
||||
@@ -80,8 +79,7 @@ def ensure_config_path(config_dir: str) -> None:
|
||||
|
||||
def get_arguments() -> argparse.Namespace:
|
||||
"""Get parsed passed in arguments."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config as config_util
|
||||
from . import config as config_util # noqa: PLC0415
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Home Assistant: Observe, Control, Automate.",
|
||||
@@ -177,8 +175,7 @@ def main() -> int:
|
||||
validate_os()
|
||||
|
||||
if args.script is not None:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import scripts
|
||||
from . import scripts # noqa: PLC0415
|
||||
|
||||
return scripts.run(args.script)
|
||||
|
||||
@@ -188,8 +185,7 @@ def main() -> int:
|
||||
|
||||
ensure_config_path(config_dir)
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from . import config, runner
|
||||
from . import config, runner # noqa: PLC0415
|
||||
|
||||
safe_mode = config.safe_mode_enabled(config_dir)
|
||||
|
||||
|
||||
@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def _generate_secret() -> str:
|
||||
"""Generate a secret."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
return str(pyotp.random_base32())
|
||||
|
||||
|
||||
def _generate_random() -> int:
|
||||
"""Generate a 32 digit number."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||
|
||||
|
||||
def _generate_otp(secret: str, count: int) -> str:
|
||||
"""Generate one time password."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
return str(pyotp.HOTP(secret).at(count))
|
||||
|
||||
|
||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||
"""Verify one time password."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
import pyqrcode # pylint: disable=import-outside-toplevel
|
||||
import pyqrcode # noqa: PLC0415
|
||||
|
||||
qr_code = pyqrcode.create(data)
|
||||
|
||||
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
||||
|
||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||
"""Generate a secret, url, and QR code."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
ota_secret = pyotp.random_base32()
|
||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||
@@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
||||
|
||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||
"""Create a ota_secret for user."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
ota_secret: str = secret or pyotp.random_base32()
|
||||
|
||||
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
||||
|
||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||
"""Validate two factor authentication code."""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||
# even we cannot find user, we still do verify
|
||||
@@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
||||
Return self.async_show_form(step_id='init') if user_input is None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
import pyotp # pylint: disable=import-outside-toplevel
|
||||
import pyotp # noqa: PLC0415
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
backup,
|
||||
category_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
@@ -89,6 +88,7 @@ from .helpers import (
|
||||
restore_state,
|
||||
template,
|
||||
translation,
|
||||
trigger,
|
||||
)
|
||||
from .helpers.dispatcher import async_dispatcher_send_internal
|
||||
from .helpers.storage import get_internal_store_manager
|
||||
@@ -394,7 +394,7 @@ async def async_setup_hass(
|
||||
|
||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||
"""Open the UI."""
|
||||
import webbrowser # pylint: disable=import-outside-toplevel
|
||||
import webbrowser # noqa: PLC0415
|
||||
|
||||
if hass.config.api is None or "frontend" not in hass.config.components:
|
||||
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
||||
@@ -452,6 +452,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
create_eager_task(restore_state.async_load(hass)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
|
||||
|
||||
@@ -561,8 +562,7 @@ async def async_enable_logging(
|
||||
|
||||
if not log_no_color:
|
||||
try:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from colorlog import ColoredFormatter
|
||||
from colorlog import ColoredFormatter # noqa: PLC0415
|
||||
|
||||
# basicConfig must be called after importing colorlog in order to
|
||||
# ensure that the handlers it sets up wraps the correct streams.
|
||||
@@ -879,10 +879,6 @@ async def _async_set_up_integrations(
|
||||
if "recorder" in all_domains:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
# Initialize backup
|
||||
if "backup" in all_domains:
|
||||
backup.async_initialize_backup(hass)
|
||||
|
||||
stages: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group, timeout)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"domain": "sony",
|
||||
"name": "Sony",
|
||||
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
||||
"integrations": [
|
||||
"braviatv",
|
||||
"ps4",
|
||||
"sony_projector",
|
||||
"songpal",
|
||||
"playstation_network"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"domain": "switchbot",
|
||||
"name": "SwitchBot",
|
||||
"integrations": ["switchbot", "switchbot_cloud"]
|
||||
"integrations": ["switchbot", "switchbot_cloud"],
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
|
||||
5
homeassistant/brands/tilt.json
Normal file
5
homeassistant/brands/tilt.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "tilt",
|
||||
"name": "Tilt",
|
||||
"integrations": ["tilt_ble", "tilt_pi"]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from jaraco.abode.exceptions import Exception as AbodeException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
@@ -70,6 +70,7 @@ def _trigger_automation(call: ServiceCall) -> None:
|
||||
dispatcher_send(call.hass, signal)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Daily forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -192,6 +193,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Hourly forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -334,7 +336,8 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
||||
name="Wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_WIND_MAX_SPEED,
|
||||
|
||||
134
homeassistant/components/ai_task/__init__.py
Normal file
134
homeassistant/components/ai_task/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Integration to offer AI tasks to Home Assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import (
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, storage
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import (
|
||||
ATTR_INSTRUCTIONS,
|
||||
ATTR_TASK_NAME,
|
||||
DATA_COMPONENT,
|
||||
DATA_PREFERENCES,
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_DATA,
|
||||
AITaskEntityFeature,
|
||||
)
|
||||
from .entity import AITaskEntity
|
||||
from .http import async_setup as async_setup_http
|
||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AITaskEntity",
|
||||
"AITaskEntityFeature",
|
||||
"GenDataTask",
|
||||
"GenDataTaskResult",
|
||||
"async_generate_data",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Register the process service."""
|
||||
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||
await hass.data[DATA_PREFERENCES].async_load()
|
||||
async_setup_http(hass)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_DATA,
|
||||
async_service_generate_data,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
job_type=HassJobType.Coroutinefunction,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run the run task service."""
|
||||
result = await async_generate_data(hass=call.hass, **call.data)
|
||||
return result.as_dict()
|
||||
|
||||
|
||||
class AITaskPreferences:
|
||||
"""AI Task preferences."""
|
||||
|
||||
KEYS = ("gen_data_entity_id",)
|
||||
|
||||
gen_data_entity_id: str | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the preferences."""
|
||||
self._store: storage.Store[dict[str, str | None]] = storage.Store(
|
||||
hass, 1, DOMAIN
|
||||
)
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the data from the store."""
|
||||
data = await self._store.async_load()
|
||||
if data is None:
|
||||
return
|
||||
for key in self.KEYS:
|
||||
setattr(self, key, data[key])
|
||||
|
||||
@callback
|
||||
def async_set_preferences(
|
||||
self,
|
||||
*,
|
||||
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||
) -> None:
|
||||
"""Set the preferences."""
|
||||
changed = False
|
||||
for key, value in (("gen_data_entity_id", gen_data_entity_id),):
|
||||
if value is not UNDEFINED:
|
||||
if getattr(self, key) != value:
|
||||
setattr(self, key, value)
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
self._store.async_delay_save(self.as_dict, 10)
|
||||
|
||||
@callback
|
||||
def as_dict(self) -> dict[str, str | None]:
|
||||
"""Get the current preferences."""
|
||||
return {key: getattr(self, key) for key in self.KEYS}
|
||||
34
homeassistant/components/ai_task/const.py
Normal file
34
homeassistant/components/ai_task/const.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Constants for the AI Task integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from . import AITaskPreferences
|
||||
from .entity import AITaskEntity
|
||||
|
||||
DOMAIN = "ai_task"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||
|
||||
SERVICE_GENERATE_DATA = "generate_data"
|
||||
|
||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
||||
ATTR_TASK_NAME: Final = "task_name"
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a Home Assistant expert and help users with their tasks."
|
||||
)
|
||||
|
||||
|
||||
class AITaskEntityFeature(IntFlag):
|
||||
"""Supported features of the AI task entity."""
|
||||
|
||||
GENERATE_DATA = 1
|
||||
"""Generate data based on instructions."""
|
||||
103
homeassistant/components/ai_task/entity.py
Normal file
103
homeassistant/components/ai_task/entity.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Entity for the AI Task integration."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import contextlib
|
||||
from typing import final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.conversation import (
|
||||
ChatLog,
|
||||
UserContent,
|
||||
async_get_chat_log,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
||||
from .task import GenDataTask, GenDataTaskResult
|
||||
|
||||
|
||||
class AITaskEntity(RestoreEntity):
|
||||
"""Entity that supports conversations."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_supported_features = AITaskEntityFeature(0)
|
||||
__last_activity: str | None = None
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the entity."""
|
||||
if self.__last_activity is None:
|
||||
return None
|
||||
return self.__last_activity
|
||||
|
||||
@cached_property
|
||||
def supported_features(self) -> AITaskEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if (
|
||||
state is not None
|
||||
and state.state is not None
|
||||
and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
):
|
||||
self.__last_activity = state.state
|
||||
|
||||
@final
|
||||
@contextlib.asynccontextmanager
|
||||
async def _async_get_ai_task_chat_log(
|
||||
self,
|
||||
task: GenDataTask,
|
||||
) -> AsyncGenerator[ChatLog]:
|
||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||
with (
|
||||
async_get_chat_session(self.hass) as session,
|
||||
async_get_chat_log(
|
||||
self.hass,
|
||||
session,
|
||||
None,
|
||||
) as chat_log,
|
||||
):
|
||||
await chat_log.async_provide_llm_data(
|
||||
llm.LLMContext(
|
||||
platform=self.platform.domain,
|
||||
context=None,
|
||||
language=None,
|
||||
assistant=DOMAIN,
|
||||
device_id=None,
|
||||
),
|
||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
chat_log.async_add_user_content(UserContent(task.instructions))
|
||||
|
||||
yield chat_log
|
||||
|
||||
@final
|
||||
async def internal_async_generate_data(
|
||||
self,
|
||||
task: GenDataTask,
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a gen data task."""
|
||||
self.__last_activity = dt_util.utcnow().isoformat()
|
||||
self.async_write_ha_state()
|
||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
||||
return await self._async_generate_data(task, chat_log)
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
task: GenDataTask,
|
||||
chat_log: ChatLog,
|
||||
) -> GenDataTaskResult:
|
||||
"""Handle a gen data task."""
|
||||
raise NotImplementedError
|
||||
54
homeassistant/components/ai_task/http.py
Normal file
54
homeassistant/components/ai_task/http.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""HTTP endpoint for AI Task integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DATA_PREFERENCES
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the HTTP API for the conversation integration."""
|
||||
websocket_api.async_register_command(hass, websocket_get_preferences)
|
||||
websocket_api.async_register_command(hass, websocket_set_preferences)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ai_task/preferences/get",
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def websocket_get_preferences(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get AI task preferences."""
|
||||
preferences = hass.data[DATA_PREFERENCES]
|
||||
connection.send_result(msg["id"], preferences.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ai_task/preferences/set",
|
||||
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@callback
|
||||
def websocket_set_preferences(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set AI task preferences."""
|
||||
preferences = hass.data[DATA_PREFERENCES]
|
||||
msg.pop("type")
|
||||
msg_id = msg.pop("id")
|
||||
preferences.async_set_preferences(**msg)
|
||||
connection.send_result(msg_id, preferences.as_dict())
|
||||
7
homeassistant/components/ai_task/icons.json
Normal file
7
homeassistant/components/ai_task/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"generate_data": {
|
||||
"service": "mdi:file-star-four-points-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
homeassistant/components/ai_task/manifest.json
Normal file
9
homeassistant/components/ai_task/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "ai_task",
|
||||
"name": "AI Task",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
19
homeassistant/components/ai_task/services.yaml
Normal file
19
homeassistant/components/ai_task/services.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
generate_data:
|
||||
fields:
|
||||
task_name:
|
||||
example: "home summary"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
instructions:
|
||||
example: "Generate a funny notification that the garage door was left open"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
entity_id:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
22
homeassistant/components/ai_task/strings.json
Normal file
22
homeassistant/components/ai_task/strings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"services": {
|
||||
"generate_data": {
|
||||
"name": "Generate data",
|
||||
"description": "Uses AI to run a task that generates data.",
|
||||
"fields": {
|
||||
"task_name": {
|
||||
"name": "Task name",
|
||||
"description": "Name of the task."
|
||||
},
|
||||
"instructions": {
|
||||
"name": "Instructions",
|
||||
"description": "Instructions on what needs to be done."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity ID",
|
||||
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
homeassistant/components/ai_task/task.py
Normal file
75
homeassistant/components/ai_task/task.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""AI tasks to be handled by agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||
|
||||
|
||||
async def async_generate_data(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
task_name: str,
|
||||
entity_id: str | None = None,
|
||||
instructions: str,
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a task in the AI Task integration."""
|
||||
if entity_id is None:
|
||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
||||
|
||||
if entity_id is None:
|
||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
||||
|
||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
||||
|
||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support generating data"
|
||||
)
|
||||
|
||||
return await entity.internal_async_generate_data(
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenDataTask:
|
||||
"""Gen data task to be processed."""
|
||||
|
||||
name: str
|
||||
"""Name of the task."""
|
||||
|
||||
instructions: str
|
||||
"""Instructions on what needs to be done."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return task as a string."""
|
||||
return f"<GenDataTask {self.name}: {id(self)}>"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenDataTaskResult:
|
||||
"""Result of gen data task."""
|
||||
|
||||
conversation_id: str
|
||||
"""Unique identifier for the conversation."""
|
||||
|
||||
data: Any
|
||||
"""Data generated by the task."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return result as a dict."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"data": self.data,
|
||||
}
|
||||
@@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
try:
|
||||
location_point_valid = await test_location(
|
||||
location_point_valid = await check_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
user_input["longitude"],
|
||||
)
|
||||
if not location_point_valid:
|
||||
location_nearest_valid = await test_location(
|
||||
location_nearest_valid = await check_location(
|
||||
websession,
|
||||
user_input["api_key"],
|
||||
user_input["latitude"],
|
||||
@@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
async def test_location(
|
||||
async def check_location(
|
||||
client: ClientSession,
|
||||
api_key: str,
|
||||
latitude: float,
|
||||
|
||||
@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library."""
|
||||
data = {}
|
||||
data: dict[str, Any] = {}
|
||||
try:
|
||||
obs = await self.airnow.observations.latLong(
|
||||
self.latitude,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairnow"],
|
||||
"requirements": ["pyairnow==1.2.1"]
|
||||
"requirements": ["pyairnow==1.3.1"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
session = async_get_clientsession(hass)
|
||||
session = async_create_clientsession(hass)
|
||||
self.airq = AirQ(
|
||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
@@ -28,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.api.close()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
coordinator = entry.runtime_data
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await coordinator.api.close()
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0
|
||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Alexa Devices binary sensor entity description."""
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice], bool]
|
||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
||||
|
||||
|
||||
BINARY_SENSORS: Final = (
|
||||
@@ -36,13 +38,49 @@ BINARY_SENSORS: Final = (
|
||||
key="online",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda _device: _device.online,
|
||||
is_on_fn=lambda device, _: device.online,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="bluetooth",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="bluetooth",
|
||||
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||
is_on_fn=lambda device, _: device.bluetooth_state,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="babyCryDetectionState",
|
||||
translation_key="baby_cry_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="beepingApplianceDetectionState",
|
||||
translation_key="beeping_appliance_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="coughDetectionState",
|
||||
translation_key="cough_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="dogBarkDetectionState",
|
||||
translation_key="dog_bark_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="humanPresenceDetectionState",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="waterSoundsDetectionState",
|
||||
translation_key="water_sounds_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -60,6 +98,7 @@ async def async_setup_entry(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||
)
|
||||
|
||||
|
||||
@@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.is_on_fn(self.device)
|
||||
return self.entity_description.is_on_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
|
||||
@@ -2,9 +2,39 @@
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"default": "mdi:bluetooth",
|
||||
"default": "mdi:bluetooth-off",
|
||||
"state": {
|
||||
"off": "mdi:bluetooth-off"
|
||||
"on": "mdi:bluetooth"
|
||||
}
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"default": "mdi:account-voice-off",
|
||||
"state": {
|
||||
"on": "mdi:account-voice"
|
||||
}
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"default": "mdi:bell-off",
|
||||
"state": {
|
||||
"on": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"cough_detection": {
|
||||
"default": "mdi:blur-off",
|
||||
"state": {
|
||||
"on": "mdi:blur"
|
||||
}
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"default": "mdi:dog-side-off",
|
||||
"state": {
|
||||
"on": "mdi:dog-side"
|
||||
}
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"default": "mdi:water-pump-off",
|
||||
"state": {
|
||||
"on": "mdi:water-pump"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.0.6"]
|
||||
"requirements": ["aioamazondevices==3.1.22"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
||||
|
||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -22,6 +24,7 @@ PARALLEL_UPDATES = 1
|
||||
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
||||
"""Alexa Devices notify entity description."""
|
||||
|
||||
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
|
||||
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
||||
subkey: str
|
||||
|
||||
@@ -31,6 +34,7 @@ NOTIFY: Final = (
|
||||
key="speak",
|
||||
translation_key="speak",
|
||||
subkey="AUDIO_PLAYER",
|
||||
is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY,
|
||||
method=lambda api, device, message: api.call_alexa_speak(device, message),
|
||||
),
|
||||
AmazonNotifyEntityDescription(
|
||||
@@ -58,6 +62,7 @@ async def async_setup_entry(
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
||||
entity_description: AmazonNotifyEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def async_send_message(
|
||||
self, message: str, title: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
|
||||
@@ -26,7 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
|
||||
88
homeassistant/components/alexa_devices/sensor.py
Normal file
88
homeassistant/components/alexa_devices/sensor.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Support for sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSensorEntityDescription(SensorEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
|
||||
|
||||
SENSORS: Final = (
|
||||
AmazonSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement_fn=lambda device, _key: (
|
||||
UnitOfTemperature.CELSIUS
|
||||
if device.sensors[_key].scale == "CELSIUS"
|
||||
else UnitOfTemperature.FAHRENHEIT
|
||||
),
|
||||
),
|
||||
AmazonSensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Amazon Devices sensors based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
|
||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
"""Sensor device."""
|
||||
|
||||
entity_description: AmazonSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor."""
|
||||
if self.entity_description.native_unit_of_measurement_fn:
|
||||
return self.entity_description.native_unit_of_measurement_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.device.sensors[self.entity_description.key].value
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"data_country": "Country code",
|
||||
"data_code": "One-time password (OTP code)",
|
||||
"data_description_country": "The country of your Amazon account.",
|
||||
"data_description_country": "The country where your Amazon account is registered.",
|
||||
"data_description_username": "The email address of your Amazon account.",
|
||||
"data_description_password": "The password of your Amazon account.",
|
||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
||||
@@ -12,10 +11,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country": "[%key:component::alexa_devices::common::data_country%]",
|
||||
"country": "[%key:common::config_flow::data::country%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||
@@ -41,6 +40,21 @@
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"name": "Baby crying"
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"name": "Beeping appliance"
|
||||
},
|
||||
"cough_detection": {
|
||||
"name": "Coughing"
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"name": "Dog barking"
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"name": "Water sounds"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
@@ -56,5 +70,13 @@
|
||||
"name": "Do not disturb"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Error connecting: {error}"
|
||||
},
|
||||
"cannot_retrieve_data": {
|
||||
"message": "Error retrieving data: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
||||
entity_description: AmazonSwitchEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def _switch_set_state(self, state: bool) -> None:
|
||||
"""Set desired switch state."""
|
||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||
|
||||
40
homeassistant/components/alexa_devices/utils.py
Normal file
40
homeassistant/components/alexa_devices/utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Utils for Alexa Devices."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AmazonEntity
|
||||
|
||||
|
||||
def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Catch Alexa API call exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap all command methods."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except CannotConnect as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return cmd_wrapper
|
||||
27
homeassistant/components/altruist/__init__.py
Normal file
27
homeassistant/components/altruist/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""The Altruist integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
||||
"""Set up Altruist from a config entry."""
|
||||
|
||||
coordinator = AltruistDataUpdateCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
107
homeassistant/components/altruist/config_flow.py
Normal file
107
homeassistant/components/altruist/config_flow.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Config flow for the Altruist integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_HOST, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AltruistConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Altruist."""
|
||||
|
||||
device: AltruistDeviceModel
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
ip_address = ""
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_HOST]
|
||||
try:
|
||||
client = await AltruistClient.from_ip_address(
|
||||
async_get_clientsession(self.hass), ip_address
|
||||
)
|
||||
except AltruistError:
|
||||
errors["base"] = "no_device_found"
|
||||
else:
|
||||
self.device = client.device
|
||||
await self.async_set_unique_id(
|
||||
client.device_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=self.device.id,
|
||||
data={
|
||||
CONF_HOST: ip_address,
|
||||
},
|
||||
)
|
||||
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
{CONF_HOST: ip_address},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
|
||||
try:
|
||||
client = await AltruistClient.from_ip_address(
|
||||
async_get_clientsession(self.hass), str(discovery_info.ip_address)
|
||||
)
|
||||
except AltruistError:
|
||||
return self.async_abort(reason="no_device_found")
|
||||
|
||||
self.device = client.device
|
||||
_LOGGER.debug("Zeroconf device: %s", client.device)
|
||||
await self.async_set_unique_id(client.device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": self.device.id,
|
||||
}
|
||||
}
|
||||
)
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self.device.id,
|
||||
data={
|
||||
CONF_HOST: self.device.ip_address,
|
||||
},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={
|
||||
"model": self.device.id,
|
||||
},
|
||||
)
|
||||
5
homeassistant/components/altruist/const.py
Normal file
5
homeassistant/components/altruist/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Altruist integration."""
|
||||
|
||||
DOMAIN = "altruist"
|
||||
|
||||
CONF_HOST = "host"
|
||||
64
homeassistant/components/altruist/coordinator.py
Normal file
64
homeassistant/components/altruist/coordinator.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Coordinator module for Altruist integration in Home Assistant.
|
||||
|
||||
This module defines the AltruistDataUpdateCoordinator class, which manages
|
||||
data updates for Altruist sensors using the AltruistClient.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from altruistclient import AltruistClient, AltruistError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_HOST
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
||||
"""Coordinates data updates for Altruist sensors."""
|
||||
|
||||
client: AltruistClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AltruistConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the data update coordinator for Altruist sensors."""
|
||||
device_id = config_entry.unique_id
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"Altruist {device_id}",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self._ip_address = config_entry.data[CONF_HOST]
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
self.client = await AltruistClient.from_ip_address(
|
||||
async_get_clientsession(self.hass), self._ip_address
|
||||
)
|
||||
await self.client.fetch_data()
|
||||
except AltruistError as e:
|
||||
raise ConfigEntryNotReady("Error in Altruist setup") from e
|
||||
|
||||
async def _async_update_data(self) -> dict[str, str]:
|
||||
try:
|
||||
fetched_data = await self.client.fetch_data()
|
||||
except AltruistError as ex:
|
||||
raise UpdateFailed(
|
||||
f"The Altruist {self.client.device_id} is unavailable: {ex}"
|
||||
) from ex
|
||||
return {item["value_type"]: item["value"] for item in fetched_data}
|
||||
15
homeassistant/components/altruist/icons.json
Normal file
15
homeassistant/components/altruist/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"pm_10": {
|
||||
"default": "mdi:thought-bubble"
|
||||
},
|
||||
"pm_25": {
|
||||
"default": "mdi:thought-bubble-outline"
|
||||
},
|
||||
"radiation": {
|
||||
"default": "mdi:radioactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
homeassistant/components/altruist/manifest.json
Normal file
12
homeassistant/components/altruist/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "altruist",
|
||||
"name": "Altruist",
|
||||
"codeowners": ["@airalab", "@LoSk-p"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/altruist",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["altruistclient==0.1.1"],
|
||||
"zeroconf": ["_altruist._tcp.local."]
|
||||
}
|
||||
83
homeassistant/components/altruist/quality_scale.yaml
Normal file
83
homeassistant/components/altruist/quality_scale.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No known use cases for repair issues or flows, yet
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
249
homeassistant/components/altruist/sensor.py
Normal file
249
homeassistant/components/altruist/sensor.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Defines the Altruist sensor platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfPressure,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import AltruistConfigEntry
|
||||
from .coordinator import AltruistDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AltruistSensorEntityDescription(SensorEntityDescription):
|
||||
"""Class to describe a Sensor entity."""
|
||||
|
||||
native_value_fn: Callable[[str], float] = float
|
||||
state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = [
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
key="BME280_humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BME280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
key="BME280_pressure",
|
||||
translation_key="pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.PA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
||||
suggested_display_precision=0,
|
||||
translation_placeholders={"sensor_name": "BME280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="BME280_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BME280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
key="BMP_pressure",
|
||||
translation_key="pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.PA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
||||
suggested_display_precision=0,
|
||||
translation_placeholders={"sensor_name": "BMP"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="BMP_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BMP"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="BMP280_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "BMP280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
key="BMP280_pressure",
|
||||
translation_key="pressure",
|
||||
native_unit_of_measurement=UnitOfPressure.PA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
||||
suggested_display_precision=0,
|
||||
translation_placeholders={"sensor_name": "BMP280"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
key="HTU21D_humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "HTU21D"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="HTU21D_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "HTU21D"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
translation_key="pm_10",
|
||||
key="SDS_P1",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
translation_key="pm_25",
|
||||
key="SDS_P2",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
key="SHT3X_humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "SHT3X"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
key="SHT3X_temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "SHT3X"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
key="signal",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
key="PCBA_noiseMax",
|
||||
translation_key="noise_max",
|
||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
key="PCBA_noiseAvg",
|
||||
translation_key="noise_avg",
|
||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
translation_key="co2",
|
||||
key="CCS_CO2",
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "CCS"},
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
key="CCS_TVOC",
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
key="GC",
|
||||
native_unit_of_measurement="μR/h",
|
||||
translation_key="radiation",
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
AltruistSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
translation_key="co2",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
key="SCD4x_co2",
|
||||
suggested_display_precision=2,
|
||||
translation_placeholders={"sensor_name": "SCD4x"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AltruistConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add sensors for passed config_entry in HA."""
|
||||
coordinator = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
AltruistSensor(coordinator, sensor_description)
|
||||
for sensor_description in SENSOR_DESCRIPTIONS
|
||||
if sensor_description.key in coordinator.data
|
||||
)
|
||||
|
||||
|
||||
class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity):
|
||||
"""Implementation of a Altruist sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AltruistDataUpdateCoordinator,
|
||||
description: AltruistSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Altruist sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._device = coordinator.client.device
|
||||
self.entity_description: AltruistSensorEntityDescription = description
|
||||
self._attr_unique_id = f"{self._device.id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, self._device.id)},
|
||||
manufacturer="Robonomics",
|
||||
model="Altruist",
|
||||
sw_version=self._device.fw_version,
|
||||
configuration_url=f"http://{self._device.ip_address}",
|
||||
serial_number=self._device.id,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available and self.entity_description.key in self.coordinator.data
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int:
|
||||
"""Return the native value of the sensor."""
|
||||
string_value = self.coordinator.data[self.entity_description.key]
|
||||
return self.entity_description.native_value_fn(string_value)
|
||||
51
homeassistant/components/altruist/strings.json
Normal file
51
homeassistant/components/altruist/strings.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to start setup {model}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Altruist IP address or hostname in the local network"
|
||||
},
|
||||
"description": "Fill in Altruist IP address or hostname in your local network"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"no_device_found": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"humidity": {
|
||||
"name": "{sensor_name} humidity"
|
||||
},
|
||||
"pressure": {
|
||||
"name": "{sensor_name} pressure"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "{sensor_name} temperature"
|
||||
},
|
||||
"noise_max": {
|
||||
"name": "Maximum noise"
|
||||
},
|
||||
"noise_avg": {
|
||||
"name": "Average noise"
|
||||
},
|
||||
"co2": {
|
||||
"name": "{sensor_name} CO2"
|
||||
},
|
||||
"radiation": {
|
||||
"name": "Radiation level"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import Unauthorized, UnknownUser
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service import async_extract_entity_ids
|
||||
@@ -15,6 +15,7 @@ from .const import CAMERAS, DATA_AMCREST, DOMAIN
|
||||
from .helpers import service_signal
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Amcrest IP Camera services."""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -31,6 +32,7 @@ class IPWebcamCamera(MjpegCamera):
|
||||
"""Representation of a IP Webcam camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = CameraEntityFeature.STREAM
|
||||
|
||||
def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None:
|
||||
"""Initialize the camera."""
|
||||
@@ -46,3 +48,17 @@ class IPWebcamCamera(MjpegCamera):
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=coordinator.config_entry.data[CONF_HOST],
|
||||
)
|
||||
self._coordinator = coordinator
|
||||
|
||||
async def stream_source(self) -> str:
|
||||
"""Get the stream source for the Android IP camera."""
|
||||
return self._coordinator.cam.get_rtsp_url(
|
||||
video_codec="h264", # most compatible & recommended
|
||||
# while "opus" is compatible with more devices,
|
||||
# HA's stream integration requires AAC or MP3,
|
||||
# and IP webcam doesn't provide MP3 audio.
|
||||
# aac is supported on select devices >= android 4.1.
|
||||
# The stream will be quiet on devices that don't support aac,
|
||||
# but it won't fail.
|
||||
audio_codec="aac",
|
||||
)
|
||||
|
||||
@@ -6,13 +6,24 @@ from functools import partial
|
||||
|
||||
import anthropic
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -20,13 +31,24 @@ 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."""
|
||||
await async_migrate_integration(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Set up Anthropic from a config entry."""
|
||||
client = await hass.async_add_executor_job(
|
||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||
)
|
||||
try:
|
||||
model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
# Use model from first conversation subentry for validation
|
||||
subentries = list(entry.subentries.values())
|
||||
if subentries:
|
||||
model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
else:
|
||||
model_id = RECOMMENDED_CHAT_MODEL
|
||||
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
||||
LOGGER.debug("Anthropic model: %s", model.display_name)
|
||||
except anthropic.AuthenticationError as err:
|
||||
@@ -39,9 +61,120 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Anthropic."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not any(entry.version == 1 for entry in entries):
|
||||
return
|
||||
|
||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
for entry in entries:
|
||||
use_existing = False
|
||||
subentry = ConfigSubentry(
|
||||
data=entry.options,
|
||||
subentry_type="conversation",
|
||||
title=entry.title,
|
||||
unique_id=None,
|
||||
)
|
||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
||||
use_existing = True
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
||||
|
||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
if conversation_entity is not None:
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
if device is not None:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
)
|
||||
if parent_entry.entry_id != entry.entry_id:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
else:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -5,20 +5,21 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
@@ -36,6 +37,7 @@ from .const import (
|
||||
CONF_RECOMMENDED,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
@@ -72,7 +74,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -81,6 +84,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except anthropic.APITimeoutError:
|
||||
@@ -102,57 +106,93 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="Claude",
|
||||
data=user_input,
|
||||
options=RECOMMENDED_OPTIONS,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return AnthropicOptionsFlow(config_entry)
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"conversation": ConversationSubentryFlowHandler}
|
||||
|
||||
|
||||
class AnthropicOptionsFlow(OptionsFlow):
|
||||
"""Anthropic config flow options handler."""
|
||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.last_rendered_recommended = config_entry.options.get(
|
||||
CONF_RECOMMENDED, False
|
||||
)
|
||||
last_rendered_recommended = False
|
||||
|
||||
async def async_step_init(
|
||||
@property
|
||||
def _is_new(self) -> bool:
|
||||
"""Return if this is a new subentry."""
|
||||
return self.source == "user"
|
||||
|
||||
async def async_step_set_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||
) -> SubentryFlowResult:
|
||||
"""Set conversation options."""
|
||||
# abort if entry is not loaded
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
if user_input.get(
|
||||
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
||||
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
||||
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
if user_input is None:
|
||||
if self._is_new:
|
||||
options = RECOMMENDED_OPTIONS.copy()
|
||||
else:
|
||||
# Re-render the options again, now with the recommended options shown/hidden
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
# If this is a reconfiguration, we need to copy the existing options
|
||||
# so that we can show the current values in the form.
|
||||
options = self._get_reconfigure_subentry().data.copy()
|
||||
|
||||
options = {
|
||||
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
||||
CONF_PROMPT: user_input[CONF_PROMPT],
|
||||
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
||||
}
|
||||
self.last_rendered_recommended = cast(
|
||||
bool, options.get(CONF_RECOMMENDED, False)
|
||||
)
|
||||
|
||||
elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
if user_input.get(
|
||||
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
||||
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
||||
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
||||
|
||||
if not errors:
|
||||
if self._is_new:
|
||||
return self.async_create_entry(
|
||||
title=user_input.pop(CONF_NAME),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
options = user_input
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
else:
|
||||
# Re-render the options again, now with the recommended options shown/hidden
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
|
||||
options = {
|
||||
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
||||
CONF_PROMPT: user_input[CONF_PROMPT],
|
||||
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
||||
}
|
||||
|
||||
suggested_values = options.copy()
|
||||
if not suggested_values.get(CONF_PROMPT):
|
||||
@@ -163,19 +203,25 @@ class AnthropicOptionsFlow(OptionsFlow):
|
||||
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
vol.Schema(anthropic_config_option_schema(self.hass, options)),
|
||||
vol.Schema(
|
||||
anthropic_config_option_schema(self.hass, self._is_new, options)
|
||||
),
|
||||
suggested_values,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
step_id="set_options",
|
||||
data_schema=schema,
|
||||
errors=errors or None,
|
||||
)
|
||||
|
||||
async_step_user = async_step_set_options
|
||||
async_step_reconfigure = async_step_set_options
|
||||
|
||||
|
||||
def anthropic_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
is_new: bool,
|
||||
options: Mapping[str, Any],
|
||||
) -> dict:
|
||||
"""Return a schema for Anthropic completion options."""
|
||||
@@ -187,15 +233,24 @@ def anthropic_config_option_schema(
|
||||
for api in llm.async_get_apis(hass)
|
||||
]
|
||||
|
||||
schema = {
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
if is_new:
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
|
||||
}
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
if options.get(CONF_RECOMMENDED):
|
||||
return schema
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
DOMAIN = "anthropic"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
|
||||
@@ -1,69 +1,17 @@
|
||||
"""Conversation support for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -72,275 +20,29 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up conversation entities."""
|
||||
agent = AnthropicConversationEntity(config_entry)
|
||||
async_add_entities([agent])
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "conversation":
|
||||
continue
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
async_add_entities(
|
||||
[AnthropicConversationEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicConversationEntity(
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
AnthropicBaseLLMEntity,
|
||||
):
|
||||
"""Anthropic conversation agent."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry) -> None:
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
super().__init__(entry, subentry)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
@@ -350,90 +52,25 @@ class AnthropicConversationEntity(
|
||||
"""Return a list of supported languages."""
|
||||
return MATCH_ALL
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Call the API."""
|
||||
options = self.entry.options
|
||||
options = self.subentry.data
|
||||
|
||||
try:
|
||||
await chat_log.async_update_llm_data(
|
||||
DOMAIN,
|
||||
user_input,
|
||||
await chat_log.async_provide_llm_data(
|
||||
user_input.as_llm_context(DOMAIN),
|
||||
options.get(CONF_LLM_HASS_API),
|
||||
options.get(CONF_PROMPT),
|
||||
user_input.extra_system_prompt,
|
||||
)
|
||||
except conversation.ConverseError as err:
|
||||
return err.as_conversation_result()
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
user_input.agent_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
await self._async_handle_chat_log(chat_log)
|
||||
|
||||
response_content = chat_log.content[-1]
|
||||
if not isinstance(response_content, conversation.AssistantContent):
|
||||
@@ -445,10 +82,3 @@ class AnthropicConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
393
homeassistant/components/anthropic/entity.py
Normal file
393
homeassistant/components/anthropic/entity.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicBaseLLMEntity(Entity):
|
||||
"""Anthropic base LLM entity."""
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
@@ -12,28 +12,44 @@
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"prompt": "Instructions",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"temperature": "Temperature",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"recommended": "Recommended model settings",
|
||||
"thinking_budget_tokens": "Thinking budget"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"thinking_budget_tokens": "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."
|
||||
"config_subentries": {
|
||||
"conversation": {
|
||||
"initiate_flow": {
|
||||
"user": "Add conversation agent",
|
||||
"reconfigure": "Reconfigure conversation agent"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"prompt": "Instructions",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"temperature": "Temperature",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"recommended": "Recommended model settings",
|
||||
"thinking_budget_tokens": "Thinking budget"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"thinking_budget_tokens": "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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
||||
},
|
||||
"error": {
|
||||
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfApparentPower,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
@@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"alarmdel": SensorEntityDescription(
|
||||
key="alarmdel",
|
||||
translation_key="alarm_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"ambtemp": SensorEntityDescription(
|
||||
key="ambtemp",
|
||||
@@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="apc",
|
||||
translation_key="apc_status",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"apcmodel": SensorEntityDescription(
|
||||
key="apcmodel",
|
||||
translation_key="apc_model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"badbatts": SensorEntityDescription(
|
||||
key="badbatts",
|
||||
translation_key="bad_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"battdate": SensorEntityDescription(
|
||||
key="battdate",
|
||||
@@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="cable",
|
||||
translation_key="cable_type",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"cumonbatt": SensorEntityDescription(
|
||||
key="cumonbatt",
|
||||
@@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="date",
|
||||
translation_key="date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dipsw": SensorEntityDescription(
|
||||
key="dipsw",
|
||||
translation_key="dip_switch_settings",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dlowbatt": SensorEntityDescription(
|
||||
key="dlowbatt",
|
||||
translation_key="low_battery_signal",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"driver": SensorEntityDescription(
|
||||
key="driver",
|
||||
translation_key="driver",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dshutd": SensorEntityDescription(
|
||||
key="dshutd",
|
||||
translation_key="shutdown_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"dwake": SensorEntityDescription(
|
||||
key="dwake",
|
||||
translation_key="wake_delay",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"end apc": SensorEntityDescription(
|
||||
key="end apc",
|
||||
translation_key="date_and_time",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"extbatts": SensorEntityDescription(
|
||||
key="extbatts",
|
||||
translation_key="external_batteries",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"firmware": SensorEntityDescription(
|
||||
key="firmware",
|
||||
translation_key="firmware_version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hitrans": SensorEntityDescription(
|
||||
key="hitrans",
|
||||
translation_key="transfer_high",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"hostname": SensorEntityDescription(
|
||||
key="hostname",
|
||||
translation_key="hostname",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"humidity": SensorEntityDescription(
|
||||
key="humidity",
|
||||
@@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="lastxfer",
|
||||
translation_key="last_transfer",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"linefail": SensorEntityDescription(
|
||||
key="linefail",
|
||||
translation_key="line_failure",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"linefreq": SensorEntityDescription(
|
||||
key="linefreq",
|
||||
@@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="transfer_low",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"mandate": SensorEntityDescription(
|
||||
key="mandate",
|
||||
translation_key="manufacture_date",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"masterupd": SensorEntityDescription(
|
||||
key="masterupd",
|
||||
translation_key="master_update",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"maxlinev": SensorEntityDescription(
|
||||
key="maxlinev",
|
||||
@@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"maxtime": SensorEntityDescription(
|
||||
key="maxtime",
|
||||
translation_key="max_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"mbattchg": SensorEntityDescription(
|
||||
key="mbattchg",
|
||||
translation_key="max_battery_charge",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"minlinev": SensorEntityDescription(
|
||||
key="minlinev",
|
||||
@@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"mintimel": SensorEntityDescription(
|
||||
key="mintimel",
|
||||
translation_key="min_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"model": SensorEntityDescription(
|
||||
key="model",
|
||||
translation_key="model",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nombattv": SensorEntityDescription(
|
||||
key="nombattv",
|
||||
translation_key="battery_nominal_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nominv": SensorEntityDescription(
|
||||
key="nominv",
|
||||
translation_key="nominal_input_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nomoutv": SensorEntityDescription(
|
||||
key="nomoutv",
|
||||
translation_key="nominal_output_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nompower": SensorEntityDescription(
|
||||
key="nompower",
|
||||
translation_key="nominal_output_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"nomapnt": SensorEntityDescription(
|
||||
key="nomapnt",
|
||||
translation_key="nominal_apparent_power",
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"numxfers": SensorEntityDescription(
|
||||
key="numxfers",
|
||||
@@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="reg1",
|
||||
translation_key="register_1_fault",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"reg2": SensorEntityDescription(
|
||||
key="reg2",
|
||||
translation_key="register_2_fault",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"reg3": SensorEntityDescription(
|
||||
key="reg3",
|
||||
translation_key="register_3_fault",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"retpct": SensorEntityDescription(
|
||||
key="retpct",
|
||||
translation_key="restore_capacity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"selftest": SensorEntityDescription(
|
||||
key="selftest",
|
||||
@@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="sense",
|
||||
translation_key="sensitivity",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"serialno": SensorEntityDescription(
|
||||
key="serialno",
|
||||
translation_key="serial_number",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"statflag": SensorEntityDescription(
|
||||
key="statflag",
|
||||
translation_key="online_status",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"status": SensorEntityDescription(
|
||||
key="status",
|
||||
@@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"stesti": SensorEntityDescription(
|
||||
key="stesti",
|
||||
translation_key="self_test_interval",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"timeleft": SensorEntityDescription(
|
||||
key="timeleft",
|
||||
@@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
key="upsname",
|
||||
translation_key="ups_name",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"version": SensorEntityDescription(
|
||||
key="version",
|
||||
translation_key="version",
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbat": SensorEntityDescription(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbatt": SensorEntityDescription(
|
||||
key="xoffbatt",
|
||||
translation_key="transfer_from_battery",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xonbatt": SensorEntityDescription(
|
||||
key="xonbatt",
|
||||
translation_key="transfer_to_battery",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -260,11 +260,18 @@ class APIEntityStateView(HomeAssistantView):
|
||||
if not user.is_admin:
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
hass = request.app[KEY_HASS]
|
||||
|
||||
body = await request.text()
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
data: Any = json_loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return self.json_message(
|
||||
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
if (new_state := data.get("state")) is None:
|
||||
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@@ -477,9 +484,19 @@ class APITemplateView(HomeAssistantView):
|
||||
@require_admin
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Render a template."""
|
||||
body = await request.text()
|
||||
|
||||
try:
|
||||
data: Any = json_loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return self.json_message(
|
||||
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||
try:
|
||||
data = await request.json()
|
||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message(
|
||||
|
||||
@@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||
def get_aruba_data(self) -> dict[str, dict[str, str]] | None:
|
||||
"""Retrieve data from Aruba Access Point and return parsed result."""
|
||||
|
||||
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
|
||||
connect = f"ssh {self.username}@{self.host}"
|
||||
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
|
||||
query = ssh.expect(
|
||||
[
|
||||
|
||||
@@ -1119,6 +1119,7 @@ class PipelineRun:
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"""Base class for assist satellite entities."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
PUNCTUATION_START,
|
||||
PUNCTUATION_START_WORD,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -23,6 +33,7 @@ from .const import (
|
||||
)
|
||||
from .entity import (
|
||||
AssistSatelliteAnnouncement,
|
||||
AssistSatelliteAnswer,
|
||||
AssistSatelliteConfiguration,
|
||||
AssistSatelliteEntity,
|
||||
AssistSatelliteEntityDescription,
|
||||
@@ -34,6 +45,7 @@ from .websocket_api import async_register_websocket_api
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"AssistSatelliteAnnouncement",
|
||||
"AssistSatelliteAnswer",
|
||||
"AssistSatelliteConfiguration",
|
||||
"AssistSatelliteEntity",
|
||||
"AssistSatelliteEntityDescription",
|
||||
@@ -59,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -69,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
"start_conversation",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("start_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
@@ -86,6 +99,62 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_start_conversation",
|
||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||
)
|
||||
|
||||
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle a Show View service call."""
|
||||
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
|
||||
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
|
||||
satellite_entity_id
|
||||
)
|
||||
if satellite_entity is None:
|
||||
raise HomeAssistantError(
|
||||
f"Invalid Assist satellite entity id: {satellite_entity_id}"
|
||||
)
|
||||
|
||||
ask_question_args = {
|
||||
"question": call.data.get("question"),
|
||||
"question_media_id": call.data.get("question_media_id"),
|
||||
"preannounce": call.data.get("preannounce", False),
|
||||
"answers": call.data.get("answers"),
|
||||
}
|
||||
|
||||
if preannounce_media_id := call.data.get("preannounce_media_id"):
|
||||
ask_question_args["preannounce_media_id"] = preannounce_media_id
|
||||
|
||||
answer = await satellite_entity.async_internal_ask_question(**ask_question_args)
|
||||
|
||||
if answer is None:
|
||||
raise HomeAssistantError("No answer from satellite")
|
||||
|
||||
return asdict(answer)
|
||||
|
||||
hass.services.async_register(
|
||||
domain=DOMAIN,
|
||||
service="ask_question",
|
||||
service_func=handle_ask_question,
|
||||
schema=vol.All(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("sentences"): vol.All(
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
cv.has_at_least_one_key("question", "question_media_id"),
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.data[CONNECTION_TEST_DATA] = {}
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
@@ -110,3 +179,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
for sentence in value:
|
||||
if (
|
||||
PUNCTUATION_START.search(sentence)
|
||||
or PUNCTUATION_END.search(sentence)
|
||||
or PUNCTUATION_START_WORD.search(sentence)
|
||||
or PUNCTUATION_END_WORD.search(sentence)
|
||||
):
|
||||
raise vol.Invalid("sentence should not contain punctuation")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
raise vol.Invalid("at least one sentence is required")
|
||||
|
||||
for sentence in value:
|
||||
if not sentence:
|
||||
raise vol.Invalid("sentences cannot be empty")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# Validator for media_id fields that accepts both string and media selector format
|
||||
_media_id_validator = vol.Any(
|
||||
cv.string, # Plain string format
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("media_content_id"): cv.string,
|
||||
vol.Required("media_content_type"): cv.string,
|
||||
vol.Remove("metadata"): dict, # Ignore metadata if present
|
||||
}
|
||||
),
|
||||
# Extract media_content_id from media selector format
|
||||
lambda x: x["media_content_id"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,12 +4,16 @@ from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal, final
|
||||
|
||||
from hassil import Intents, recognize
|
||||
from hassil.expression import Expression, ListReference, Sequence
|
||||
from hassil.intents import WildcardSlotList
|
||||
|
||||
from homeassistant.components import conversation, media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
OPTION_PREFERRED,
|
||||
@@ -105,6 +109,20 @@ class AssistSatelliteAnnouncement:
|
||||
"""Media ID to be played before announcement."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistSatelliteAnswer:
|
||||
"""Answer to a question."""
|
||||
|
||||
id: str | None
|
||||
"""Matched answer id or None if no answer was matched."""
|
||||
|
||||
sentence: str
|
||||
"""Raw sentence text from user response."""
|
||||
|
||||
slots: dict[str, Any] = field(default_factory=dict)
|
||||
"""Matched slots from answer."""
|
||||
|
||||
|
||||
class AssistSatelliteEntity(entity.Entity):
|
||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||
|
||||
@@ -122,6 +140,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
_ask_question_future: asyncio.Future[str | None] | None = None
|
||||
|
||||
__assist_satellite_state = AssistSatelliteState.IDLE
|
||||
|
||||
@@ -309,6 +328,112 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""Start a conversation from the satellite."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_internal_ask_question(
|
||||
self,
|
||||
question: str | None = None,
|
||||
question_media_id: str | None = None,
|
||||
preannounce: bool = True,
|
||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||
answers: list[dict[str, Any]] | None = None,
|
||||
) -> AssistSatelliteAnswer | None:
|
||||
"""Ask a question and get a user's response from the satellite.
|
||||
|
||||
If question_media_id is not provided, question is synthesized to audio
|
||||
with the selected pipeline.
|
||||
|
||||
If question_media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce is True, a sound is played before the start message or media.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
if question is None:
|
||||
question = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
question,
|
||||
question_media_id,
|
||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
self._ask_question_future = asyncio.Future()
|
||||
|
||||
try:
|
||||
# Wait for announcement to finish
|
||||
await self.async_start_conversation(announcement)
|
||||
|
||||
# Wait for response text
|
||||
response_text = await self._ask_question_future
|
||||
if response_text is None:
|
||||
raise HomeAssistantError("No answer from question")
|
||||
|
||||
if not answers:
|
||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
||||
|
||||
return self._question_response_to_answer(response_text, answers)
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
self._ask_question_future = None
|
||||
|
||||
def _question_response_to_answer(
|
||||
self, response_text: str, answers: list[dict[str, Any]]
|
||||
) -> AssistSatelliteAnswer:
|
||||
"""Match text to a pre-defined set of answers."""
|
||||
|
||||
# Build intents and match
|
||||
intents = Intents.from_dict(
|
||||
{
|
||||
"language": self.hass.config.language,
|
||||
"intents": {
|
||||
"QuestionIntent": {
|
||||
"data": [
|
||||
{
|
||||
"sentences": answer["sentences"],
|
||||
"metadata": {"answer_id": answer["id"]},
|
||||
}
|
||||
for answer in answers
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Assume slot list references are wildcards
|
||||
wildcard_names: set[str] = set()
|
||||
for intent in intents.intents.values():
|
||||
for intent_data in intent.data:
|
||||
for sentence in intent_data.sentences:
|
||||
_collect_list_references(sentence, wildcard_names)
|
||||
|
||||
for wildcard_name in wildcard_names:
|
||||
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
||||
|
||||
# Match response text
|
||||
result = recognize(response_text, intents)
|
||||
if result is None:
|
||||
# No match
|
||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
||||
|
||||
assert result.intent_metadata
|
||||
return AssistSatelliteAnswer(
|
||||
id=result.intent_metadata["answer_id"],
|
||||
sentence=response_text,
|
||||
slots={
|
||||
entity_name: entity.value
|
||||
for entity_name, entity in result.entities.items()
|
||||
},
|
||||
)
|
||||
|
||||
async def async_accept_pipeline_from_satellite(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
@@ -351,6 +476,11 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
||||
return
|
||||
|
||||
if (self._ask_question_future is not None) and (
|
||||
start_stage == PipelineStage.STT
|
||||
):
|
||||
end_stage = PipelineStage.STT
|
||||
|
||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||
|
||||
# Refresh context if necessary
|
||||
@@ -433,6 +563,16 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
elif event.type is PipelineEventType.STT_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING)
|
||||
elif event.type is PipelineEventType.STT_END:
|
||||
# Intercepting text for ask question
|
||||
if (
|
||||
(self._ask_question_future is not None)
|
||||
and (not self._ask_question_future.done())
|
||||
and event.data
|
||||
):
|
||||
self._ask_question_future.set_result(
|
||||
event.data.get("stt_output", {}).get("text")
|
||||
)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
self._set_state(AssistSatelliteState.PROCESSING)
|
||||
elif event.type is PipelineEventType.TTS_START:
|
||||
@@ -443,6 +583,12 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
if not self._run_has_tts:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
|
||||
if (self._ask_question_future is not None) and (
|
||||
not self._ask_question_future.done()
|
||||
):
|
||||
# No text for ask question
|
||||
self._ask_question_future.set_result(None)
|
||||
|
||||
self.on_pipeline_event(event)
|
||||
|
||||
@callback
|
||||
@@ -577,3 +723,15 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
media_id_source=media_id_source,
|
||||
preannounce_media_id=preannounce_media_id,
|
||||
)
|
||||
|
||||
|
||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
||||
"""Collect list reference names recursively."""
|
||||
if isinstance(expression, Sequence):
|
||||
seq: Sequence = expression
|
||||
for item in seq.items:
|
||||
_collect_list_references(item, list_names)
|
||||
elif isinstance(expression, ListReference):
|
||||
# {list}
|
||||
list_ref: ListReference = expression
|
||||
list_names.add(list_ref.slot_name)
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"start_conversation": {
|
||||
"service": "mdi:forum"
|
||||
},
|
||||
"ask_question": {
|
||||
"service": "mdi:microphone-question"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ announce:
|
||||
media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -23,7 +25,9 @@ announce:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
@@ -40,7 +44,9 @@ start_conversation:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
@@ -52,5 +58,57 @@ start_conversation:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
ask_question:
|
||||
fields:
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
question:
|
||||
required: false
|
||||
example: "What kind of music would you like to play?"
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
question_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
answers:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
label_field: sentences
|
||||
description_field: id
|
||||
multiple: true
|
||||
translation_key: answers
|
||||
fields:
|
||||
id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
sentences:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
@@ -59,6 +59,44 @@
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_question": {
|
||||
"name": "Ask question",
|
||||
"description": "Asks a question and gets the user's response.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Assist satellite entity to ask the question on."
|
||||
},
|
||||
"question": {
|
||||
"name": "Question",
|
||||
"description": "The question to ask."
|
||||
},
|
||||
"question_media_id": {
|
||||
"name": "Question media ID",
|
||||
"description": "The media ID of the question to use instead of text-to-speech."
|
||||
},
|
||||
"preannounce": {
|
||||
"name": "Preannounce",
|
||||
"description": "Play a sound before the start message or media."
|
||||
},
|
||||
"preannounce_media_id": {
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
},
|
||||
"answers": {
|
||||
"name": "Answers",
|
||||
"description": "Possible answers to the question."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"answers": {
|
||||
"fields": {
|
||||
"id": "Answer ID",
|
||||
"sentences": "Sentences"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints"
|
||||
|
||||
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
||||
"""Return True if any automation references the blueprint."""
|
||||
from . import automations_with_blueprint # pylint: disable=import-outside-toplevel
|
||||
from . import automations_with_blueprint # noqa: PLC0415
|
||||
|
||||
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
||||
|
||||
@@ -28,8 +28,7 @@ async def _reload_blueprint_automations(
|
||||
@callback
|
||||
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
||||
"""Get automation blueprints."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA
|
||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
|
||||
|
||||
return blueprint.DomainBlueprints(
|
||||
hass,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -37,7 +37,6 @@ from .manager import (
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
ManagerStateEvent,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
@@ -45,6 +44,7 @@ from .manager import (
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||
from .services import async_setup_services
|
||||
from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
@@ -71,12 +71,12 @@ __all__ = [
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"ManagerStateEvent",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
@@ -94,46 +94,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if not with_hassio:
|
||||
reader_writer = CoreBackupReaderWriter(hass)
|
||||
else:
|
||||
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
|
||||
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
|
||||
# pylint: disable-next=hass-component-root-import
|
||||
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
||||
SupervisorBackupReaderWriter,
|
||||
)
|
||||
|
||||
reader_writer = SupervisorBackupReaderWriter(hass)
|
||||
|
||||
backup_manager = BackupManager(hass, reader_writer)
|
||||
hass.data[DATA_MANAGER] = backup_manager
|
||||
try:
|
||||
await backup_manager.async_setup()
|
||||
except Exception as err:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||
raise
|
||||
else:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||
await backup_manager.async_setup()
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
async def async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await backup_manager.async_create_automatic_backup()
|
||||
|
||||
if not with_hassio:
|
||||
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", async_handle_create_automatic_service
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
async_register_http_views(hass)
|
||||
|
||||
@@ -162,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
||||
"""Get the backup manager instance.
|
||||
|
||||
Raises HomeAssistantError if the backup integration is not available.
|
||||
"""
|
||||
if DATA_MANAGER not in hass.data:
|
||||
raise HomeAssistantError("Backup integration is not available")
|
||||
|
||||
return hass.data[DATA_MANAGER]
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Websocket commands for the Backup integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import async_subscribe_events
|
||||
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import ManagerStateEvent
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||
"""Register websocket commands."""
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
if DATA_MANAGER in hass.data:
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -8,10 +8,6 @@ from datetime import datetime
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import (
|
||||
async_subscribe_events,
|
||||
async_subscribe_platform_events,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
||||
update_interval=None,
|
||||
)
|
||||
self.unsubscribe: list[Callable[[], None]] = [
|
||||
async_subscribe_events(hass, self._on_event),
|
||||
async_subscribe_platform_events(hass, self._on_event),
|
||||
backup_manager.async_subscribe_events(self._on_event),
|
||||
backup_manager.async_subscribe_platform_events(self._on_event),
|
||||
]
|
||||
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
@@ -36,7 +36,6 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
|
||||
@@ -372,12 +371,10 @@ class BackupManager:
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||
self.last_action_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_event_subscriptions
|
||||
self._backup_platform_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_platform_event_subscriptions
|
||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||
self._backup_platform_event_subscriptions: list[
|
||||
Callable[[BackupPlatformEvent], None]
|
||||
] = []
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the backup manager."""
|
||||
@@ -1385,6 +1382,32 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
on_event: Callable[[ManagerStateEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
@callback
|
||||
def async_subscribe_platform_events(
|
||||
self,
|
||||
on_event: Callable[[BackupPlatformEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to backup platform events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_platform_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_platform_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
def _create_automatic_backup_failed_issue(
|
||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||
) -> None:
|
||||
|
||||
@@ -19,9 +19,14 @@ from homeassistant.components.onboarding import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
|
||||
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
|
||||
from . import (
|
||||
BackupManager,
|
||||
Folder,
|
||||
IncorrectPasswordError,
|
||||
async_get_manager,
|
||||
http as backup_http,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.onboarding import OnboardingStoreData
|
||||
@@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
|
||||
if self._data["done"]:
|
||||
raise HTTPUnauthorized
|
||||
|
||||
manager = await async_get_backup_manager(request.app[KEY_HASS])
|
||||
manager = async_get_manager(request.app[KEY_HASS])
|
||||
return await func(self, manager, request, *args, **kwargs)
|
||||
|
||||
return with_backup
|
||||
|
||||
36
homeassistant/components/backup/services.py
Normal file
36
homeassistant/components/backup/services.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
|
||||
|
||||
async def _async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
backup_manager = call.hass.data[DATA_MANAGER]
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
|
||||
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services."""
|
||||
if not is_hassio(hass):
|
||||
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
||||
)
|
||||
@@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .config import Day, ScheduleRecurrence
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||
from .manager import (
|
||||
DecryptOnDowloadNotSupported,
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||
websocket_api.async_register_command(hass, handle_delete)
|
||||
websocket_api.async_register_command(hass, handle_restore)
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_config_info)
|
||||
websocket_api.async_register_command(hass, handle_config_update)
|
||||
@@ -417,3 +422,22 @@ def handle_config_update(
|
||||
changes.pop("type")
|
||||
manager.config.update(**changes)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
from .services import setup_services
|
||||
from .services import async_setup_services
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up bosch alarm services."""
|
||||
setup_services(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||
}
|
||||
|
||||
@@ -28,38 +28,41 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
No options flow is provided.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the bosch alarm integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.12.4"]
|
||||
"requirements": ["bthome-ble==3.13.1"]
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
key="windazimuth",
|
||||
translation_key="windazimuth",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
|
||||
@@ -240,6 +240,10 @@ async def _async_get_stream_image(
|
||||
height: int | None = None,
|
||||
wait_for_next_keyframe: bool = False,
|
||||
) -> bytes | None:
|
||||
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
|
||||
image := await provider.async_get_image(camera, width=width, height=height)
|
||||
) is not None:
|
||||
return image
|
||||
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
||||
camera.stream = await camera.async_create_stream()
|
||||
if camera.stream:
|
||||
@@ -494,19 +498,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def supported_features_compat(self) -> CameraEntityFeature:
|
||||
"""Return the supported features as CameraEntityFeature.
|
||||
|
||||
Remove this compatibility shim in 2025.1 or later.
|
||||
"""
|
||||
features = self.supported_features
|
||||
if type(features) is int:
|
||||
new_features = CameraEntityFeature(features)
|
||||
self._report_deprecated_supported_features_values(new_features)
|
||||
return new_features
|
||||
return features
|
||||
|
||||
@cached_property
|
||||
def is_recording(self) -> bool:
|
||||
"""Return true if the device is recording."""
|
||||
@@ -700,9 +691,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
self.__supports_stream = (
|
||||
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||
)
|
||||
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
|
||||
await self.async_refresh_providers(write_state=False)
|
||||
|
||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||
@@ -731,7 +720,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||
) -> _T | None:
|
||||
"""Get first provider that supports this camera."""
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
if CameraEntityFeature.STREAM not in self.supported_features:
|
||||
return None
|
||||
|
||||
return await fn(self.hass, self)
|
||||
@@ -781,7 +770,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def camera_capabilities(self) -> CameraCapabilities:
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if CameraEntityFeature.STREAM in self.supported_features:
|
||||
if self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
@@ -801,8 +790,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features_compat
|
||||
& CameraEntityFeature.STREAM
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
self.__supports_stream = supports_stream
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
|
||||
@@ -156,6 +156,15 @@ class CameraWebRTCProvider(ABC):
|
||||
"""Close the session."""
|
||||
return ## This is an optional method so we need a default here.
|
||||
|
||||
async def async_get_image(
|
||||
self,
|
||||
camera: Camera,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bytes | None:
|
||||
"""Get an image from the camera."""
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_webrtc_provider(
|
||||
|
||||
@@ -105,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
|
||||
|
||||
# Can be removed in 2025.1 after deprecation period of the new feature flags
|
||||
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
|
||||
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
SET_TEMPERATURE_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(
|
||||
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.101.0"],
|
||||
"requirements": ["hass-nabucasa==0.104.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ class Control4RuntimeData:
|
||||
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
||||
|
||||
|
||||
async def call_c4_api_retry(func, *func_args):
|
||||
async def call_c4_api_retry(func, *func_args): # noqa: RET503
|
||||
"""Call C4 API function and retry on failure."""
|
||||
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
||||
for i in range(API_RETRY_TIMES): # noqa: RET503
|
||||
for i in range(API_RETRY_TIMES):
|
||||
try:
|
||||
return await func(*func_args)
|
||||
except client_exceptions.ClientError as exception:
|
||||
|
||||
@@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
# Temporary migration. We can remove this in 2024.10
|
||||
from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel
|
||||
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
|
||||
async_migrate_engine,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,12 +14,11 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import chat_session, intent, llm, template
|
||||
from homeassistant.helpers import chat_session, frame, intent, llm, template
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
@@ -359,7 +358,7 @@ class ChatLog:
|
||||
self,
|
||||
llm_context: llm.LLMContext,
|
||||
prompt: str,
|
||||
language: str,
|
||||
language: str | None,
|
||||
user_name: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
@@ -373,7 +372,7 @@ class ChatLog:
|
||||
)
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=language)
|
||||
intent_response = intent.IntentResponse(language=language or "")
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
@@ -392,15 +391,25 @@ class ChatLog:
|
||||
user_llm_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Set the LLM system prompt."""
|
||||
llm_context = llm.LLMContext(
|
||||
platform=conversing_domain,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
frame.report_usage(
|
||||
"ChatLog.async_update_llm_data",
|
||||
breaks_in_ha_version="2026.1",
|
||||
)
|
||||
return await self.async_provide_llm_data(
|
||||
llm_context=user_input.as_llm_context(conversing_domain),
|
||||
user_llm_hass_api=user_llm_hass_api,
|
||||
user_llm_prompt=user_llm_prompt,
|
||||
user_extra_system_prompt=user_input.extra_system_prompt,
|
||||
)
|
||||
|
||||
async def async_provide_llm_data(
|
||||
self,
|
||||
llm_context: llm.LLMContext,
|
||||
user_llm_hass_api: str | list[str] | None = None,
|
||||
user_llm_prompt: str | None = None,
|
||||
user_extra_system_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Set the LLM system prompt."""
|
||||
llm_api: llm.APIInstance | None = None
|
||||
|
||||
if user_llm_hass_api:
|
||||
@@ -414,10 +423,12 @@ class ChatLog:
|
||||
LOGGER.error(
|
||||
"Error getting LLM API %s for %s: %s",
|
||||
user_llm_hass_api,
|
||||
conversing_domain,
|
||||
llm_context.platform,
|
||||
err,
|
||||
)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response = intent.IntentResponse(
|
||||
language=llm_context.language or ""
|
||||
)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Error preparing LLM API",
|
||||
@@ -431,10 +442,10 @@ class ChatLog:
|
||||
user_name: str | None = None
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
llm_context.context
|
||||
and llm_context.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
||||
user := await self.hass.auth.async_get_user(llm_context.context.user_id)
|
||||
)
|
||||
):
|
||||
user_name = user.name
|
||||
@@ -444,7 +455,7 @@ class ChatLog:
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
user_input.language,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
)
|
||||
)
|
||||
@@ -456,14 +467,14 @@ class ChatLog:
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.BASE_PROMPT,
|
||||
user_input.language,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
)
|
||||
)
|
||||
|
||||
if extra_system_prompt := (
|
||||
# Take new system prompt if one was given
|
||||
user_input.extra_system_prompt or self.extra_system_prompt
|
||||
user_extra_system_prompt or self.extra_system_prompt
|
||||
):
|
||||
prompt_parts.append(extra_system_prompt)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers import intent, llm
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -56,6 +58,16 @@ class ConversationInput:
|
||||
"extra_system_prompt": self.extra_system_prompt,
|
||||
}
|
||||
|
||||
def as_llm_context(self, conversing_domain: str) -> llm.LLMContext:
|
||||
"""Return input as an LLM context."""
|
||||
return llm.LLMContext(
|
||||
platform=conversing_domain,
|
||||
context=self.context,
|
||||
language=self.language,
|
||||
assistant=DOMAIN,
|
||||
device_id=self.device_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConversationResult:
|
||||
|
||||
@@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_SWING_SUPPORT
|
||||
from .const import CONF_SWING_SUPPORT, DOMAIN
|
||||
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
||||
@@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
|
||||
"""Unload a Coolmaster config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CoolmasterConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
return not device_entry.identifiers.intersection(
|
||||
(DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data
|
||||
)
|
||||
|
||||
@@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def supported_features(self) -> CoverEntityFeature:
|
||||
"""Flag supported features."""
|
||||
if (features := self._attr_supported_features) is not None:
|
||||
if type(features) is int:
|
||||
new_features = CoverEntityFeature(features)
|
||||
self._report_deprecated_supported_features_values(new_features)
|
||||
return new_features
|
||||
return features
|
||||
|
||||
supported_features = (
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_MAX_SUB_INTERVAL,
|
||||
CONF_ROUND_DIGITS,
|
||||
CONF_TIME_WINDOW,
|
||||
CONF_UNIT_PREFIX,
|
||||
@@ -104,6 +105,9 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict:
|
||||
options=TIME_UNITS, translation_key="time_unit"
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ CONF_TIME_WINDOW = "time_window"
|
||||
CONF_UNIT = "unit"
|
||||
CONF_UNIT_PREFIX = "unit_prefix"
|
||||
CONF_UNIT_TIME = "unit_time"
|
||||
CONF_MAX_SUB_INTERVAL = "max_sub_interval"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "derivative",
|
||||
"name": "Derivative",
|
||||
"after_dependencies": ["counter"],
|
||||
"codeowners": ["@afaucogney"],
|
||||
"codeowners": ["@afaucogney", "@karwosts"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/derivative",
|
||||
"integration_type": "helper",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
from decimal import Decimal, DecimalException, InvalidOperation
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
EventStateReportedData,
|
||||
@@ -40,12 +41,14 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
async_track_state_report_event,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
CONF_MAX_SUB_INTERVAL,
|
||||
CONF_ROUND_DIGITS,
|
||||
CONF_TIME_WINDOW,
|
||||
CONF_UNIT,
|
||||
@@ -89,10 +92,20 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
|
||||
vol.Optional(CONF_UNIT): cv.string,
|
||||
vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period,
|
||||
vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_decimal_state(state: str) -> bool:
|
||||
try:
|
||||
Decimal(state)
|
||||
except (InvalidOperation, TypeError):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -114,6 +127,11 @@ async def async_setup_entry(
|
||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||
unit_prefix = None
|
||||
|
||||
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
||||
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
||||
else:
|
||||
max_sub_interval = None
|
||||
|
||||
derivative_sensor = DerivativeSensor(
|
||||
name=config_entry.title,
|
||||
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
||||
@@ -124,6 +142,7 @@ async def async_setup_entry(
|
||||
unit_prefix=unit_prefix,
|
||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||
device_info=device_info,
|
||||
max_sub_interval=max_sub_interval,
|
||||
)
|
||||
|
||||
async_add_entities([derivative_sensor])
|
||||
@@ -145,6 +164,7 @@ async def async_setup_platform(
|
||||
unit_prefix=config[CONF_UNIT_PREFIX],
|
||||
unit_time=config[CONF_UNIT_TIME],
|
||||
unique_id=None,
|
||||
max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL),
|
||||
)
|
||||
|
||||
async_add_entities([derivative])
|
||||
@@ -166,6 +186,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
unit_of_measurement: str | None,
|
||||
unit_prefix: str | None,
|
||||
unit_time: UnitOfTime,
|
||||
max_sub_interval: timedelta | None,
|
||||
unique_id: str | None,
|
||||
device_info: DeviceInfo | None = None,
|
||||
) -> None:
|
||||
@@ -192,6 +213,34 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
|
||||
self._unit_time = UNIT_TIME[unit_time]
|
||||
self._time_window = time_window.total_seconds()
|
||||
self._max_sub_interval: timedelta | None = (
|
||||
None # disable time based derivative
|
||||
if max_sub_interval is None or max_sub_interval.total_seconds() == 0
|
||||
else max_sub_interval
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback: CALLBACK_TYPE = (
|
||||
lambda *args: None
|
||||
)
|
||||
|
||||
def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal:
|
||||
def calculate_weight(start: datetime, end: datetime, now: datetime) -> float:
|
||||
window_start = now - timedelta(seconds=self._time_window)
|
||||
return (end - max(start, window_start)).total_seconds() / self._time_window
|
||||
|
||||
derivative = Decimal("0.00")
|
||||
for start, end, value in self._state_list:
|
||||
weight = calculate_weight(start, end, current_time)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
|
||||
return derivative
|
||||
|
||||
def _prune_state_list(self, current_time: datetime) -> None:
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (current_time - time_end).total_seconds() < self._time_window
|
||||
]
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
@@ -209,13 +258,52 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
except SyntaxError as err:
|
||||
_LOGGER.warning("Could not restore last state: %s", err)
|
||||
|
||||
def schedule_max_sub_interval_exceeded(source_state: State | None) -> None:
|
||||
"""Schedule calculation using the source state and max_sub_interval.
|
||||
|
||||
The callback reference is stored for possible cancellation if the source state
|
||||
reports a change before max_sub_interval has passed.
|
||||
If the callback is executed, meaning there was no state change reported, the
|
||||
source_state is assumed constant and calculation is done using its value.
|
||||
"""
|
||||
if (
|
||||
self._max_sub_interval is not None
|
||||
and source_state is not None
|
||||
and (_is_decimal_state(source_state.state))
|
||||
):
|
||||
|
||||
@callback
|
||||
def _calc_derivative_on_max_sub_interval_exceeded_callback(
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""Calculate derivative based on time and reschedule."""
|
||||
|
||||
self._prune_state_list(now)
|
||||
derivative = self._calc_derivative_from_state_list(now)
|
||||
self._attr_native_value = round(derivative, self._round_digits)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
# If derivative is now zero, don't schedule another timeout callback, as it will have no effect
|
||||
if derivative != 0:
|
||||
schedule_max_sub_interval_exceeded(source_state)
|
||||
|
||||
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
|
||||
self.hass,
|
||||
self._max_sub_interval,
|
||||
_calc_derivative_on_max_sub_interval_exceeded_callback,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if self._attr_native_value == Decimal(0):
|
||||
# If the derivative is zero, and the source sensor hasn't
|
||||
# changed state, then we know it will still be zero.
|
||||
return
|
||||
schedule_max_sub_interval_exceeded(new_state)
|
||||
new_state = event.data["new_state"]
|
||||
if new_state is not None:
|
||||
calc_derivative(
|
||||
@@ -225,7 +313,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
schedule_max_sub_interval_exceeded(new_state)
|
||||
old_state = event.data["old_state"]
|
||||
if new_state is not None and old_state is not None:
|
||||
calc_derivative(new_state, old_state.state, old_state.last_reported)
|
||||
@@ -246,13 +336,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
"" if unit is None else unit
|
||||
)
|
||||
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (new_state.last_reported - time_end).total_seconds()
|
||||
< self._time_window
|
||||
]
|
||||
self._prune_state_list(new_state.last_reported)
|
||||
|
||||
try:
|
||||
elapsed_time = (
|
||||
@@ -290,28 +374,27 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
(old_last_reported, new_state.last_reported, new_derivative)
|
||||
)
|
||||
|
||||
def calculate_weight(
|
||||
start: datetime, end: datetime, now: datetime
|
||||
) -> float:
|
||||
window_start = now - timedelta(seconds=self._time_window)
|
||||
if start < window_start:
|
||||
weight = (end - window_start).total_seconds() / self._time_window
|
||||
else:
|
||||
weight = (end - start).total_seconds() / self._time_window
|
||||
return weight
|
||||
|
||||
# If outside of time window just report derivative (is the same as modeling it in the window),
|
||||
# otherwise take the weighted average with the previous derivatives
|
||||
if elapsed_time > self._time_window:
|
||||
derivative = new_derivative
|
||||
else:
|
||||
derivative = Decimal("0.00")
|
||||
for start, end, value in self._state_list:
|
||||
weight = calculate_weight(start, end, new_state.last_reported)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
derivative = self._calc_derivative_from_state_list(
|
||||
new_state.last_reported
|
||||
)
|
||||
self._attr_native_value = round(derivative, self._round_digits)
|
||||
self.async_write_ha_state()
|
||||
|
||||
if self._max_sub_interval is not None:
|
||||
source_state = self.hass.states.get(self._sensor_source_id)
|
||||
schedule_max_sub_interval_exceeded(source_state)
|
||||
|
||||
@callback
|
||||
def on_removed() -> None:
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
|
||||
self.async_on_remove(on_removed)
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, self._sensor_source_id, on_state_changed
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"title": "Create Derivative sensor",
|
||||
"description": "Create a sensor that estimates the derivative of a sensor.",
|
||||
"data": {
|
||||
"max_sub_interval": "Max sub-interval",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"round": "Precision",
|
||||
"source": "Input sensor",
|
||||
@@ -14,6 +15,7 @@
|
||||
"unit_time": "Time unit"
|
||||
},
|
||||
"data_description": {
|
||||
"max_sub_interval": "If defined, derivative automatically recalculates if the source has not updated for this duration.",
|
||||
"round": "Controls the number of decimal digits in the output.",
|
||||
"time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.",
|
||||
"unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative."
|
||||
@@ -25,6 +27,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"max_sub_interval": "[%key:component::derivative::config::step::user::data::max_sub_interval%]",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"round": "[%key:component::derivative::config::step::user::data::round%]",
|
||||
"source": "[%key:component::derivative::config::step::user::data::source%]",
|
||||
@@ -33,6 +36,7 @@
|
||||
"unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]"
|
||||
},
|
||||
"data_description": {
|
||||
"max_sub_interval": "[%key:component::derivative::config::step::user::data_description::max_sub_interval%]",
|
||||
"round": "[%key:component::derivative::config::step::user::data_description::round%]",
|
||||
"time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]",
|
||||
"unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user