mirror of
https://github.com/home-assistant/core.git
synced 2026-01-10 17:18:36 +00:00
Compare commits
606 Commits
input_bool
...
disable_py
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdefbf68ad | ||
|
|
193c02a253 | ||
|
|
8d376027bf | ||
|
|
47e91bc2ec | ||
|
|
33d1cdd0ac | ||
|
|
f46de054ba | ||
|
|
741aa714dd | ||
|
|
5fac7d4ffb | ||
|
|
341c441e61 | ||
|
|
a1edf0a77c | ||
|
|
dd84b52c7b | ||
|
|
43ced677e5 | ||
|
|
7a696935ed | ||
|
|
be3be360a7 | ||
|
|
092ebaaeb1 | ||
|
|
e8025317ed | ||
|
|
39b025dfea | ||
|
|
1b436a8808 | ||
|
|
a7440e3756 | ||
|
|
2c7852f94b | ||
|
|
bd4653f830 | ||
|
|
c0b2847a87 | ||
|
|
8853f6698b | ||
|
|
b1a3ad6ac3 | ||
|
|
dafa2e69e2 | ||
|
|
2c6d6f8ab4 | ||
|
|
10d32b7f23 | ||
|
|
e4dc4e0ced | ||
|
|
6f9794f235 | ||
|
|
b8cff13737 | ||
|
|
7777714cc0 | ||
|
|
f15d5cdf2a | ||
|
|
6181f4e7de | ||
|
|
80df3b5b80 | ||
|
|
6e32a2aa18 | ||
|
|
3b575fe3e3 | ||
|
|
229400de98 | ||
|
|
e963adfdf0 | ||
|
|
fd7bbc68c6 | ||
|
|
9281ab018c | ||
|
|
80baf86e23 | ||
|
|
db497b23fe | ||
|
|
a2fb8f5a72 | ||
|
|
6953bd4599 | ||
|
|
225be65f71 | ||
|
|
7b0463f763 | ||
|
|
4d305b657a | ||
|
|
d5a553c8c7 | ||
|
|
9169b68254 | ||
|
|
fde9bd95d5 | ||
|
|
e4db8ff86e | ||
|
|
a084e51345 | ||
|
|
00381e6dfd | ||
|
|
b6d493696a | ||
|
|
5f0500c3cd | ||
|
|
c61a63cc6f | ||
|
|
5445a4f40f | ||
|
|
2888cacc3f | ||
|
|
16f3e6d2c9 | ||
|
|
7a872970fa | ||
|
|
4f5ca986ce | ||
|
|
b58e058da5 | ||
|
|
badebe0c7f | ||
|
|
7817ec1a52 | ||
|
|
c773998946 | ||
|
|
2bc9397103 | ||
|
|
685534b17c | ||
|
|
c740f44bfa | ||
|
|
ce471d0222 | ||
|
|
53ed344fe0 | ||
|
|
5f8f3c961a | ||
|
|
9d0c5530f2 | ||
|
|
d114fe4fbd | ||
|
|
f03d44d5b5 | ||
|
|
35f4464d4a | ||
|
|
fc2530e979 | ||
|
|
354fafda1a | ||
|
|
5b0dab479d | ||
|
|
1e1f414849 | ||
|
|
7c81df6c5c | ||
|
|
95d7c42e6a | ||
|
|
19fd80035e | ||
|
|
8e30787ae6 | ||
|
|
7133da928f | ||
|
|
3f9a41d393 | ||
|
|
f4caf36204 | ||
|
|
079866e384 | ||
|
|
dce0db78aa | ||
|
|
fffc18d28b | ||
|
|
c7cbcbc32d | ||
|
|
aebcdd6e7a | ||
|
|
3be92510f8 | ||
|
|
4d8448e82a | ||
|
|
625bc467d4 | ||
|
|
47573b7f6a | ||
|
|
7c95c92525 | ||
|
|
1aed46e39e | ||
|
|
6659166df0 | ||
|
|
1e6b0ba9ec | ||
|
|
1f23098638 | ||
|
|
98ee0421b7 | ||
|
|
6aaa57f660 | ||
|
|
fad817853f | ||
|
|
7ef7d3f570 | ||
|
|
14bca5a052 | ||
|
|
18769730f0 | ||
|
|
de6d117d9a | ||
|
|
d2deef968a | ||
|
|
6cae1821fb | ||
|
|
8d8046d233 | ||
|
|
d7a9a980d0 | ||
|
|
ff8ad0c9ba | ||
|
|
27728cdca8 | ||
|
|
f1eaf78923 | ||
|
|
667b1db594 | ||
|
|
d6cad546e1 | ||
|
|
4c8ffa2158 | ||
|
|
933fae9ade | ||
|
|
b6dd9db76e | ||
|
|
11487d6856 | ||
|
|
920e938d84 | ||
|
|
afc256622a | ||
|
|
bfef048a7c | ||
|
|
bfc8111728 | ||
|
|
ebd6ae7e80 | ||
|
|
dd98a85300 | ||
|
|
6568a19ce6 | ||
|
|
83c1e8d5b5 | ||
|
|
c5a06657a3 | ||
|
|
25e54990d2 | ||
|
|
3b2a7ba561 | ||
|
|
8f8f896675 | ||
|
|
9539a612a6 | ||
|
|
d6751eb63f | ||
|
|
b462038126 | ||
|
|
ce06446376 | ||
|
|
8de22e0134 | ||
|
|
fbd08d4e42 | ||
|
|
32e0be4535 | ||
|
|
0423639833 | ||
|
|
1244d8aa33 | ||
|
|
38c37ab33c | ||
|
|
1636eab2e8 | ||
|
|
737a5811a9 | ||
|
|
5f2da20319 | ||
|
|
2aed4fb8e9 | ||
|
|
2b10dc4545 | ||
|
|
b5d22a63bb | ||
|
|
e8e19f47cd | ||
|
|
97e6643cd7 | ||
|
|
ee4bb0eef5 | ||
|
|
f82bb8f0b8 | ||
|
|
79b368cfc3 | ||
|
|
6da4a006f2 | ||
|
|
e5f3ccb38d | ||
|
|
560b91b93b | ||
|
|
edd9f50562 | ||
|
|
a4b2e84b03 | ||
|
|
9da07c2058 | ||
|
|
8de6785182 | ||
|
|
77f6fa8116 | ||
|
|
6b6f338e7e | ||
|
|
aa995fb590 | ||
|
|
f0fee87b9e | ||
|
|
56ab3bf59b | ||
|
|
24e2720924 | ||
|
|
bacc2f00af | ||
|
|
6de2d6810b | ||
|
|
de07833d92 | ||
|
|
b4eff231c3 | ||
|
|
98fea46eea | ||
|
|
18e8821891 | ||
|
|
cc2377d44d | ||
|
|
8370c6abfb | ||
|
|
2d1a672de5 | ||
|
|
75ea42a834 | ||
|
|
45491e17cd | ||
|
|
b994f03391 | ||
|
|
473cb59013 | ||
|
|
9302926d99 | ||
|
|
d92516b7c9 | ||
|
|
5b561213d3 | ||
|
|
0a16bd4919 | ||
|
|
f74a6e2625 | ||
|
|
ecc271409a | ||
|
|
1f63bc3231 | ||
|
|
78adeb837e | ||
|
|
bfacf462bf | ||
|
|
771d40dbf6 | ||
|
|
8e441242ad | ||
|
|
b8a4237ab1 | ||
|
|
e92af1ee76 | ||
|
|
e561c1cebb | ||
|
|
d77f82f8e8 | ||
|
|
fcc3598d7f | ||
|
|
a1a1d65ee4 | ||
|
|
8778d4c704 | ||
|
|
7790a2ebdd | ||
|
|
585c2dce16 | ||
|
|
08d25d388f | ||
|
|
f06f25b99a | ||
|
|
f11791f84d | ||
|
|
8a3c0edb59 | ||
|
|
e7176c4919 | ||
|
|
3327c3513b | ||
|
|
8ca87ef1cb | ||
|
|
d90e72c6d4 | ||
|
|
4083bd3c94 | ||
|
|
fd2a92ffce | ||
|
|
ac2941569e | ||
|
|
043465e42f | ||
|
|
ec5657753f | ||
|
|
e2fa95694f | ||
|
|
2e28796ab0 | ||
|
|
bdb456e568 | ||
|
|
e1599dc53a | ||
|
|
0b10c36521 | ||
|
|
f8dd05efde | ||
|
|
9e0b4c2beb | ||
|
|
315c7db527 | ||
|
|
f3832442be | ||
|
|
97d1c18f21 | ||
|
|
307aea90d6 | ||
|
|
7b8d65b91f | ||
|
|
24f253f775 | ||
|
|
f2a4d55439 | ||
|
|
33975f7c7f | ||
|
|
8c9a6ccd6d | ||
|
|
6fc3e2dc53 | ||
|
|
28c14f21fa | ||
|
|
ca912699e3 | ||
|
|
96d1e3d260 | ||
|
|
0ea38335d7 | ||
|
|
86be5d9dc3 | ||
|
|
01d4c42138 | ||
|
|
a8114b7e4f | ||
|
|
be7b7f3d25 | ||
|
|
f9481b6e51 | ||
|
|
183bc31125 | ||
|
|
46befc257a | ||
|
|
097d190750 | ||
|
|
5df03851df | ||
|
|
de97a949ac | ||
|
|
e9d2f6add2 | ||
|
|
5aa0eefd5f | ||
|
|
7d17f0a00c | ||
|
|
e329eab514 | ||
|
|
3b0ebcaa9e | ||
|
|
9595cf30bb | ||
|
|
efee51548f | ||
|
|
6db227e4ab | ||
|
|
4c6074621f | ||
|
|
584687f7c4 | ||
|
|
e16335f15b | ||
|
|
960049e7d3 | ||
|
|
0f61b68324 | ||
|
|
fc5c31b348 | ||
|
|
cba33133cd | ||
|
|
9631528c87 | ||
|
|
d8b2d026c3 | ||
|
|
35ba9c7007 | ||
|
|
cdb7b9cc25 | ||
|
|
ea9ac1dd36 | ||
|
|
8508d48d79 | ||
|
|
2516e80663 | ||
|
|
70096d435a | ||
|
|
7cfd58dce2 | ||
|
|
2fdfcd6bad | ||
|
|
337789cd8c | ||
|
|
d87528e068 | ||
|
|
dc119d47c5 | ||
|
|
8aa897b090 | ||
|
|
7931cb4773 | ||
|
|
559d42dc27 | ||
|
|
cfe6cf2448 | ||
|
|
85ef06f26c | ||
|
|
25fc41a934 | ||
|
|
12047e8499 | ||
|
|
702fd78d86 | ||
|
|
375b0186db | ||
|
|
efd6b686a8 | ||
|
|
e68ef21522 | ||
|
|
4f589b144d | ||
|
|
baa4685df1 | ||
|
|
0a6d433594 | ||
|
|
ed72e0d4a7 | ||
|
|
bcdcc1208e | ||
|
|
dd53a82fd5 | ||
|
|
b61c6d1edd | ||
|
|
ceeec6817e | ||
|
|
74370bf3ba | ||
|
|
07ef6110ca | ||
|
|
9e68800564 | ||
|
|
c8af5bb452 | ||
|
|
58069fd473 | ||
|
|
822227f740 | ||
|
|
36eece93ee | ||
|
|
31631bb619 | ||
|
|
82e6b52129 | ||
|
|
9298b7787f | ||
|
|
f883eeebf3 | ||
|
|
93fe23081d | ||
|
|
ca064bf09b | ||
|
|
0addd82bf7 | ||
|
|
4686968275 | ||
|
|
7f28f09616 | ||
|
|
1e9af4fbe0 | ||
|
|
5399655134 | ||
|
|
cfaba23412 | ||
|
|
c7fa557148 | ||
|
|
2b6abb372c | ||
|
|
1ea8023753 | ||
|
|
14e79ff311 | ||
|
|
b57e848d5d | ||
|
|
938d6b6b0d | ||
|
|
31de4a4fa2 | ||
|
|
88b5b37f07 | ||
|
|
17ddba98c1 | ||
|
|
71fd1d079b | ||
|
|
08a8836d29 | ||
|
|
c5261c5bb5 | ||
|
|
a82d00475c | ||
|
|
d62251f0a3 | ||
|
|
dc88502894 | ||
|
|
a8a8017d35 | ||
|
|
de224b8107 | ||
|
|
841baa15b6 | ||
|
|
9a9c968cd2 | ||
|
|
f0ddb9ff2c | ||
|
|
8f6d88f517 | ||
|
|
20b0b6beb4 | ||
|
|
2980187206 | ||
|
|
97998ff61f | ||
|
|
b1189a33fe | ||
|
|
90cf2c7592 | ||
|
|
9b56229d34 | ||
|
|
848de08baa | ||
|
|
7d9bee8cea | ||
|
|
3a712f6512 | ||
|
|
40d566f7f7 | ||
|
|
60ba1b0288 | ||
|
|
c1d77f00b3 | ||
|
|
96b2146f2b | ||
|
|
83b53e7bc7 | ||
|
|
456a12f612 | ||
|
|
a8c732047d | ||
|
|
d9fe37e325 | ||
|
|
c41d14fbe7 | ||
|
|
cf9444dc64 | ||
|
|
a447217b03 | ||
|
|
e639ebc269 | ||
|
|
45ba7e0df1 | ||
|
|
dfdcdbc856 | ||
|
|
ea5df92ab9 | ||
|
|
9d1f500d65 | ||
|
|
a82f500934 | ||
|
|
71d29ba28e | ||
|
|
7910f33140 | ||
|
|
91ebeb84e7 | ||
|
|
1664dd5702 | ||
|
|
5e96ec820f | ||
|
|
5eedef4920 | ||
|
|
71728ba37e | ||
|
|
5657bd11b8 | ||
|
|
cd6bb861a8 | ||
|
|
3175c149c6 | ||
|
|
bfe1e70e06 | ||
|
|
0a4c75951a | ||
|
|
fb6380157a | ||
|
|
8d2b925131 | ||
|
|
5359a8bf26 | ||
|
|
be966f1196 | ||
|
|
2d6ae8f907 | ||
|
|
2683b893c4 | ||
|
|
2b5de0db01 | ||
|
|
83d4f8eedc | ||
|
|
89247e9069 | ||
|
|
5f996e700f | ||
|
|
8f44eb6652 | ||
|
|
31dadd3102 | ||
|
|
4c74a57b63 | ||
|
|
750744f332 | ||
|
|
03442c5e51 | ||
|
|
26774c20c7 | ||
|
|
29201ac5d6 | ||
|
|
a6938127ea | ||
|
|
65d7f22072 | ||
|
|
0730c707e9 | ||
|
|
2e3eb0f9af | ||
|
|
2b5823c264 | ||
|
|
95165022db | ||
|
|
7c71c0377f | ||
|
|
b07b699e79 | ||
|
|
34db548725 | ||
|
|
5150efd63f | ||
|
|
0525c75686 | ||
|
|
7c14862f62 | ||
|
|
19f8d9d41b | ||
|
|
af1218876c | ||
|
|
9715a7cc32 | ||
|
|
b87b72ab01 | ||
|
|
0f3f16fabe | ||
|
|
85311e3def | ||
|
|
a33a4b6d9d | ||
|
|
02f412feb1 | ||
|
|
b3c78d4207 | ||
|
|
a3dec29c72 | ||
|
|
aa20a74a76 | ||
|
|
c0fa6ad2e0 | ||
|
|
5107b7012d | ||
|
|
bcc5985c8b | ||
|
|
5933c09a1d | ||
|
|
5f1e6f3633 | ||
|
|
6bd8d123ed | ||
|
|
50a51b5ecc | ||
|
|
c115b418ac | ||
|
|
2160827a50 | ||
|
|
82d84d7adf | ||
|
|
3e498d289b | ||
|
|
e6d8092c37 | ||
|
|
2e4f95c099 | ||
|
|
9f54b09423 | ||
|
|
8361d65d23 | ||
|
|
7a82aa4803 | ||
|
|
02ab11c1bd | ||
|
|
64f0a615df | ||
|
|
3e889616f2 | ||
|
|
bdbe2a6346 | ||
|
|
016d492342 | ||
|
|
9ce46c0937 | ||
|
|
8d96aee96e | ||
|
|
7083a0fdb7 | ||
|
|
e3976923b2 | ||
|
|
0b20417895 | ||
|
|
ed46c30b10 | ||
|
|
38f4cf0575 | ||
|
|
7b60cc3a80 | ||
|
|
fe0c92b6c5 | ||
|
|
c4386b4360 | ||
|
|
d4d26bccc1 | ||
|
|
550b7bf7ba | ||
|
|
6ff472ff87 | ||
|
|
ca30d8b1c2 | ||
|
|
aae98a77d5 | ||
|
|
30b7b24ddd | ||
|
|
a972a6d43a | ||
|
|
6e06c015df | ||
|
|
01c3e88e0f | ||
|
|
fd9064376a | ||
|
|
9eb5d452cf | ||
|
|
966209e4b6 | ||
|
|
a09ac94db9 | ||
|
|
0710cf3e6b | ||
|
|
a81f2a63c0 | ||
|
|
6ef2d0d0a3 | ||
|
|
911ea67a6d | ||
|
|
28dc32d5dc | ||
|
|
c95416cb48 | ||
|
|
7dc9084f06 | ||
|
|
39ba36d642 | ||
|
|
5009560f57 | ||
|
|
41e88573bb | ||
|
|
27ee986b1b | ||
|
|
c9d21c1851 | ||
|
|
2afbdc5757 | ||
|
|
14cb8af9fe | ||
|
|
74ae0f8297 | ||
|
|
3050a5c896 | ||
|
|
9f886e66c7 | ||
|
|
3c752d4516 | ||
|
|
e4bfdf5b30 | ||
|
|
3e43424a73 | ||
|
|
62ec64c3fe | ||
|
|
cbc6306963 | ||
|
|
e098acfa69 | ||
|
|
52630ccca1 | ||
|
|
3001dcb8ff | ||
|
|
cec5134369 | ||
|
|
80f2889e1f | ||
|
|
188c98fd08 | ||
|
|
e086e013d5 | ||
|
|
3c20df961e | ||
|
|
9f31d95940 | ||
|
|
d5cbc6efca | ||
|
|
793877bfeb | ||
|
|
692847d9a8 | ||
|
|
31785bf68f | ||
|
|
d17ed3ed95 | ||
|
|
7bbeb2a006 | ||
|
|
7275be4629 | ||
|
|
37a32bf27d | ||
|
|
00b7138c43 | ||
|
|
1b464e799b | ||
|
|
1a56855158 | ||
|
|
0dac52cbe4 | ||
|
|
63cb220a8f | ||
|
|
af72bc4d2a | ||
|
|
108d94ab06 | ||
|
|
d64313cd28 | ||
|
|
b608dcb2eb | ||
|
|
e0fa5db218 | ||
|
|
96d2ecf250 | ||
|
|
b0fac94666 | ||
|
|
8902ba9f1d | ||
|
|
581919ccb4 | ||
|
|
7714b51c21 | ||
|
|
8ee94f829a | ||
|
|
73734d2ff2 | ||
|
|
b7d4c3c5d1 | ||
|
|
5d30fc3436 | ||
|
|
4cced81f86 | ||
|
|
81d10d02de | ||
|
|
73484cb8fb | ||
|
|
d0aaac0382 | ||
|
|
67550731b3 | ||
|
|
04746b6843 | ||
|
|
0547153730 | ||
|
|
eb024b4dde | ||
|
|
1d4817608e | ||
|
|
a37ca293e1 | ||
|
|
f3dbddee16 | ||
|
|
b26681ee88 | ||
|
|
effe72bfda | ||
|
|
076835ca1c | ||
|
|
4b9b1e611a | ||
|
|
0b4ea42810 | ||
|
|
8907608345 | ||
|
|
356ee07e22 | ||
|
|
bee3ee6320 | ||
|
|
fb72ff9bd0 | ||
|
|
412e05d8da | ||
|
|
58ee8e863e | ||
|
|
e3a47bfc51 | ||
|
|
a6cdacc8fe | ||
|
|
dd0425ab8e | ||
|
|
1d289c0083 | ||
|
|
70786a1d90 | ||
|
|
293eb69788 | ||
|
|
71d92291d1 | ||
|
|
726de64394 | ||
|
|
de04f22f89 | ||
|
|
9e8cc3a65b | ||
|
|
27fa92b607 | ||
|
|
ce5c5c5eb7 | ||
|
|
88e29df8eb | ||
|
|
a2b5744696 | ||
|
|
201c3785f5 | ||
|
|
24de26cbf5 | ||
|
|
ac0a544829 | ||
|
|
1a11b92f05 | ||
|
|
ab0811f59f | ||
|
|
68711b2f21 | ||
|
|
886e2b0af1 | ||
|
|
7492b5be75 | ||
|
|
e4f1565e3c | ||
|
|
7f37412199 | ||
|
|
eaef0160a2 | ||
|
|
f049c425ba | ||
|
|
50eee75b8f | ||
|
|
81e47f6844 | ||
|
|
ffebbab020 | ||
|
|
9824bdc1c9 | ||
|
|
a933d4a0eb | ||
|
|
f7f7f9a2de | ||
|
|
aac412f3a8 | ||
|
|
660a14e78d | ||
|
|
4aa3f0a400 | ||
|
|
0b52c806d4 | ||
|
|
bbe27d86a1 | ||
|
|
fb7941df1d | ||
|
|
c46e341941 | ||
|
|
2e3a9e3a90 | ||
|
|
55c5ecd28a | ||
|
|
e50e2487e1 | ||
|
|
74e118f85c | ||
|
|
39a62ec2f6 | ||
|
|
1310efcb07 | ||
|
|
53af592c2c | ||
|
|
023987b805 | ||
|
|
5b8fb607b4 | ||
|
|
252f6716ff | ||
|
|
bf78e28f83 | ||
|
|
22706d02a7 | ||
|
|
5cff0e946a | ||
|
|
6cbe2ed279 | ||
|
|
fb0f5f52b2 | ||
|
|
5c422bb770 | ||
|
|
fd1bc07b8c | ||
|
|
97a019d313 | ||
|
|
8ae8a564c2 | ||
|
|
2f72f57bb7 | ||
|
|
e928e3cb54 | ||
|
|
b0e2109e15 | ||
|
|
b449c6673f | ||
|
|
877ad38ac3 | ||
|
|
229f45feae | ||
|
|
a535d1f4eb | ||
|
|
d4adc00ae6 | ||
|
|
ba141f9d1d | ||
|
|
72be9793a4 | ||
|
|
5ae7cc5f84 | ||
|
|
d01a469b46 | ||
|
|
9f07052874 | ||
|
|
b9bc9d3fc2 | ||
|
|
1e180cd5ee | ||
|
|
dc9cdd13b1 |
@@ -40,7 +40,8 @@
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
// Pyright type checking is too not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
|
||||
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
HA_SHORT_VERSION: "2026.2"
|
||||
DEFAULT_PYTHON: "3.13.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -92,6 +92,7 @@ pip-selfcheck.json
|
||||
venv
|
||||
.venv
|
||||
Pipfile*
|
||||
uv.lock
|
||||
share/*
|
||||
/Scripts/
|
||||
|
||||
|
||||
4
.vscode/settings.default.jsonc
vendored
4
.vscode/settings.default.jsonc
vendored
@@ -7,8 +7,8 @@
|
||||
"python.testing.pytestEnabled": false,
|
||||
// https://code.visualstudio.com/docs/python/linting#_general-settings
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
// Pyright is too pedantic for Home Assistant
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
// Pyright type checking is too not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
},
|
||||
|
||||
15
CODEOWNERS
generated
15
CODEOWNERS
generated
@@ -516,6 +516,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fireservicerota/ @cyberjunky
|
||||
/homeassistant/components/firmata/ @DaAwesomeP
|
||||
/tests/components/firmata/ @DaAwesomeP
|
||||
/homeassistant/components/fish_audio/ @noambav
|
||||
/tests/components/fish_audio/ @noambav
|
||||
/homeassistant/components/fitbit/ @allenporter
|
||||
/tests/components/fitbit/ @allenporter
|
||||
/homeassistant/components/fivem/ @Sander0542
|
||||
@@ -530,6 +532,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/flo/ @dmulcahey
|
||||
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||
/homeassistant/components/fluss/ @fluss
|
||||
/tests/components/fluss/ @fluss
|
||||
/homeassistant/components/flux_led/ @icemanch
|
||||
/tests/components/flux_led/ @icemanch
|
||||
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
|
||||
@@ -657,6 +661,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
||||
/homeassistant/components/hassio/ @home-assistant/supervisor
|
||||
/tests/components/hassio/ @home-assistant/supervisor
|
||||
/homeassistant/components/hdfury/ @glenndehaan
|
||||
/tests/components/hdfury/ @glenndehaan
|
||||
/homeassistant/components/hdmi_cec/ @inytar
|
||||
/tests/components/hdmi_cec/ @inytar
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
@@ -794,6 +800,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/intellifire/ @jeeftor
|
||||
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
/homeassistant/components/intent_script/ @arturpragacz
|
||||
/tests/components/intent_script/ @arturpragacz
|
||||
/homeassistant/components/intesishome/ @jnimmo
|
||||
/homeassistant/components/iometer/ @jukrebs
|
||||
/tests/components/iometer/ @jukrebs
|
||||
@@ -1164,6 +1172,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openevse/ @c00w @firstof9
|
||||
/tests/components/openevse/ @c00w @firstof9
|
||||
/homeassistant/components/openexchangerates/ @MartinHjelmare
|
||||
/tests/components/openexchangerates/ @MartinHjelmare
|
||||
/homeassistant/components/opengarage/ @danielhiversen
|
||||
@@ -1693,8 +1703,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trafikverket_train/ @gjohansson-ST
|
||||
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins
|
||||
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
|
||||
/homeassistant/components/trend/ @jpbede
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
@@ -1795,6 +1805,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/waqi/ @joostlek
|
||||
/homeassistant/components/water_heater/ @home-assistant/core
|
||||
/tests/components/water_heater/ @home-assistant/core
|
||||
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
|
||||
/homeassistant/components/watergate/ @adam-the-hero
|
||||
/tests/components/watergate/ @adam-the-hero
|
||||
/homeassistant/components/watson_tts/ @rutkai
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.2.2"]
|
||||
"requirements": ["accuweather==5.0.0"]
|
||||
}
|
||||
|
||||
@@ -15,12 +15,10 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity, ActronAirZoneEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -56,8 +54,7 @@ async def async_setup_entry(
|
||||
|
||||
for coordinator in system_coordinators.values():
|
||||
status = coordinator.data
|
||||
name = status.ac_system.system_name
|
||||
entities.append(ActronSystemClimate(coordinator, name))
|
||||
entities.append(ActronSystemClimate(coordinator))
|
||||
|
||||
entities.extend(
|
||||
ActronZoneClimate(coordinator, zone)
|
||||
@@ -68,10 +65,9 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
|
||||
class ActronAirClimateEntity(ClimateEntity):
|
||||
"""Base class for Actron Air climate entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
@@ -83,43 +79,17 @@ class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEn
|
||||
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
|
||||
|
||||
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
"""Representation of the Actron Air system."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_number = coordinator.serial_number
|
||||
|
||||
|
||||
class ActronSystemClimate(BaseClimateEntity):
|
||||
"""Representation of the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator, name)
|
||||
serial_number = coordinator.serial_number
|
||||
self._attr_unique_id = serial_number
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=self._status.ac_system.system_name,
|
||||
manufacturer="Actron Air",
|
||||
model_id=self._status.ac_system.master_wc_model,
|
||||
sw_version=self._status.ac_system.master_wc_firmware_version,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
self._attr_unique_id = self._serial_number
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
@@ -168,7 +138,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set a new fan mode."""
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
|
||||
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
@@ -182,7 +152,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
||||
await self._status.user_aircon_settings.set_temperature(temperature=temp)
|
||||
|
||||
|
||||
class ActronZoneClimate(BaseClimateEntity):
|
||||
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
"""Representation of a zone within the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
@@ -197,18 +167,8 @@ class ActronZoneClimate(BaseClimateEntity):
|
||||
zone: ActronAirZone,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator, zone.title)
|
||||
serial_number = coordinator.serial_number
|
||||
self._zone_id: int = zone.zone_id
|
||||
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=zone.title,
|
||||
manufacturer="Actron Air",
|
||||
model="Zone",
|
||||
suggested_area=zone.title,
|
||||
via_device=(DOMAIN, serial_number),
|
||||
)
|
||||
super().__init__(coordinator, zone)
|
||||
self._attr_unique_id: str = self._zone_identifier
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
@@ -256,4 +216,4 @@ class ActronZoneClimate(BaseClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
await self._zone.set_temperature(temperature=kwargs["temperature"])
|
||||
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import timedelta
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAPIError,
|
||||
ActronAirAuthError,
|
||||
ActronAirStatus,
|
||||
)
|
||||
@@ -15,7 +16,7 @@ from actron_neo_api import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
@@ -70,6 +71,12 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
|
||||
63
homeassistant/components/actron_air/entity.py
Normal file
63
homeassistant/components/actron_air/entity.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Base entity classes for Actron Air integration."""
|
||||
|
||||
from actron_neo_api import ActronAirZone
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirSystemCoordinator
|
||||
|
||||
|
||||
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
|
||||
"""Base class for Actron Air entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_number = coordinator.serial_number
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return not self.coordinator.is_device_stale()
|
||||
|
||||
|
||||
class ActronAirAcEntity(ActronAirEntity):
|
||||
"""Base class for Actron Air entities."""
|
||||
|
||||
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._serial_number)},
|
||||
name=coordinator.data.ac_system.system_name,
|
||||
manufacturer="Actron Air",
|
||||
model_id=coordinator.data.ac_system.master_wc_model,
|
||||
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
|
||||
serial_number=self._serial_number,
|
||||
)
|
||||
|
||||
|
||||
class ActronAirZoneEntity(ActronAirEntity):
|
||||
"""Base class for Actron Air zone entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
zone: ActronAirZone,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id: int = zone.zone_id
|
||||
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._zone_identifier)},
|
||||
name=zone.title,
|
||||
manufacturer="Actron Air",
|
||||
model="Zone",
|
||||
suggested_area=zone.title,
|
||||
via_device=(DOMAIN, self._serial_number),
|
||||
)
|
||||
@@ -51,6 +51,9 @@
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ from typing import Any
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -74,10 +72,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
|
||||
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
|
||||
"""Actron Air switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: ActronAirSwitchEntityDescription
|
||||
|
||||
@@ -90,11 +87,6 @@ class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntit
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.serial_number)},
|
||||
manufacturer="Actron Air",
|
||||
name=coordinator.data.ac_system.system_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"],
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
||||
|
||||
89
homeassistant/components/airobot/button.py
Normal file
89
homeassistant/components/airobot/button.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Button platform for Airobot integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyairobotrest.exceptions import (
|
||||
AirobotConnectionError,
|
||||
AirobotError,
|
||||
AirobotTimeoutError,
|
||||
)
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
||||
from .entity import AirobotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirobotButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Airobot button entity."""
|
||||
|
||||
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
|
||||
AirobotButtonEntityDescription(
|
||||
key="restart",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot button entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirobotButton(coordinator, description) for description in BUTTON_TYPES
|
||||
)
|
||||
|
||||
|
||||
class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Representation of an Airobot button."""
|
||||
|
||||
entity_description: AirobotButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirobotDataUpdateCoordinator,
|
||||
description: AirobotButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except (AirobotConnectionError, AirobotTimeoutError):
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
pass
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_press_failed",
|
||||
translation_placeholders={"button": self.entity_description.key},
|
||||
) from err
|
||||
@@ -175,6 +175,42 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Verify the device ID matches the existing config entry
|
||||
await self.async_set_unique_id(info.device_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_device")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=user_input,
|
||||
title=info.title,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
9
homeassistant/components/airobot/icons.json
Normal file
9
homeassistant/components/airobot/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
"requirements": ["pyairobotrest==0.2.0"]
|
||||
}
|
||||
|
||||
99
homeassistant/components/airobot/number.py
Normal file
99
homeassistant/components/airobot/number.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Number platform for Airobot thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN
|
||||
from pyairobotrest.exceptions import AirobotError
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AirobotConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirobotDataUpdateCoordinator
|
||||
from .entity import AirobotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirobotNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes Airobot number entity."""
|
||||
|
||||
value_fn: Callable[[AirobotDataUpdateCoordinator], float]
|
||||
set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]]
|
||||
|
||||
|
||||
NUMBERS: tuple[AirobotNumberEntityDescription, ...] = (
|
||||
AirobotNumberEntityDescription(
|
||||
key="hysteresis_band",
|
||||
translation_key="hysteresis_band",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
native_min_value=HYSTERESIS_BAND_MIN / 10.0,
|
||||
native_max_value=HYSTERESIS_BAND_MAX / 10.0,
|
||||
native_step=0.1,
|
||||
value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band,
|
||||
set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band(
|
||||
value
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot number platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AirobotNumber(coordinator, description) for description in NUMBERS
|
||||
)
|
||||
|
||||
|
||||
class AirobotNumber(AirobotEntity, NumberEntity):
|
||||
"""Representation of an Airobot number entity."""
|
||||
|
||||
entity_description: AirobotNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirobotDataUpdateCoordinator,
|
||||
description: AirobotNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the current value."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
try:
|
||||
await self.entity_description.set_value_fn(self.coordinator, value)
|
||||
except AirobotError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_value_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -48,7 +48,7 @@ rules:
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Single device integration, no dynamic device discovery needed.
|
||||
@@ -57,8 +57,8 @@ rules:
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration doesn't have any cases where raising an issue is needed.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_device": "Device ID does not match the existing configuration. Please use the correct device credentials."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -28,6 +30,19 @@
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "Device ID"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::airobot::config::step::user::data_description::host%]",
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::airobot::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Update your Airobot thermostat connection details. Note: The Device ID must remain the same as the original configuration."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -44,6 +59,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"name": "Hysteresis band"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"name": "Air temperature"
|
||||
@@ -66,6 +86,9 @@
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"button_press_failed": {
|
||||
"message": "Failed to press {button} button."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
@@ -74,6 +97,9 @@
|
||||
},
|
||||
"set_temperature_failed": {
|
||||
"message": "Failed to set temperature to {temperature}."
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set value: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.0"]
|
||||
"requirements": ["airos==0.6.1"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
@@ -30,14 +30,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_credentials(
|
||||
auth: MSOB2CAuth, account_number: str
|
||||
) -> str | MSOB2CAuth:
|
||||
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
|
||||
"""Validate the provided credentials."""
|
||||
try:
|
||||
await auth.send_login_request()
|
||||
@@ -46,6 +43,33 @@ async def validate_credentials(
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
return auth
|
||||
|
||||
|
||||
def humanize_account_data(account: dict) -> str:
|
||||
"""Convert an account data into a human-readable format."""
|
||||
if account["address"]["company_name"] != "":
|
||||
return f"{account['account_number']} - {account['address']['company_name']}"
|
||||
if account["address"]["building_name"] != "":
|
||||
return f"{account['account_number']} - {account['address']['building_name']}"
|
||||
return f"{account['account_number']} - {account['address']['postcode']}"
|
||||
|
||||
|
||||
async def get_accounts(auth: MSOB2CAuth) -> list[selector.SelectOptionDict]:
|
||||
"""Retrieve the list of accounts associated with the authenticated user."""
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
accounts = await _aw.api.get_associated_accounts()
|
||||
return [
|
||||
selector.SelectOptionDict(
|
||||
value=str(account["account_number"]),
|
||||
label=humanize_account_data(account),
|
||||
)
|
||||
for account in accounts["result"]["active"]
|
||||
]
|
||||
|
||||
|
||||
async def validate_account(auth: MSOB2CAuth, account_number: str) -> str | MSOB2CAuth:
|
||||
"""Validate the provided account number."""
|
||||
_aw = AnglianWater(authenticator=auth)
|
||||
try:
|
||||
await _aw.validate_smart_meter(account_number)
|
||||
@@ -57,36 +81,91 @@ async def validate_credentials(
|
||||
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anglian Water."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.authenticator: MSOB2CAuth | None = None
|
||||
self.accounts: list[selector.SelectOptionDict] = []
|
||||
self.user_input: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
validation_response = await validate_credentials(
|
||||
MSOB2CAuth(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
self.authenticator = MSOB2CAuth(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
validation_response = await validate_credentials(self.authenticator)
|
||||
if isinstance(validation_response, str):
|
||||
errors["base"] = validation_response
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_ACCOUNT_NUMBER],
|
||||
data={
|
||||
**user_input,
|
||||
CONF_ACCESS_TOKEN: validation_response.refresh_token,
|
||||
},
|
||||
self.accounts = await get_accounts(self.authenticator)
|
||||
if len(self.accounts) > 1:
|
||||
self.user_input = user_input
|
||||
return await self.async_step_select_account()
|
||||
account_number = self.accounts[0]["value"]
|
||||
self.user_input = user_input
|
||||
return await self.async_step_complete(
|
||||
{
|
||||
CONF_ACCOUNT_NUMBER: account_number,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_select_account(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the account selection step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if TYPE_CHECKING:
|
||||
assert self.authenticator
|
||||
validation_result = await validate_account(
|
||||
self.authenticator,
|
||||
user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
if isinstance(validation_result, str):
|
||||
errors["base"] = validation_result
|
||||
else:
|
||||
return await self.async_step_complete(user_input)
|
||||
return self.async_show_form(
|
||||
step_id="select_account",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=self.accounts,
|
||||
multiple=False,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_complete(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle the final configuration step."""
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
if TYPE_CHECKING:
|
||||
assert self.authenticator
|
||||
assert self.user_input
|
||||
config_entry_data = {
|
||||
**self.user_input,
|
||||
CONF_ACCOUNT_NUMBER: user_input[CONF_ACCOUNT_NUMBER],
|
||||
CONF_ACCESS_TOKEN: self.authenticator.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_ACCOUNT_NUMBER],
|
||||
data=config_entry_data,
|
||||
)
|
||||
|
||||
@@ -10,14 +10,21 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"select_account": {
|
||||
"data": {
|
||||
"account_number": "Billing account number"
|
||||
},
|
||||
"data_description": {
|
||||
"account_number": "Select the billing account you wish to use."
|
||||
},
|
||||
"description": "Multiple active billing accounts were found with your credentials. Please select the account you wish to use. If this is unexpected, contact Anglian Water to confirm your active accounts."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"account_number": "Billing Account Number",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"account_number": "Your account number found on your latest bill.",
|
||||
"password": "Your password",
|
||||
"username": "Username or email used to log in to the Anglian Water website."
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -16,7 +16,10 @@ from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
|
||||
from homeassistant.components.labs import async_listen as async_labs_listen
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_MODE,
|
||||
ATTR_NAME,
|
||||
CONF_ACTIONS,
|
||||
@@ -30,6 +33,7 @@ from homeassistant.const import (
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
CONF_ZONE,
|
||||
@@ -136,6 +140,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"person",
|
||||
"scene",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
@@ -587,20 +593,32 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return True if entity is on."""
|
||||
return self._async_detach_triggers is not None or self._is_enabled
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def referenced_labels(self) -> set[str]:
|
||||
"""Return a set of referenced labels."""
|
||||
return self.action_script.referenced_labels
|
||||
referenced = self.action_script.referenced_labels
|
||||
|
||||
@property
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_floors(self) -> set[str]:
|
||||
"""Return a set of referenced floors."""
|
||||
return self.action_script.referenced_floors
|
||||
referenced = self.action_script.referenced_floors
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_areas(self) -> set[str]:
|
||||
"""Return a set of referenced areas."""
|
||||
return self.action_script.referenced_areas
|
||||
referenced = self.action_script.referenced_areas
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
@@ -1208,6 +1226,9 @@ def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
|
||||
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
|
||||
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
|
||||
|
||||
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
|
||||
return target_devices
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -1238,9 +1259,28 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
|
||||
|
||||
if target_entities := _get_targets_from_trigger_config(
|
||||
trigger_conf, CONF_ENTITY_ID
|
||||
):
|
||||
return target_entities
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _get_targets_from_trigger_config(
|
||||
config: dict,
|
||||
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
||||
) -> list[str]:
|
||||
"""Extract targets from a target config."""
|
||||
if not (target_conf := config.get(CONF_TARGET)):
|
||||
return []
|
||||
if not (targets := target_conf.get(target)):
|
||||
return []
|
||||
|
||||
return [targets] if isinstance(targets, str) else targets
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==65"],
|
||||
"requirements": ["axis==66"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -80,7 +80,7 @@ class AzureDataExplorerClient:
|
||||
def test_connection(self) -> None:
|
||||
"""Test connection, will throw Exception if it cannot connect."""
|
||||
|
||||
query = f"{self._table} | take 1"
|
||||
query = f"['{self._table}'] | take 1"
|
||||
|
||||
self.query_client.execute_query(self._database, query)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def validate_input(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async def validate_input(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
@@ -54,36 +54,40 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(client.test_connection)
|
||||
|
||||
except KustoAuthenticationError as exp:
|
||||
_LOGGER.error(exp)
|
||||
except KustoAuthenticationError as err:
|
||||
_LOGGER.error("Authentication failed: %s", err)
|
||||
return {"base": "invalid_auth"}
|
||||
|
||||
except KustoServiceError as exp:
|
||||
_LOGGER.error(exp)
|
||||
except KustoServiceError as err:
|
||||
_LOGGER.error("Could not connect: %s", err)
|
||||
return {"base": "cannot_connect"}
|
||||
|
||||
return None
|
||||
return {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
|
||||
errors: dict = {}
|
||||
if user_input:
|
||||
errors = await self.validate_input(user_input) # type: ignore[assignment]
|
||||
errors: dict[str, str] = {}
|
||||
data_schema = STEP_USER_DATA_SCHEMA
|
||||
|
||||
if user_input is not None:
|
||||
errors = await self.validate_input(user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
data=user_input,
|
||||
title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
|
||||
"https://", ""
|
||||
),
|
||||
title=f"{user_input[CONF_ADX_CLUSTER_INGEST_URI].replace('https://', '')} / {user_input[CONF_ADX_DATABASE_NAME]} ({user_input[CONF_ADX_TABLE_NAME]})",
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
# Keep previously entered values when we re-show the form after an error.
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"use_queued_ingestion": "Use queued ingestion"
|
||||
},
|
||||
"data_description": {
|
||||
"authority_id": "In Azure portal this is also known as Directory (tenant) ID",
|
||||
"cluster_ingest_uri": "Ingestion URI of the cluster",
|
||||
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
|
||||
},
|
||||
|
||||
@@ -6,13 +6,15 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception
|
||||
from b2sdk.v2 import Bucket, exception
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
# Import from b2_client to ensure timeout configuration is applied
|
||||
from .b2_client import B2Api, InMemoryAccountInfo
|
||||
from .const import (
|
||||
BACKBLAZE_REALM,
|
||||
CONF_APPLICATION_KEY,
|
||||
@@ -72,7 +74,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except exception.ConnectionReset as err:
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
exception.ConnectionReset,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
|
||||
39
homeassistant/components/backblaze_b2/b2_client.py
Normal file
39
homeassistant/components/backblaze_b2/b2_client.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Backblaze B2 client with extended timeouts.
|
||||
|
||||
The b2sdk library uses class-level timeout attributes. To avoid modifying
|
||||
global library state, we subclass the relevant classes to provide extended
|
||||
timeouts suitable for backup operations involving large files.
|
||||
"""
|
||||
|
||||
from b2sdk.v2 import B2Api as BaseB2Api, InMemoryAccountInfo
|
||||
from b2sdk.v2.b2http import B2Http as BaseB2Http
|
||||
from b2sdk.v2.session import B2Session as BaseB2Session
|
||||
|
||||
# Extended timeouts for Home Assistant backup operations
|
||||
# Default CONNECTION_TIMEOUT is 46 seconds, which can be too short for slow connections
|
||||
CONNECTION_TIMEOUT = 120 # 2 minutes
|
||||
|
||||
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
|
||||
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
|
||||
|
||||
|
||||
class B2Http(BaseB2Http): # type: ignore[misc]
|
||||
"""B2Http with extended timeouts for backup operations."""
|
||||
|
||||
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
|
||||
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
|
||||
|
||||
|
||||
class B2Session(BaseB2Session): # type: ignore[misc]
|
||||
"""B2Session using custom B2Http with extended timeouts."""
|
||||
|
||||
B2HTTP_CLASS = B2Http
|
||||
|
||||
|
||||
class B2Api(BaseB2Api): # type: ignore[misc]
|
||||
"""B2Api using custom session with extended timeouts."""
|
||||
|
||||
SESSION_CLASS = B2Session
|
||||
|
||||
|
||||
__all__ = ["B2Api", "InMemoryAccountInfo"]
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2 import B2Api, InMemoryAccountInfo, exception
|
||||
from b2sdk.v2 import exception
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
@@ -17,6 +17,8 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
# Import from b2_client to ensure timeout configuration is applied
|
||||
from .b2_client import B2Api, InMemoryAccountInfo
|
||||
from .const import (
|
||||
BACKBLAZE_REALM,
|
||||
CONF_APPLICATION_KEY,
|
||||
@@ -172,8 +174,12 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
|
||||
)
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except exception.ConnectionReset:
|
||||
_LOGGER.error("Failed to connect to Backblaze B2. Connection reset")
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
exception.ConnectionReset,
|
||||
) as err:
|
||||
_LOGGER.error("Failed to connect to Backblaze B2: %s", err)
|
||||
errors["base"] = "cannot_connect"
|
||||
except exception.MissingAccountData:
|
||||
# This generally indicates an issue with how InMemoryAccountInfo is used
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["b2sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.8.1"]
|
||||
"requirements": ["b2sdk==2.10.1"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class BeoData:
|
||||
|
||||
type BeoConfigEntry = ConfigEntry[BeoData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
|
||||
@@ -115,6 +115,7 @@ class WebsocketNotification(StrEnum):
|
||||
"""Enum for WebSocket notification types."""
|
||||
|
||||
ACTIVE_LISTENING_MODE = "active_listening_mode"
|
||||
BATTERY = "battery"
|
||||
BEO_REMOTE_BUTTON = "beo_remote_button"
|
||||
BUTTON = "button"
|
||||
PLAYBACK_ERROR = "playback_error"
|
||||
|
||||
@@ -6,13 +6,14 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .util import get_device_buttons
|
||||
from .util import get_device_buttons, get_remote_keys, get_remotes
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -53,4 +54,47 @@ async def async_get_config_entry_diagnostics(
|
||||
state_dict.pop("context")
|
||||
data[f"{device_button}_event"] = state_dict
|
||||
|
||||
# Get remotes
|
||||
for remote in await get_remotes(config_entry.runtime_data.client):
|
||||
# Get Battery Sensor states
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level",
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data[f"remote_{remote.serial_number}_battery_level"] = state_dict
|
||||
|
||||
# Get key Event entity states (if enabled)
|
||||
for key_type in get_remote_keys():
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
EVENT_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{remote.serial_number}_{config_entry.unique_id}_{key_type}",
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data[f"remote_{remote.serial_number}_{key_type}_event"] = state_dict
|
||||
|
||||
# Add remote Mozart model
|
||||
data[f"remote_{remote.serial_number}"] = dict(remote)
|
||||
|
||||
# Get Mozart battery entity
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data["battery_level"] = state_dict
|
||||
|
||||
return data
|
||||
|
||||
@@ -16,11 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
BEO_REMOTE_KEYS,
|
||||
BEO_REMOTE_SUBMENU_CONTROL,
|
||||
BEO_REMOTE_SUBMENU_LIGHT,
|
||||
CONNECTION_STATUS,
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DOMAIN,
|
||||
@@ -29,7 +25,7 @@ from .const import (
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BeoEntity
|
||||
from .util import get_device_buttons, get_remotes
|
||||
from .util import get_device_buttons, get_remote_keys, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -40,38 +36,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Event entities from config entry."""
|
||||
entities: list[BeoEvent] = []
|
||||
|
||||
async_add_entities(
|
||||
entities: list[BeoEvent] = [
|
||||
BeoButtonEvent(config_entry, button_type)
|
||||
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
|
||||
)
|
||||
]
|
||||
|
||||
# Check for connected Beoremote One
|
||||
remotes = await get_remotes(config_entry.runtime_data.client)
|
||||
|
||||
for remote in remotes:
|
||||
# Add Light keys
|
||||
entities.extend(
|
||||
[
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
|
||||
)
|
||||
for key_type in BEO_REMOTE_KEYS
|
||||
]
|
||||
)
|
||||
|
||||
# Add Control keys
|
||||
entities.extend(
|
||||
[
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
|
||||
)
|
||||
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
|
||||
BeoRemoteKeyEvent(config_entry, remote, key_type)
|
||||
for key_type in get_remote_keys()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
139
homeassistant/components/bang_olufsen/sensor.py
Normal file
139
homeassistant/components/bang_olufsen/sensor.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Sensor entities for the Bang & Olufsen integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from mozart_api.exceptions import ApiException
|
||||
from mozart_api.models import BatteryState, PairedRemote
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BeoConfigEntry
|
||||
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
|
||||
from .entity import BeoEntity
|
||||
from .util import get_remotes, supports_battery
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Sensor entities from config entry."""
|
||||
entities: list[BeoSensor] = []
|
||||
|
||||
# Check for Mozart device with battery
|
||||
if await supports_battery(config_entry.runtime_data.client):
|
||||
entities.append(BeoSensorBatteryLevel(config_entry))
|
||||
|
||||
# Add any Beoremote One remotes
|
||||
entities.extend(
|
||||
[
|
||||
BeoSensorRemoteBatteryLevel(config_entry, remote)
|
||||
for remote in (await get_remotes(config_entry.runtime_data.client))
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BeoSensor(SensorEntity, BeoEntity):
|
||||
"""Base Bang & Olufsen Sensor."""
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Initialize Sensor."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
|
||||
class BeoSensorBatteryLevel(BeoSensor):
|
||||
"""Battery level Sensor for Mozart devices."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Init the battery level Sensor."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
self._attr_unique_id = f"{self._unique_id}_battery_level"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Turn on the dispatchers."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
|
||||
self._update_battery,
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_battery(self, data: BatteryState) -> None:
|
||||
"""Update sensor value."""
|
||||
self._attr_native_value = data.battery_level
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BeoSensorRemoteBatteryLevel(BeoSensor):
|
||||
"""Battery level Sensor for the Beoremote One."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_should_poll = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None:
|
||||
"""Init the battery level Sensor."""
|
||||
super().__init__(config_entry)
|
||||
# Serial number is not None, as the remote object is provided by get_remotes
|
||||
assert remote.serial_number
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
|
||||
)
|
||||
self._attr_native_value = remote.battery_level
|
||||
self._remote = remote
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Turn on the dispatchers."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Poll battery status."""
|
||||
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
|
||||
for remote in await get_remotes(self._client):
|
||||
if remote.serial_number == self._remote.serial_number:
|
||||
self._attr_native_value = remote.battery_level
|
||||
break
|
||||
@@ -11,7 +11,16 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
|
||||
from .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEYS,
|
||||
BEO_REMOTE_SUBMENU_CONTROL,
|
||||
BEO_REMOTE_SUBMENU_LIGHT,
|
||||
DEVICE_BUTTONS,
|
||||
DOMAIN,
|
||||
BeoButtons,
|
||||
BeoModel,
|
||||
)
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
@@ -64,3 +73,21 @@ def get_device_buttons(model: BeoModel) -> list[str]:
|
||||
buttons.remove(BeoButtons.BLUETOOTH)
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
def get_remote_keys() -> list[str]:
|
||||
"""Get remote keys for the Beoremote One. Formatted for Home Assistant use."""
|
||||
return [
|
||||
*[f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}" for key_type in BEO_REMOTE_KEYS],
|
||||
*[
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}"
|
||||
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
async def supports_battery(client: MozartClient) -> bool:
|
||||
"""Get if a Mozart device has a battery."""
|
||||
battery_state = await client.get_battery_state()
|
||||
|
||||
return battery_state.state != "BatteryNotPresent"
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mozart_api.models import (
|
||||
BatteryState,
|
||||
BeoRemoteButton,
|
||||
ButtonEvent,
|
||||
ListeningModeProps,
|
||||
@@ -60,6 +61,7 @@ class BeoWebsocket(BeoBase):
|
||||
self._client.get_active_listening_mode_notifications(
|
||||
self.on_active_listening_mode
|
||||
)
|
||||
self._client.get_battery_notifications(self.on_battery_notification)
|
||||
self._client.get_beo_remote_button_notifications(
|
||||
self.on_beo_remote_button_notification
|
||||
)
|
||||
@@ -115,6 +117,14 @@ class BeoWebsocket(BeoBase):
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_battery_notification(self, notification: BatteryState) -> None:
|
||||
"""Send battery dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
|
||||
"""Send beo_remote_button dispatch."""
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"domain": "blackbird",
|
||||
"name": "Monoprice Blackbird Matrix Switch",
|
||||
"codeowners": [],
|
||||
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
|
||||
"documentation": "https://www.home-assistant.io/integrations/blackbird",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyblackbird"],
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
|
||||
from pyblu import Player
|
||||
from pyblu.errors import PlayerUnreachableError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, 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, service
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import (
|
||||
BluesoundConfigEntry,
|
||||
BluesoundCoordinator,
|
||||
@@ -28,6 +37,38 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bluesound."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_increase_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_clear_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_JOIN,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_MASTER): cv.entity_id},
|
||||
func="async_bluesound_join",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_UNJOIN,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_bluesound_unjoin",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -4,3 +4,8 @@ DOMAIN = "bluesound"
|
||||
INTEGRATION_TITLE = "Bluesound"
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -22,11 +21,7 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -40,9 +35,22 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
||||
from .const import (
|
||||
ATTR_BLUESOUND_GROUP,
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .coordinator import BluesoundCoordinator
|
||||
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
|
||||
from .utils import (
|
||||
dispatcher_join_signal,
|
||||
dispatcher_unjoin_signal,
|
||||
format_unique_id,
|
||||
id_to_paired_player,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BluesoundConfigEntry
|
||||
@@ -54,11 +62,6 @@ SCAN_INTERVAL = timedelta(minutes=15)
|
||||
DATA_BLUESOUND = DOMAIN
|
||||
DEFAULT_PORT = 11000
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
POLL_TIMEOUT = 120
|
||||
|
||||
|
||||
@@ -75,18 +78,6 @@ async def async_setup_entry(
|
||||
config_entry.runtime_data.player,
|
||||
)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_TIMER, None, "async_increase_timer"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
|
||||
)
|
||||
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
|
||||
|
||||
async_add_entities([bluesound_player], update_before_add=True)
|
||||
|
||||
|
||||
@@ -120,6 +111,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
self._presets: list[Preset] = coordinator.data.presets
|
||||
self._group_name: str | None = None
|
||||
self._group_list: list[str] = []
|
||||
self._group_members: list[str] | None = None
|
||||
self._bluesound_device_name = sync_status.name
|
||||
self._player = player
|
||||
self._last_status_update = dt_util.utcnow()
|
||||
@@ -180,6 +172,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
self._last_status_update = dt_util.utcnow()
|
||||
|
||||
self._group_list = self.rebuild_bluesound_group()
|
||||
self._group_members = self.rebuild_group_members()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -365,11 +358,13 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
)
|
||||
|
||||
supported = (
|
||||
MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
)
|
||||
|
||||
if not self._status.indexing:
|
||||
@@ -421,8 +416,57 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
|
||||
return shuffle
|
||||
|
||||
async def async_join(self, master: str) -> None:
|
||||
@property
|
||||
def group_members(self) -> list[str] | None:
|
||||
"""Get list of group members. Leader is always first."""
|
||||
return self._group_members
|
||||
|
||||
async def async_join_players(self, group_members: list[str]) -> None:
|
||||
"""Join `group_members` as a player group with the current player."""
|
||||
if self.entity_id in group_members:
|
||||
raise ServiceValidationError("Cannot join player to itself")
|
||||
|
||||
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
|
||||
|
||||
paired_players = []
|
||||
for group_member in group_members:
|
||||
sync_status = entity_ids_with_sync_status.get(group_member)
|
||||
if sync_status is None:
|
||||
continue
|
||||
paired_player = id_to_paired_player(sync_status.id)
|
||||
if paired_player:
|
||||
paired_players.append(paired_player)
|
||||
|
||||
if paired_players:
|
||||
await self._player.add_followers(paired_players)
|
||||
|
||||
async def async_unjoin_player(self) -> None:
|
||||
"""Remove this player from any group."""
|
||||
if self._sync_status.leader is not None:
|
||||
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
|
||||
async_dispatcher_send(
|
||||
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
|
||||
)
|
||||
|
||||
if self._sync_status.followers is not None:
|
||||
await self._player.remove_follower(self.host, self.port)
|
||||
|
||||
async def async_bluesound_join(self, master: str) -> None:
|
||||
"""Join the player to a group."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_JOIN}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2026.7.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_join",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
|
||||
if master == self.entity_id:
|
||||
raise ServiceValidationError("Cannot join player to itself")
|
||||
|
||||
@@ -431,18 +475,24 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
self.hass, dispatcher_join_signal(master), self.host, self.port
|
||||
)
|
||||
|
||||
async def async_unjoin(self) -> None:
|
||||
async def async_bluesound_unjoin(self) -> None:
|
||||
"""Unjoin the player from a group."""
|
||||
if self._sync_status.leader is None:
|
||||
return
|
||||
|
||||
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
|
||||
|
||||
_LOGGER.debug("Trying to unjoin player: %s", self.id)
|
||||
async_dispatcher_send(
|
||||
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_UNJOIN}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2026.7.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_unjoin",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
|
||||
await self.async_unjoin_player()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""List members in group."""
|
||||
@@ -488,6 +538,63 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
follower_names.insert(0, leader_sync_status.name)
|
||||
return follower_names
|
||||
|
||||
def rebuild_group_members(self) -> list[str] | None:
|
||||
"""Get list of group members. Leader is always first."""
|
||||
if self.sync_status.leader is None and self.sync_status.followers is None:
|
||||
return None
|
||||
|
||||
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
|
||||
|
||||
leader_entity_id = None
|
||||
followers = None
|
||||
if self.sync_status.followers is not None:
|
||||
leader_entity_id = self.entity_id
|
||||
followers = self.sync_status.followers
|
||||
elif self.sync_status.leader is not None:
|
||||
leader_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
|
||||
for entity_id, sync_status in entity_ids_with_sync_status.items():
|
||||
if sync_status.id == leader_id:
|
||||
leader_entity_id = entity_id
|
||||
followers = sync_status.followers
|
||||
break
|
||||
|
||||
if leader_entity_id is None or followers is None:
|
||||
return None
|
||||
|
||||
grouped_entity_ids = [leader_entity_id]
|
||||
for follower in followers:
|
||||
follower_id = f"{follower.ip}:{follower.port}"
|
||||
entity_ids = [
|
||||
entity_id
|
||||
for entity_id, sync_status in entity_ids_with_sync_status.items()
|
||||
if sync_status.id == follower_id
|
||||
]
|
||||
match entity_ids:
|
||||
case [entity_id]:
|
||||
grouped_entity_ids.append(entity_id)
|
||||
|
||||
return grouped_entity_ids
|
||||
|
||||
def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]:
|
||||
result = {}
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
config_entries: list[BluesoundConfigEntry] = (
|
||||
self.hass.config_entries.async_entries(DOMAIN)
|
||||
)
|
||||
for config_entry in config_entries:
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
if entity_entry.domain == "media_player":
|
||||
result[entity_entry.entity_id] = (
|
||||
config_entry.runtime_data.coordinator.data.sync_status
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def async_add_follower(self, host: str, port: int) -> None:
|
||||
"""Add follower to leader."""
|
||||
await self._player.add_follower(host, port)
|
||||
|
||||
@@ -41,9 +41,17 @@
|
||||
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.clear_sleep_timer"
|
||||
},
|
||||
"deprecated_service_join": {
|
||||
"description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.join"
|
||||
},
|
||||
"deprecated_service_set_sleep_timer": {
|
||||
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.set_sleep_timer"
|
||||
},
|
||||
"deprecated_service_unjoin": {
|
||||
"description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
|
||||
"title": "Detected use of deprecated action bluesound.unjoin"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Utility functions for the Bluesound component."""
|
||||
|
||||
from pyblu import PairedPlayer
|
||||
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
|
||||
@@ -19,3 +21,12 @@ def dispatcher_unjoin_signal(leader_id: str) -> str:
|
||||
Id is ip_address:port. This can be obtained from sync_status.id.
|
||||
"""
|
||||
return f"bluesound_unjoin_{leader_id}"
|
||||
|
||||
|
||||
def id_to_paired_player(id: str) -> PairedPlayer | None:
|
||||
"""Try to convert id in format 'ip:port' to PairedPlayer. Returns None if unable to do so."""
|
||||
match id.rsplit(":", 1):
|
||||
case [str() as ip, str() as port] if port.isdigit():
|
||||
return PairedPlayer(ip, int(port))
|
||||
case _:
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_SSL
|
||||
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
@@ -26,11 +27,12 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
mac = config_entry.data[CONF_MAC]
|
||||
ssl = config_entry.data.get(CONF_USE_SSL, False)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
|
||||
)
|
||||
client = BraviaClient(host, mac, session=session)
|
||||
client = BraviaClient(host, mac, session=session, ssl=ssl)
|
||||
coordinator = BraviaTVCoordinator(
|
||||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def create_client(self) -> None:
|
||||
"""Create Bravia TV client from config."""
|
||||
host = self.device_config[CONF_HOST]
|
||||
ssl = self.device_config[CONF_USE_SSL]
|
||||
session = async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
self.client = BraviaClient(host=host, session=session)
|
||||
self.client = BraviaClient(host=host, session=session, ssl=ssl)
|
||||
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle authorize step."""
|
||||
self.create_client()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
|
||||
self.create_client()
|
||||
if user_input[CONF_USE_PSK]:
|
||||
return await self.async_step_psk()
|
||||
return await self.async_step_pin()
|
||||
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
vol.Required(CONF_USE_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
CONF_USE_PSK: Final = "use_psk"
|
||||
CONF_USE_SSL: Final = "use_ssl"
|
||||
|
||||
DOMAIN: Final = "braviatv"
|
||||
LEGACY_CLIENT_ID: Final = "HomeAssistant"
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import MediaType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -56,8 +56,31 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
|
||||
"""Catch Bravia errors and log message."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except BraviaNotFound as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error_not_found",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error_offline",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except BraviaError as err:
|
||||
_LOGGER.error("Command error: %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
await self.async_request_refresh()
|
||||
|
||||
return wrapper
|
||||
@@ -165,17 +188,35 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
|
||||
if self.skipped_updates < 10:
|
||||
self.connected = False
|
||||
self.skipped_updates += 1
|
||||
_LOGGER.debug("Update skipped, Bravia API service is reloading")
|
||||
_LOGGER.debug(
|
||||
"Update for %s skipped: the Bravia API service is reloading",
|
||||
self.config_entry.title,
|
||||
)
|
||||
return
|
||||
raise UpdateFailed("Error communicating with device") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error_not_found",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
},
|
||||
) from err
|
||||
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
_LOGGER.debug("Update skipped, Bravia TV is off")
|
||||
_LOGGER.debug(
|
||||
"Update for %s skipped: the TV is turned off", self.config_entry.title
|
||||
)
|
||||
except BraviaError as err:
|
||||
self.is_on = False
|
||||
self.connected = False
|
||||
raise UpdateFailed("Error communicating with device") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={
|
||||
"device": self.config_entry.title,
|
||||
"error": repr(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_update_volume(self) -> None:
|
||||
"""Update volume information."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.3.4"],
|
||||
"requirements": ["pybravia==0.4.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Sony Corporation",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
"step": {
|
||||
"authorize": {
|
||||
"data": {
|
||||
"use_psk": "Use PSK authentication"
|
||||
"use_psk": "Use PSK authentication",
|
||||
"use_ssl": "Use SSL connection"
|
||||
},
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"confirm": {
|
||||
@@ -54,5 +55,22 @@
|
||||
"name": "Terminate apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_error": {
|
||||
"message": "Error sending command to {device}: {error}"
|
||||
},
|
||||
"command_error_not_found": {
|
||||
"message": "Error sending command to {device}: the Bravia API service is reloading"
|
||||
},
|
||||
"command_error_offline": {
|
||||
"message": "Error sending command to {device}: the TV is turned off"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "Error updating data for {device}: {error}"
|
||||
},
|
||||
"update_error_not_found": {
|
||||
"message": "Error updating data for {device}: the Bravia API service is stuck"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,18 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_PASSKEY, DOMAIN
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
|
||||
|
||||
|
||||
@@ -49,6 +54,12 @@ class BSBLanData:
|
||||
static: StaticState
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the BSB-Lan integration."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
|
||||
"""Set up BSB-Lan from a config entry."""
|
||||
|
||||
|
||||
10
homeassistant/components/bsblan/icons.json
Normal file
10
homeassistant/components/bsblan/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"service": "mdi:calendar-clock"
|
||||
},
|
||||
"sync_time": {
|
||||
"service": "mdi:timer-sync-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==3.1.4"],
|
||||
"requirements": ["python-bsblan==3.1.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
291
homeassistant/components/bsblan/services.py
Normal file
291
homeassistant/components/bsblan/services.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Support for BSB-Lan services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BSBLanConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_MONDAY_SLOTS = "monday_slots"
|
||||
ATTR_TUESDAY_SLOTS = "tuesday_slots"
|
||||
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
|
||||
ATTR_THURSDAY_SLOTS = "thursday_slots"
|
||||
ATTR_FRIDAY_SLOTS = "friday_slots"
|
||||
ATTR_SATURDAY_SLOTS = "saturday_slots"
|
||||
ATTR_SUNDAY_SLOTS = "sunday_slots"
|
||||
|
||||
# Service names
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
|
||||
SERVICE_SYNC_TIME = "sync_time"
|
||||
|
||||
|
||||
# Schema for a single time slot
|
||||
_SLOT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("start_time"): cv.time,
|
||||
vol.Required("end_time"): cv.time,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _convert_time_slots_to_day_schedule(
|
||||
slots: list[dict[str, time]] | None,
|
||||
) -> DaySchedule | None:
|
||||
"""Convert list of time slot dicts to a DaySchedule object.
|
||||
|
||||
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
|
||||
{"start_time": time(17, 0), "end_time": time(21, 0)}]
|
||||
becomes: DaySchedule with two TimeSlot objects
|
||||
|
||||
None returns None (don't modify this day).
|
||||
Empty list returns DaySchedule with empty slots (clear this day).
|
||||
"""
|
||||
if slots is None:
|
||||
return None
|
||||
|
||||
if not slots:
|
||||
return DaySchedule(slots=[])
|
||||
|
||||
time_slots = []
|
||||
for slot in slots:
|
||||
start_time = slot["start_time"]
|
||||
end_time = slot["end_time"]
|
||||
|
||||
# Validate that end time is after start time
|
||||
if end_time <= start_time:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_time_before_start_time",
|
||||
translation_placeholders={
|
||||
"start_time": start_time.strftime("%H:%M"),
|
||||
"end_time": end_time.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
|
||||
time_slots.append(TimeSlot(start=start_time, end=end_time))
|
||||
LOGGER.debug(
|
||||
"Created time slot: %s-%s",
|
||||
start_time.strftime("%H:%M"),
|
||||
end_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
|
||||
return DaySchedule(slots=time_slots)
|
||||
|
||||
|
||||
async def set_hot_water_schedule(service_call: ServiceCall) -> None:
|
||||
"""Set hot water heating schedule."""
|
||||
device_id = service_call.data[ATTR_DEVICE_ID]
|
||||
|
||||
# Get the device and config entry
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device_id",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
# Find the config entry for this device
|
||||
matching_entries: list[BSBLanConfigEntry] = [
|
||||
entry
|
||||
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
]
|
||||
|
||||
if not matching_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry_for_device",
|
||||
translation_placeholders={"device_id": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
entry = matching_entries[0]
|
||||
|
||||
# Verify the config entry is loaded
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
translation_placeholders={"device_name": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
|
||||
# Convert time slots to DaySchedule objects
|
||||
monday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_MONDAY_SLOTS)
|
||||
)
|
||||
tuesday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_TUESDAY_SLOTS)
|
||||
)
|
||||
wednesday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_WEDNESDAY_SLOTS)
|
||||
)
|
||||
thursday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_THURSDAY_SLOTS)
|
||||
)
|
||||
friday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_FRIDAY_SLOTS)
|
||||
)
|
||||
saturday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_SATURDAY_SLOTS)
|
||||
)
|
||||
sunday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_SUNDAY_SLOTS)
|
||||
)
|
||||
|
||||
# Create the DHWSchedule object
|
||||
dhw_schedule = DHWSchedule(
|
||||
monday=monday,
|
||||
tuesday=tuesday,
|
||||
wednesday=wednesday,
|
||||
thursday=thursday,
|
||||
friday=friday,
|
||||
saturday=saturday,
|
||||
sunday=sunday,
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
"Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, "
|
||||
"Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s",
|
||||
monday,
|
||||
tuesday,
|
||||
wednesday,
|
||||
thursday,
|
||||
friday,
|
||||
saturday,
|
||||
sunday,
|
||||
)
|
||||
|
||||
try:
|
||||
# Call the BSB-Lan API to set the schedule
|
||||
await client.set_hot_water_schedule(dhw_schedule)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_schedule_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# Refresh the slow coordinator to get the updated schedule
|
||||
await entry.runtime_data.slow_coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_sync_time(service_call: ServiceCall) -> None:
|
||||
"""Synchronize BSB-LAN device time with Home Assistant."""
|
||||
device_id: str = service_call.data[ATTR_DEVICE_ID]
|
||||
|
||||
# Get the device and config entry
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device_id",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
# Find the config entry for this device
|
||||
matching_entries: list[BSBLanConfigEntry] = [
|
||||
entry
|
||||
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
]
|
||||
|
||||
if not matching_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry_for_device",
|
||||
translation_placeholders={"device_id": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
entry = matching_entries[0]
|
||||
|
||||
# Verify the config entry is loaded
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
translation_placeholders={"device_name": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
|
||||
try:
|
||||
# Get current device time
|
||||
device_time = await client.time()
|
||||
current_time = dt_util.now()
|
||||
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
# Only sync if device time differs from HA time
|
||||
if device_time.time.value != current_time_str:
|
||||
await client.set_time(current_time_str)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="sync_time_failed",
|
||||
translation_placeholders={
|
||||
"device_name": device_entry.name or device_id,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
SYNC_TIME_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the BSB-Lan services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
set_hot_water_schedule,
|
||||
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SYNC_TIME,
|
||||
async_sync_time,
|
||||
schema=SYNC_TIME_SCHEMA,
|
||||
)
|
||||
122
homeassistant/components/bsblan/services.yaml
Normal file
122
homeassistant/components/bsblan/services.yaml
Normal file
@@ -0,0 +1,122 @@
|
||||
sync_time:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
example: "abc123device456"
|
||||
selector:
|
||||
device:
|
||||
integration: bsblan
|
||||
|
||||
set_hot_water_schedule:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
example: "abc123device456"
|
||||
selector:
|
||||
device:
|
||||
integration: bsblan
|
||||
monday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
tuesday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
wednesday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
thursday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
friday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
saturday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
sunday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
@@ -70,6 +70,18 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_loaded": {
|
||||
"message": "The device `{device_name}` is not currently loaded or available"
|
||||
},
|
||||
"end_time_before_start_time": {
|
||||
"message": "End time ({end_time}) must be after start time ({start_time})"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID: {device_id}"
|
||||
},
|
||||
"no_config_entry_for_device": {
|
||||
"message": "No configuration entry found for device: {device_id}"
|
||||
},
|
||||
"set_data_error": {
|
||||
"message": "An error occurred while sending the data to the BSB-Lan device"
|
||||
},
|
||||
@@ -79,6 +91,9 @@
|
||||
"set_preset_mode_error": {
|
||||
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
|
||||
},
|
||||
"set_schedule_failed": {
|
||||
"message": "Failed to set hot water schedule: {error}"
|
||||
},
|
||||
"set_temperature_error": {
|
||||
"message": "An error occurred while setting the temperature"
|
||||
},
|
||||
@@ -90,6 +105,59 @@
|
||||
},
|
||||
"setup_general_error": {
|
||||
"message": "An unknown error occurred while retrieving static device data"
|
||||
},
|
||||
"sync_time_failed": {
|
||||
"message": "Failed to sync time for {device_name}: {error}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"description": "Set the hot water heating schedule for a BSB-LAN device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The BSB-LAN device to configure.",
|
||||
"name": "Device"
|
||||
},
|
||||
"friday_slots": {
|
||||
"description": "Time periods for Friday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Friday time slots"
|
||||
},
|
||||
"monday_slots": {
|
||||
"description": "Time periods for Monday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Monday time slots"
|
||||
},
|
||||
"saturday_slots": {
|
||||
"description": "Time periods for Saturday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Saturday time slots"
|
||||
},
|
||||
"sunday_slots": {
|
||||
"description": "Time periods for Sunday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Sunday time slots"
|
||||
},
|
||||
"thursday_slots": {
|
||||
"description": "Time periods for Thursday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Thursday time slots"
|
||||
},
|
||||
"tuesday_slots": {
|
||||
"description": "Time periods for Tuesday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Tuesday time slots"
|
||||
},
|
||||
"wednesday_slots": {
|
||||
"description": "Time periods for Wednesday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Wednesday time slots"
|
||||
}
|
||||
},
|
||||
"name": "Set hot water schedule"
|
||||
},
|
||||
"sync_time": {
|
||||
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The BSB-LAN device to sync time for.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Sync time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.15.0"]
|
||||
"requirements": ["bthome-ble==3.17.0"]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"codeowners": ["@emontnemery"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.9"],
|
||||
|
||||
@@ -98,6 +98,18 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"trigger": "mdi:thermostat"
|
||||
},
|
||||
@@ -110,6 +122,12 @@
|
||||
"started_heating": {
|
||||
"trigger": "mdi:fire"
|
||||
},
|
||||
"target_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"target_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
|
||||
@@ -312,6 +312,78 @@
|
||||
},
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity changed"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity crossed threshold"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the temperature is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the temperature is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature changed"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature crossed threshold"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"description": "Triggers after the mode of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
@@ -356,6 +428,42 @@
|
||||
},
|
||||
"name": "Climate-control device started heating"
|
||||
},
|
||||
"target_humidity_changed": {
|
||||
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the target humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the target humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device target humidity changed"
|
||||
},
|
||||
"target_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device target humidity crossed threshold"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
|
||||
@@ -17,7 +17,15 @@ from homeassistant.helpers.trigger import (
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
from .const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
DOMAIN,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
@@ -25,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [HVACMode]
|
||||
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -45,6 +53,18 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
@@ -52,6 +72,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_HUMIDITY
|
||||
),
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_HUMIDITY
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_TEMPERATURE
|
||||
),
|
||||
|
||||
@@ -65,6 +65,48 @@ hvac_mode_changed:
|
||||
- unknown
|
||||
multiple: true
|
||||
|
||||
current_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_humidity_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
target_humidity_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
current_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_temperature_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
|
||||
@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
|
||||
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
from .const import CONF_VEDO_PIN, DEFAULT_PORT
|
||||
from .coordinator import (
|
||||
ComelitBaseCoordinator,
|
||||
ComelitConfigEntry,
|
||||
@@ -22,6 +22,16 @@ BRIDGE_PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
BRIDGE_AND_VEDO_PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.HUMIDIFIER,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
VEDO_PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -37,15 +47,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
|
||||
session = await async_client_session(hass)
|
||||
|
||||
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
vedo_pin = entry.data.get(CONF_VEDO_PIN)
|
||||
coordinator = ComelitSerialBridge(
|
||||
hass,
|
||||
entry,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
entry.data[CONF_PIN],
|
||||
vedo_pin,
|
||||
session,
|
||||
)
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
# Add VEDO platforms if vedo_pin is configured
|
||||
if vedo_pin:
|
||||
platforms = BRIDGE_AND_VEDO_PLATFORMS
|
||||
else:
|
||||
coordinator = ComelitVedoSystem(
|
||||
hass,
|
||||
@@ -71,6 +86,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
|
||||
|
||||
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
# Add VEDO platforms if vedo_pin was configured
|
||||
if entry.data.get(CONF_VEDO_PIN):
|
||||
platforms = BRIDGE_AND_VEDO_PLATFORMS
|
||||
else:
|
||||
platforms = VEDO_PLATFORMS
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiocomelit.api import ComelitVedoAreaObject
|
||||
from aiocomelit.const import AlarmAreaState
|
||||
from aiocomelit.const import ALARM_AREA, AlarmAreaState
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -56,15 +56,25 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Comelit VEDO system alarm control panel devices."""
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_areas"].values()
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
|
||||
if data := coordinator.data[ALARM_AREA]:
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in data.values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
|
||||
class ComelitAlarmEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], AlarmControlPanelEntity
|
||||
):
|
||||
"""Representation of a Ness alarm panel."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -78,7 +88,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
area: ComelitVedoAreaObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
@@ -95,7 +105,9 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
|
||||
@property
|
||||
def _area(self) -> ComelitVedoAreaObject:
|
||||
"""Return area object."""
|
||||
return self.coordinator.data["alarm_areas"][self._area_index]
|
||||
return cast(
|
||||
ComelitVedoAreaObject, self.coordinator.data[ALARM_AREA][self._area_index]
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiocomelit import ComelitVedoZoneObject
|
||||
from aiocomelit.api import ComelitVedoZoneObject
|
||||
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -15,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ObjectClassType
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .utils import new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
@@ -29,25 +30,32 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO presence sensors."""
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem], BinarySensorEntity
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], BinarySensorEntity
|
||||
):
|
||||
"""Sensor device."""
|
||||
|
||||
@@ -56,7 +64,7 @@ class ComelitVedoBinarySensorEntity(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
@@ -68,9 +76,25 @@ class ComelitVedoBinarySensorEntity(
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
|
||||
|
||||
@property
|
||||
def _zone(self) -> ComelitVedoZoneObject:
|
||||
"""Return zone object."""
|
||||
return cast(
|
||||
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._zone.human_status in [
|
||||
AlarmZoneState.FAULTY,
|
||||
AlarmZoneState.UNAVAILABLE,
|
||||
AlarmZoneState.UNKNOWN,
|
||||
]:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Presence detected."""
|
||||
return (
|
||||
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
|
||||
)
|
||||
return self._zone.status_api == "0001"
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from asyncio.exceptions import TimeoutError
|
||||
from collections.abc import Mapping
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiocomelit import (
|
||||
ComeliteSerialBridgeApi,
|
||||
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .utils import async_client_session
|
||||
|
||||
DEFAULT_HOST = "192.168.1.252"
|
||||
@@ -34,9 +34,12 @@ USER_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_PIN): cv.string, vol.Optional(CONF_VEDO_PIN): cv.string}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
@@ -72,6 +75,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
finally:
|
||||
await api.logout()
|
||||
|
||||
# Validate VEDO PIN if provided and device type is BRIDGE
|
||||
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoPin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(api, ComeliteSerialBridgeApi)
|
||||
|
||||
# Verify VEDO is enabled with the provided PIN
|
||||
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoAuth
|
||||
|
||||
return {"title": data[CONF_HOST]}
|
||||
|
||||
|
||||
@@ -99,6 +114,10 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
@@ -182,6 +201,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
await validate_input(self.hass, data_to_validate)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -189,6 +210,10 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
@@ -198,6 +223,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=data_updates
|
||||
)
|
||||
@@ -211,6 +238,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
|
||||
): cv.port,
|
||||
vol.Optional(CONF_PIN): cv.string,
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -231,3 +259,11 @@ class InvalidAuth(HomeAssistantError):
|
||||
|
||||
class InvalidPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid pin."""
|
||||
|
||||
|
||||
class InvalidVedoPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid VEDO pin."""
|
||||
|
||||
|
||||
class InvalidVedoAuth(HomeAssistantError):
|
||||
"""Error to indicate VEDO authentication failed."""
|
||||
|
||||
@@ -19,6 +19,7 @@ ObjectClassType = (
|
||||
DOMAIN = "comelit"
|
||||
DEFAULT_PORT = 80
|
||||
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
|
||||
CONF_VEDO_PIN = "vedo_pin"
|
||||
|
||||
SCAN_INTERVAL = 5
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Support for Comelit."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, TypeVar
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from aiocomelit.api import (
|
||||
AlarmDataObject,
|
||||
ComelitCommonApi,
|
||||
ComeliteSerialBridgeApi,
|
||||
ComelitSerialBridgeObject,
|
||||
ComelitVedoApi,
|
||||
)
|
||||
from aiocomelit.api import ComelitCommonApi, ComeliteSerialBridgeApi, ComelitVedoApi
|
||||
from aiocomelit.const import (
|
||||
ALARM_AREA,
|
||||
ALARM_ZONE,
|
||||
BRIDGE,
|
||||
CLIMATE,
|
||||
COVER,
|
||||
@@ -37,7 +34,10 @@ type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
|
||||
|
||||
T = TypeVar(
|
||||
"T",
|
||||
bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
|
||||
bound=dict[
|
||||
str,
|
||||
Mapping[int, ObjectClassType],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -118,8 +118,8 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
|
||||
async def _async_remove_stale_devices(
|
||||
self,
|
||||
previous_list: dict[int, Any],
|
||||
current_list: dict[int, Any],
|
||||
previous_list: Mapping[int, ObjectClassType],
|
||||
current_list: Mapping[int, ObjectClassType],
|
||||
dev_type: str,
|
||||
) -> None:
|
||||
"""Remove stale devices."""
|
||||
@@ -143,9 +143,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
)
|
||||
|
||||
|
||||
class ComelitSerialBridge(
|
||||
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
|
||||
):
|
||||
class ComelitSerialBridge(ComelitBaseCoordinator[T]):
|
||||
"""Queries Comelit Serial Bridge."""
|
||||
|
||||
_hw_version = "20003101"
|
||||
@@ -158,17 +156,23 @@ class ComelitSerialBridge(
|
||||
host: str,
|
||||
port: int,
|
||||
pin: str,
|
||||
vedo_pin: str | None,
|
||||
session: ClientSession,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
|
||||
self.vedo_pin = vedo_pin
|
||||
super().__init__(hass, entry, BRIDGE, host)
|
||||
|
||||
async def _async_update_system_data(
|
||||
self,
|
||||
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
|
||||
) -> T:
|
||||
"""Specific method for updating data."""
|
||||
data = await self.api.get_all_devices()
|
||||
data: dict[
|
||||
str,
|
||||
Mapping[int, ObjectClassType],
|
||||
] = {}
|
||||
data.update(await self.api.get_all_devices())
|
||||
|
||||
if self.data:
|
||||
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
|
||||
@@ -176,10 +180,14 @@ class ComelitSerialBridge(
|
||||
self.data[dev_type], data[dev_type], dev_type
|
||||
)
|
||||
|
||||
return data
|
||||
# Get VEDO alarm data if vedo_pin is configured
|
||||
if self.vedo_pin:
|
||||
data.update(await self.api.get_all_areas_and_zones())
|
||||
|
||||
return cast(T, data)
|
||||
|
||||
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[T]):
|
||||
"""Queries Comelit VEDO system."""
|
||||
|
||||
_hw_version = "VEDO IP"
|
||||
@@ -196,20 +204,21 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.api = ComelitVedoApi(host, port, pin, session)
|
||||
self.vedo_pin = pin
|
||||
super().__init__(hass, entry, VEDO, host)
|
||||
|
||||
async def _async_update_system_data(
|
||||
self,
|
||||
) -> AlarmDataObject:
|
||||
) -> T:
|
||||
"""Specific method for updating data."""
|
||||
data = await self.api.get_all_areas_and_zones()
|
||||
|
||||
if self.data:
|
||||
for obj_type in ("alarm_areas", "alarm_zones"):
|
||||
for obj_type in (ALARM_AREA, ALARM_ZONE):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[obj_type],
|
||||
data[obj_type],
|
||||
"area" if obj_type == "alarm_areas" else "zone",
|
||||
"area" if obj_type == ALARM_AREA else "zone",
|
||||
)
|
||||
|
||||
return data
|
||||
return cast(T, data)
|
||||
|
||||
@@ -72,7 +72,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@property
|
||||
def device_status(self) -> int:
|
||||
"""Return current device status."""
|
||||
return self.coordinator.data[COVER][self._device.index].status
|
||||
return cast("int", self.coordinator.data[COVER][self._device.index].status)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
@@ -86,7 +86,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the cover is closing."""
|
||||
return self._current_action("closing")
|
||||
return bool(self._current_action("closing"))
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
|
||||
@@ -68,4 +68,4 @@ class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if light is on."""
|
||||
return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON
|
||||
return bool(self.coordinator.data[LIGHT][self._device.index].status == STATE_ON)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==1.1.2"]
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final, cast
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
|
||||
from aiocomelit.const import ALARM_ZONE, OTHER, AlarmZoneState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import CONF_TYPE, UnitOfPower
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -52,23 +52,20 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Comelit sensors."""
|
||||
|
||||
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
await async_setup_bridge_entry(hass, config_entry, async_add_entities)
|
||||
else:
|
||||
await async_setup_vedo_entry(hass, config_entry, async_add_entities)
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
|
||||
async def async_setup_bridge_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ComelitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Comelit Bridge sensors."""
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
def _add_new_bridge_entities(
|
||||
new_devices: list[ObjectClassType], dev_type: str
|
||||
) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
entities = [
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
@@ -80,36 +77,32 @@ async def async_setup_bridge_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, OTHER)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ComelitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO sensors."""
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
def _add_new_vedo_entities(
|
||||
new_devices: list[ObjectClassType], dev_type: str
|
||||
) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
# Bridge native sensors
|
||||
if is_bridge:
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_bridge_entities, OTHER)
|
||||
)
|
||||
|
||||
# Alarm sensors (both via Bridge or VedoSystem)
|
||||
if coordinator.vedo_pin:
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_vedo_entities, ALARM_ZONE)
|
||||
)
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
@@ -141,14 +134,16 @@ class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity):
|
||||
class ComelitVedoSensorEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], SensorEntity
|
||||
):
|
||||
"""Sensor device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
description: SensorEntityDescription,
|
||||
@@ -166,7 +161,9 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
|
||||
@property
|
||||
def _zone_object(self) -> ComelitVedoZoneObject:
|
||||
"""Zone object."""
|
||||
return self.coordinator.data["alarm_zones"][self._zone_index]
|
||||
return cast(
|
||||
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
|
||||
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
|
||||
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
@@ -13,28 +15,34 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
|
||||
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
|
||||
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{host}",
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"pin": "The PIN of your Comelit device."
|
||||
"pin": "The PIN of your Comelit device.",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]"
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -42,13 +50,15 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"type": "Device type"
|
||||
"type": "Device type",
|
||||
"vedo_pin": "VEDO alarm PIN (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Comelit device.",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"port": "The port of your Comelit device.",
|
||||
"type": "The type of your Comelit device."
|
||||
"type": "The type of your Comelit device.",
|
||||
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if switch is on."""
|
||||
return (
|
||||
return bool(
|
||||
self.coordinator.data[self._device.type][self._device.index].status
|
||||
== STATE_ON
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ async def async_setup_entry(
|
||||
name="light",
|
||||
update_method=async_update_data_non_dimmer,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
@@ -73,6 +74,7 @@ async def async_setup_entry(
|
||||
name="light",
|
||||
update_method=async_update_data_dimmer,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
@@ -110,6 +110,7 @@ async def async_setup_entry(
|
||||
name="room",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .helpers import cookidoo_from_config_entry
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CALENDAR,
|
||||
Platform.SENSOR,
|
||||
Platform.TODO,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
103
homeassistant/components/cookidoo/calendar.py
Normal file
103
homeassistant/components/cookidoo/calendar.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Calendar platform for the Cookidoo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooException
|
||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .entity import CookidooBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CookidooConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the calendar platform for entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities([CookidooCalendarEntity(coordinator)])
|
||||
|
||||
|
||||
def recipe_to_event(day_date: date, recipe: CookidooCalendarDayRecipe) -> CalendarEvent:
|
||||
"""Convert a Cookidoo recipe to a CalendarEvent."""
|
||||
return CalendarEvent(
|
||||
start=day_date,
|
||||
end=day_date + timedelta(days=1), # All-day event
|
||||
summary=recipe.name,
|
||||
description=f"Total Time: {recipe.total_time}",
|
||||
)
|
||||
|
||||
|
||||
class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
"""A calendar entity."""
|
||||
|
||||
_attr_translation_key = "meal_plan"
|
||||
|
||||
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
assert coordinator.config_entry.unique_id
|
||||
self._attr_unique_id = coordinator.config_entry.unique_id
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
if not self.coordinator.data.week_plan:
|
||||
return None
|
||||
|
||||
today = date.today()
|
||||
for day_data in self.coordinator.data.week_plan:
|
||||
day_date = date.fromisoformat(day_data.id)
|
||||
if day_date >= today and day_data.recipes:
|
||||
recipe = day_data.recipes[0]
|
||||
return recipe_to_event(day_date, recipe)
|
||||
return None
|
||||
|
||||
async def _fetch_week_plan(self, week_day: date) -> list:
|
||||
"""Fetch a single Cookidoo week plan, retrying once on auth failure."""
|
||||
try:
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooAuthException:
|
||||
await self.coordinator.cookidoo.refresh_token()
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="calendar_fetch_failed",
|
||||
) from e
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
events: list[CalendarEvent] = []
|
||||
current_day = start_date.date()
|
||||
while current_day <= end_date.date():
|
||||
week_plan = await self._fetch_week_plan(current_day)
|
||||
for day_data in week_plan:
|
||||
day_date = date.fromisoformat(day_data.id)
|
||||
if start_date.date() <= day_date <= end_date.date():
|
||||
events.extend(
|
||||
recipe_to_event(day_date, recipe) for recipe in day_data.recipes
|
||||
)
|
||||
current_day += timedelta(days=7) # Move to the next week
|
||||
return events
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import (
|
||||
@@ -16,6 +16,7 @@ from cookidoo_api import (
|
||||
CookidooSubscription,
|
||||
CookidooUserInfo,
|
||||
)
|
||||
from cookidoo_api.types import CookidooCalendarDay
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
@@ -37,6 +38,7 @@ class CookidooData:
|
||||
ingredient_items: list[CookidooIngredientItem]
|
||||
additional_items: list[CookidooAdditionalItem]
|
||||
subscription: CookidooSubscription | None
|
||||
week_plan: list[CookidooCalendarDay]
|
||||
|
||||
|
||||
class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
@@ -81,6 +83,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
ingredient_items = await self.cookidoo.get_ingredient_items()
|
||||
additional_items = await self.cookidoo.get_additional_items()
|
||||
subscription = await self.cookidoo.get_active_subscription()
|
||||
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today())
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.cookidoo.refresh_token()
|
||||
@@ -106,4 +109,5 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
ingredient_items=ingredient_items,
|
||||
additional_items=additional_items,
|
||||
subscription=subscription,
|
||||
week_plan=week_plan,
|
||||
)
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"name": "Clear shopping list and additional purchases"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"meal_plan": {
|
||||
"name": "Meal plan"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"expires": {
|
||||
"name": "Subscription expiration date"
|
||||
@@ -80,6 +85,9 @@
|
||||
"button_clear_todo_failed": {
|
||||
"message": "Failed to clear all items from the Cookidoo shopping list"
|
||||
},
|
||||
"calendar_fetch_failed": {
|
||||
"message": "Failed to fetch Cookidoo meal plan"
|
||||
},
|
||||
"setup_authentication_exception": {
|
||||
"message": "Authentication failed for {email}, check your email and password"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@ MEDIA_MODES = {
|
||||
"Favorites": "FAVORITES",
|
||||
"Internet Radio": "IRADIO",
|
||||
"USB/IPOD": "USB/IPOD",
|
||||
"USB": "USB",
|
||||
}
|
||||
|
||||
# Sub-modes of 'NET/USB'
|
||||
@@ -279,7 +280,7 @@ class DenonDevice(MediaPlayerEntity):
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute (true) or unmute (false) media player."""
|
||||
mute_status = "ON" if mute else "OFF"
|
||||
self.telnet_command(f"MU{mute_status})")
|
||||
self.telnet_command(f"MU{mute_status}")
|
||||
|
||||
def media_play(self) -> None:
|
||||
"""Play media player."""
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.46.1"],
|
||||
"requirements": ["async-upnp-client==0.46.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Integrate with DuckDNS."""
|
||||
"""Duck DNS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,25 +8,16 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
|
||||
from .helpers import update_duckdns
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_TXT = "txt"
|
||||
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -40,27 +31,11 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the DuckDNS component."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_TXT,
|
||||
update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA,
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
@@ -87,49 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> b
|
||||
return True
|
||||
|
||||
|
||||
def get_config_entry(
|
||||
hass: HomeAssistant, entry_id: str | None = None
|
||||
) -> DuckDnsConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if len(config_entries) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_selected",
|
||||
)
|
||||
return config_entries[0]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
|
||||
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
txt=call.data.get(ATTR_TXT),
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
@@ -5,3 +5,5 @@ from typing import Final
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
ATTR_CONFIG_ENTRY: Final = "config_entry_id"
|
||||
ATTR_TXT: Final = "txt"
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"codeowners": ["@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/duckdns",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
87
homeassistant/components/duckdns/services.py
Normal file
87
homeassistant/components/duckdns/services.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Actions for Duck DNS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT
|
||||
from .coordinator import DuckDnsConfigEntry
|
||||
from .helpers import update_duckdns
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Habitica integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_TXT,
|
||||
update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def get_config_entry(
|
||||
hass: HomeAssistant, entry_id: str | None = None
|
||||
) -> DuckDnsConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_selected",
|
||||
)
|
||||
return entries[0]
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
|
||||
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
try:
|
||||
if not await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
txt=call.data.get(ATTR_TXT),
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: entry.data[CONF_DOMAIN],
|
||||
},
|
||||
)
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: entry.data[CONF_DOMAIN],
|
||||
},
|
||||
) from e
|
||||
@@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
|
||||
frontend_url_path=DOMAIN,
|
||||
config_panel_domain=DOMAIN,
|
||||
webcomponent_name="dynalite-panel",
|
||||
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
|
||||
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
|
||||
embed_iframe=True,
|
||||
require_admin=True,
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"cpu_overheating": "CPU overheating",
|
||||
"none": "None",
|
||||
"pellets": "Pellets",
|
||||
"unkownn": "Unknown alarm"
|
||||
"unknown": "Unknown alarm"
|
||||
}
|
||||
},
|
||||
"convector_air_flow": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
|
||||
@@ -39,6 +40,9 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_METRIC: Final = (
|
||||
EcoWittSensorTypes.TEMPERATURE_C,
|
||||
EcoWittSensorTypes.RAIN_COUNT_MM,
|
||||
@@ -57,6 +61,40 @@ _IMPERIAL: Final = (
|
||||
)
|
||||
|
||||
|
||||
_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = {
|
||||
"eventrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"hourlyrainin": None,
|
||||
"totalrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"dailyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"weeklyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"monthlyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"yearlyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrainin": None,
|
||||
"eventrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"hourlyrainmm": None,
|
||||
"totalrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"dailyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"weeklyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"monthlyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"yearlyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrainmm": None,
|
||||
"erain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"hrain_piezo": None,
|
||||
"drain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"wrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"mrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"yrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrain_piezo": None,
|
||||
"erain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"hrain_piezomm": None,
|
||||
"drain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"wrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"mrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"yrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrain_piezomm": None,
|
||||
}
|
||||
|
||||
|
||||
ECOWITT_SENSORS_MAPPING: Final = {
|
||||
EcoWittSensorTypes.HUMIDITY: SensorEntityDescription(
|
||||
key="HUMIDITY",
|
||||
@@ -285,15 +323,15 @@ async def async_setup_entry(
|
||||
name=sensor.name,
|
||||
)
|
||||
|
||||
# Only total rain needs state class for long-term statistics
|
||||
if sensor.key in (
|
||||
"totalrainin",
|
||||
"totalrainmm",
|
||||
if sensor.stype in (
|
||||
EcoWittSensorTypes.RAIN_COUNT_INCHES,
|
||||
EcoWittSensorTypes.RAIN_COUNT_MM,
|
||||
):
|
||||
description = dataclasses.replace(
|
||||
description,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING:
|
||||
_LOGGER.warning("Unknown rain count sensor: %s", sensor.key)
|
||||
return
|
||||
state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key]
|
||||
description = dataclasses.replace(description, state_class=state_class)
|
||||
|
||||
async_add_entities([EcowittSensorEntity(sensor, description)])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.4.0"],
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import AsyncIterable
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
from elevenlabs.core import ApiError
|
||||
@@ -180,15 +181,17 @@ class ElevenLabsSTTEntity(SpeechToTextEntity):
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.speech_to_text.convert(
|
||||
file=BytesIO(audio),
|
||||
file_format=file_format,
|
||||
model_id=self._stt_model,
|
||||
language_code=lang_code,
|
||||
tag_audio_events=False,
|
||||
num_speakers=1,
|
||||
diarize=False,
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"file": BytesIO(audio),
|
||||
"file_format": file_format,
|
||||
"model_id": self._stt_model,
|
||||
"tag_audio_events": False,
|
||||
"num_speakers": 1,
|
||||
"diarize": False,
|
||||
}
|
||||
if lang_code is not None:
|
||||
kwargs["language_code"] = lang_code
|
||||
response = await self._client.speech_to_text.convert(**kwargs)
|
||||
except ApiError as exc:
|
||||
_LOGGER.error("Error during processing of STT request: %s", exc)
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
|
||||
@@ -206,7 +206,7 @@ class EnvoyProductionSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Envoy production sensor entity."""
|
||||
|
||||
value_fn: Callable[[EnvoySystemProduction], int]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
|
||||
|
||||
PRODUCTION_SENSORS = (
|
||||
@@ -219,7 +219,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="daily_production",
|
||||
@@ -230,7 +229,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
value_fn=attrgetter("watt_hours_today"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="seven_days_production",
|
||||
@@ -240,7 +238,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
value_fn=attrgetter("watt_hours_last_7_days"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyProductionSensorEntityDescription(
|
||||
key="lifetime_production",
|
||||
@@ -251,7 +248,6 @@ PRODUCTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -277,7 +273,7 @@ class EnvoyConsumptionSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Envoy consumption sensor entity."""
|
||||
|
||||
value_fn: Callable[[EnvoySystemConsumption], int]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
|
||||
|
||||
CONSUMPTION_SENSORS = (
|
||||
@@ -290,7 +286,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="daily_consumption",
|
||||
@@ -301,7 +296,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
value_fn=attrgetter("watt_hours_today"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="seven_days_consumption",
|
||||
@@ -311,7 +305,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
value_fn=attrgetter("watt_hours_last_7_days"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="lifetime_consumption",
|
||||
@@ -322,7 +315,6 @@ CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -354,7 +346,6 @@ NET_CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watts_now"),
|
||||
on_phase=None,
|
||||
),
|
||||
EnvoyConsumptionSensorEntityDescription(
|
||||
key="lifetime_balanced_net_consumption",
|
||||
@@ -366,7 +357,6 @@ NET_CONSUMPTION_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("watt_hours_lifetime"),
|
||||
on_phase=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -395,7 +385,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
||||
[EnvoyMeterData],
|
||||
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
|
||||
]
|
||||
on_phase: str | None
|
||||
on_phase: str | None = None
|
||||
cttype: str | None = None
|
||||
|
||||
|
||||
@@ -411,7 +401,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_delivered"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -430,7 +419,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("energy_received"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -449,7 +437,6 @@ CT_SENSORS = (
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
suggested_display_precision=3,
|
||||
value_fn=attrgetter("active_power"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -468,7 +455,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("frequency"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -488,7 +474,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=1,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("voltage"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -508,7 +493,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=3,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("current"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -526,7 +510,6 @@ CT_SENSORS = (
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("power_factor"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key in (
|
||||
@@ -544,7 +527,6 @@ CT_SENSORS = (
|
||||
options=list(CtMeterStatus),
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=attrgetter("metering_status"),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -565,7 +547,6 @@ CT_SENSORS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||
on_phase=None,
|
||||
cttype=cttype,
|
||||
)
|
||||
for cttype, key, translation_key in (
|
||||
@@ -620,6 +601,7 @@ ENCHARGE_INVENTORY_SENSORS = (
|
||||
EnvoyEnchargeSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -634,6 +616,7 @@ ENCHARGE_INVENTORY_SENSORS = (
|
||||
ENCHARGE_POWER_SENSORS = (
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="soc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("soc"),
|
||||
@@ -641,12 +624,14 @@ ENCHARGE_POWER_SENSORS = (
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="apparent_power_mva",
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
value_fn=lambda encharge: encharge.apparent_power_mva * 0.001,
|
||||
),
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="real_power_mw",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=lambda encharge: encharge.real_power_mw * 0.001,
|
||||
),
|
||||
@@ -664,6 +649,7 @@ ENPOWER_SENSORS = (
|
||||
EnvoyEnpowerSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -693,6 +679,7 @@ COLLAR_SENSORS = (
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -760,6 +747,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
EnvoyEnchargeAggregateSensorEntityDescription(
|
||||
key="battery_level",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
),
|
||||
@@ -767,6 +755,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="reserve_soc",
|
||||
translation_key="reserve_soc",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("reserve_state_of_charge"),
|
||||
),
|
||||
@@ -774,6 +763,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="available_energy",
|
||||
translation_key="available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
value_fn=attrgetter("available_energy"),
|
||||
),
|
||||
@@ -781,6 +771,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="reserve_energy",
|
||||
translation_key="reserve_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
value_fn=attrgetter("backup_reserve"),
|
||||
),
|
||||
@@ -805,12 +796,14 @@ ACB_BATTERY_POWER_SENSORS = (
|
||||
EnvoyAcbBatterySensorEntityDescription(
|
||||
key="acb_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=attrgetter("power"),
|
||||
),
|
||||
EnvoyAcbBatterySensorEntityDescription(
|
||||
key="acb_soc",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
),
|
||||
@@ -828,6 +821,7 @@ ACB_BATTERY_ENERGY_SENSORS = (
|
||||
key="acb_available_energy",
|
||||
translation_key="acb_available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("charge_wh"),
|
||||
),
|
||||
@@ -845,6 +839,7 @@ AGGREGATE_BATTERY_SENSORS = (
|
||||
EnvoyAggregateBatterySensorEntityDescription(
|
||||
key="aggregated_soc",
|
||||
translation_key="aggregated_soc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
@@ -853,6 +848,7 @@ AGGREGATE_BATTERY_SENSORS = (
|
||||
key="aggregated_available_energy",
|
||||
translation_key="aggregated_available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("available_energy"),
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.3.0",
|
||||
"aioesphomeapi==43.10.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -8,8 +8,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .manager import async_replace_device
|
||||
|
||||
@@ -22,13 +21,6 @@ class ESPHomeRepair(RepairsFlow):
|
||||
self._data = data
|
||||
super().__init__()
|
||||
|
||||
@callback
|
||||
def _async_get_placeholders(self) -> dict[str, str]:
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
|
||||
assert issue is not None
|
||||
return issue.translation_placeholders or {}
|
||||
|
||||
|
||||
class DeviceConflictRepair(ESPHomeRepair):
|
||||
"""Handler for an issue fixing device conflict."""
|
||||
@@ -58,7 +50,6 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["migrate", "manual"],
|
||||
description_placeholders=self._async_get_placeholders(),
|
||||
)
|
||||
|
||||
async def async_step_migrate(
|
||||
@@ -69,7 +60,6 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
return self.async_show_form(
|
||||
step_id="migrate",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=self._async_get_placeholders(),
|
||||
)
|
||||
entry_id = self.entry_id
|
||||
await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac)
|
||||
@@ -84,7 +74,6 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
return self.async_show_form(
|
||||
step_id="manual",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=self._async_get_placeholders(),
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user