mirror of
https://github.com/home-assistant/core.git
synced 2026-01-22 07:26:58 +01:00
Compare commits
1350 Commits
strings/ma
...
claude/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6c2b4f74 | ||
|
|
e0349cae63 | ||
|
|
83a53dea94 | ||
|
|
4fb89e68a7 | ||
|
|
5202ddf095 | ||
|
|
f7d7a4502e | ||
|
|
c7417d77b5 | ||
|
|
22018f1f80 | ||
|
|
22c6704d81 | ||
|
|
0552934b3c | ||
|
|
bbe1d28e88 | ||
|
|
b700a27c8f | ||
|
|
0566a668a9 | ||
|
|
94f636bc2d | ||
|
|
a6e7546142 | ||
|
|
493319894b | ||
|
|
987396722b | ||
|
|
4f52b0363d | ||
|
|
52e18ed6f6 | ||
|
|
4180175fd3 | ||
|
|
e39ee8cae7 | ||
|
|
c214193087 | ||
|
|
2d84847be5 | ||
|
|
0d69fd4535 | ||
|
|
56f556864c | ||
|
|
c1b03dc553 | ||
|
|
07e76578e6 | ||
|
|
bc45fd4e45 | ||
|
|
0ea03f549c | ||
|
|
0ee46dbf5d | ||
|
|
e12f394f8e | ||
|
|
b40046264d | ||
|
|
22afa1d248 | ||
|
|
8920ffbcdb | ||
|
|
a447c1b42e | ||
|
|
50211f75ed | ||
|
|
27117c9d17 | ||
|
|
7c4cdd57b6 | ||
|
|
6af5698645 | ||
|
|
75db2cde40 | ||
|
|
329dd05434 | ||
|
|
53c53d03e0 | ||
|
|
360b394d03 | ||
|
|
a663d55632 | ||
|
|
3fd266a513 | ||
|
|
442c1d6242 | ||
|
|
0e2aae02f6 | ||
|
|
3227a6e49f | ||
|
|
9d0cfb628b | ||
|
|
4578fe0260 | ||
|
|
0d92708108 | ||
|
|
cceb50071b | ||
|
|
62f296c9dd | ||
|
|
ea1f280494 | ||
|
|
67108a2fc8 | ||
|
|
1ccbd5124e | ||
|
|
818af90a7b | ||
|
|
23bc78fa25 | ||
|
|
0b1cc7638f | ||
|
|
c291a2fbc1 | ||
|
|
7379a4ff4b | ||
|
|
ddcf5cb749 | ||
|
|
4b10a542b0 | ||
|
|
beea9fa74b | ||
|
|
ce8fd16456 | ||
|
|
2172d15489 | ||
|
|
0cfa0ed670 | ||
|
|
f6839913d8 | ||
|
|
8fa01497ee | ||
|
|
e077c65a77 | ||
|
|
7c49656fa8 | ||
|
|
1730479c8d | ||
|
|
bc28c8fd3c | ||
|
|
c3616fd5df | ||
|
|
6b97f2ac06 | ||
|
|
deefcbcbe4 | ||
|
|
e84aeb9f99 | ||
|
|
ade3d8a657 | ||
|
|
a65d9032ff | ||
|
|
b950a4eaf4 | ||
|
|
3fe91751f5 | ||
|
|
6ee58b96ca | ||
|
|
d1404e7905 | ||
|
|
7c34191813 | ||
|
|
7540d04779 | ||
|
|
d828130670 | ||
|
|
2ec6c08bd7 | ||
|
|
48852bab7a | ||
|
|
7d370f4513 | ||
|
|
9d97791faf | ||
|
|
4fe8982b68 | ||
|
|
8248ade211 | ||
|
|
572c0e393c | ||
|
|
d25f2bab9a | ||
|
|
916812dd58 | ||
|
|
cea84aa3c8 | ||
|
|
af83fa809a | ||
|
|
8c997cb6a9 | ||
|
|
4ccb6e4c8b | ||
|
|
37a45b1a92 | ||
|
|
ac84211702 | ||
|
|
c209ddbb24 | ||
|
|
66ab50c737 | ||
|
|
46074b0f9c | ||
|
|
56d8913159 | ||
|
|
c1bbfec203 | ||
|
|
290c2fd5b6 | ||
|
|
e472180fb2 | ||
|
|
a1ced9a259 | ||
|
|
80a700f668 | ||
|
|
54fc963297 | ||
|
|
59776adeb3 | ||
|
|
af53daa43c | ||
|
|
65123609ea | ||
|
|
847adcf977 | ||
|
|
f0dc66cb53 | ||
|
|
54275a0ee4 | ||
|
|
964f36bc50 | ||
|
|
e83cbc3fc5 | ||
|
|
e26d90d82b | ||
|
|
da52482365 | ||
|
|
6ba16ee9e9 | ||
|
|
fa29d8180f | ||
|
|
5d43efb22d | ||
|
|
3539c4bcec | ||
|
|
3e3ec4616c | ||
|
|
907861effd | ||
|
|
862a2bc95c | ||
|
|
60f498c1fa | ||
|
|
bb3617ac08 | ||
|
|
48d1bd13fa | ||
|
|
8555bc9da0 | ||
|
|
9260394883 | ||
|
|
8503637a80 | ||
|
|
c993cd9bee | ||
|
|
171013c0d0 | ||
|
|
c8a7aa359e | ||
|
|
88d8951657 | ||
|
|
b66ab3cf92 | ||
|
|
253b32abd6 | ||
|
|
cc20072c86 | ||
|
|
f86db56d48 | ||
|
|
3e2ebb8ebb | ||
|
|
6e7b206788 | ||
|
|
cee007b0b0 | ||
|
|
bd24c27bc9 | ||
|
|
49bd26da86 | ||
|
|
49c42b9ad0 | ||
|
|
411491dc45 | ||
|
|
47383a499e | ||
|
|
f9aa307cb2 | ||
|
|
7c6a31861e | ||
|
|
b2b25ca28c | ||
|
|
ad9efab16a | ||
|
|
e967d33911 | ||
|
|
86bacdbdde | ||
|
|
644a40674d | ||
|
|
2cf813758e | ||
|
|
ad47eccf5f | ||
|
|
581b554a66 | ||
|
|
e4def9eb03 | ||
|
|
5f2d17faf6 | ||
|
|
e17565c069 | ||
|
|
b856e04825 | ||
|
|
67e676df4f | ||
|
|
e2e7485e30 | ||
|
|
043a0b5aa6 | ||
|
|
457af066c8 | ||
|
|
3040fa3412 | ||
|
|
1293e7ed70 | ||
|
|
3e81cea99f | ||
|
|
4ce2dae701 | ||
|
|
a14a8c4e43 | ||
|
|
89e734d2de | ||
|
|
26c81f29e9 | ||
|
|
ce82e88919 | ||
|
|
60316a1232 | ||
|
|
aca4d3c5e6 | ||
|
|
9a93096e4b | ||
|
|
3b68aa0776 | ||
|
|
6ca60f0260 | ||
|
|
fc281b2fae | ||
|
|
3b111287d5 | ||
|
|
00f42efc7e | ||
|
|
9b9f94414b | ||
|
|
f01653633d | ||
|
|
1ace3e248f | ||
|
|
d9bde85b58 | ||
|
|
766a50abd7 | ||
|
|
9e6073099c | ||
|
|
892618d2ff | ||
|
|
79c4164e03 | ||
|
|
77dd4189b1 | ||
|
|
4dbab23ada | ||
|
|
ce7f1a6f6a | ||
|
|
6fc28298aa | ||
|
|
0130919128 | ||
|
|
200627a695 | ||
|
|
82926f8e9d | ||
|
|
07fc81361b | ||
|
|
bd8aed8e63 | ||
|
|
2c1693d50a | ||
|
|
6e60b70691 | ||
|
|
ac889feb75 | ||
|
|
a902f3bb00 | ||
|
|
fcb0c9500b | ||
|
|
f049fbdf77 | ||
|
|
20102cd83f | ||
|
|
6d6324dae5 | ||
|
|
2ee5410a6c | ||
|
|
56f02a41ca | ||
|
|
d43102de1b | ||
|
|
2bcd02b296 | ||
|
|
ad11c72488 | ||
|
|
ddfa6f83c3 | ||
|
|
85baf7a41d | ||
|
|
bf4d5a0bab | ||
|
|
16527ba707 | ||
|
|
0612ea4ee8 | ||
|
|
9e842152f7 | ||
|
|
63e79c3639 | ||
|
|
d0e4a7fa75 | ||
|
|
815976b9a4 | ||
|
|
86a5cc5edb | ||
|
|
3ebc08c5ec | ||
|
|
1bcbebb00c | ||
|
|
2895225552 | ||
|
|
f4f772ea31 | ||
|
|
66f60e6757 | ||
|
|
72d299f088 | ||
|
|
9c66561381 | ||
|
|
e762f839fa | ||
|
|
0c9d97c89f | ||
|
|
fb3ee34c81 | ||
|
|
cb99400128 | ||
|
|
58ef925a07 | ||
|
|
41bbfb8725 | ||
|
|
ed226e31b1 | ||
|
|
e900bb9770 | ||
|
|
d173d25072 | ||
|
|
0959896984 | ||
|
|
4a3ae454b8 | ||
|
|
f2cf6b69bf | ||
|
|
176f847ebb | ||
|
|
277419aafb | ||
|
|
d2b8d165d7 | ||
|
|
bf74e67700 | ||
|
|
5c3b85a37a | ||
|
|
8543f3f989 | ||
|
|
52a8a66a91 | ||
|
|
002a931e70 | ||
|
|
0667bfc81d | ||
|
|
329b2c840d | ||
|
|
ea7e94bcc1 | ||
|
|
cc30add73a | ||
|
|
21cfb9a0e5 | ||
|
|
143eadd887 | ||
|
|
855da1d070 | ||
|
|
d5be76d7e6 | ||
|
|
5f396332df | ||
|
|
56e638e170 | ||
|
|
52b90c7706 | ||
|
|
a6221d16b6 | ||
|
|
51701cab7c | ||
|
|
010e1f2d0d | ||
|
|
66909fc9ca | ||
|
|
90a28c95c8 | ||
|
|
83f2c53e8c | ||
|
|
514b6e243c | ||
|
|
742230c7be | ||
|
|
acb6b1444e | ||
|
|
f358b2231a | ||
|
|
fd24cffa6b | ||
|
|
0b5d6ee538 | ||
|
|
d125bb88d1 | ||
|
|
2ab51f582a | ||
|
|
f9b32811b2 | ||
|
|
41a423e140 | ||
|
|
f717867657 | ||
|
|
ab202a03db | ||
|
|
46a3e5e5b5 | ||
|
|
0163a4d289 | ||
|
|
6c1bf31a3c | ||
|
|
a434760a80 | ||
|
|
798990fadc | ||
|
|
b3d9d92e4a | ||
|
|
1082a9ca69 | ||
|
|
c247f56658 | ||
|
|
e7f71781f1 | ||
|
|
c4b2c5e621 | ||
|
|
7779609a76 | ||
|
|
7b9a5f897c | ||
|
|
6eccbfc1cf | ||
|
|
0da518e951 | ||
|
|
e5851b7920 | ||
|
|
1b9364e8b5 | ||
|
|
8460d4f5e2 | ||
|
|
8fd35cd70d | ||
|
|
88be115699 | ||
|
|
7f4063f91e | ||
|
|
080ba46885 | ||
|
|
2cb028ee79 | ||
|
|
72655dbf0b | ||
|
|
153278221d | ||
|
|
4942ce7e86 | ||
|
|
98e918cd8a | ||
|
|
1efc87bfef | ||
|
|
b4360ccbd9 | ||
|
|
ce234d69a7 | ||
|
|
b2a198e230 | ||
|
|
538009d2df | ||
|
|
99329851a2 | ||
|
|
f8ec395e96 | ||
|
|
98fe189edf | ||
|
|
7b413e3fd3 | ||
|
|
00ca5473d4 | ||
|
|
33c808713e | ||
|
|
c97437fbf3 | ||
|
|
ad8f14fec1 | ||
|
|
7df586eff1 | ||
|
|
f6fa95d2f7 | ||
|
|
23a8300012 | ||
|
|
694d67d2d5 | ||
|
|
a26c910db7 | ||
|
|
ac9d04624b | ||
|
|
a0ec7bde33 | ||
|
|
5f7dc49215 | ||
|
|
f79eef150e | ||
|
|
1733599442 | ||
|
|
3bde4f606b | ||
|
|
afb635125c | ||
|
|
876d54ad4d | ||
|
|
c20cd8fb94 | ||
|
|
e15b2ec0cb | ||
|
|
1829452ef1 | ||
|
|
9d8dc9ec06 | ||
|
|
72a3523193 | ||
|
|
7c3541e983 | ||
|
|
8246fc78fa | ||
|
|
78dd3aee10 | ||
|
|
c22e578aca | ||
|
|
1021c1959e | ||
|
|
d3161d8e92 | ||
|
|
fc468b56c8 | ||
|
|
ea48dc3c58 | ||
|
|
11dde08d79 | ||
|
|
5e43708a40 | ||
|
|
1ac2280266 | ||
|
|
6b1ad8d2d1 | ||
|
|
c1741237f4 | ||
|
|
8ecacd6490 | ||
|
|
188ab3930c | ||
|
|
a8dba53185 | ||
|
|
a2ef0c9a75 | ||
|
|
5a1fe17580 | ||
|
|
34388f52a6 | ||
|
|
fc2199fcf7 | ||
|
|
2236f8cd07 | ||
|
|
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 | ||
|
|
0db9dcfd1c | ||
|
|
5b5850224a | ||
|
|
065b0eb5b2 | ||
|
|
6a1d86d5db | ||
|
|
f99a73ef28 | ||
|
|
0436d30062 | ||
|
|
24b6b5452b | ||
|
|
8b91ebfe30 | ||
|
|
37d3b73c1b | ||
|
|
c881d9809e | ||
|
|
62ec64c3fe | ||
|
|
85dfe3a107 | ||
|
|
cbc6306963 | ||
|
|
d8a468833e | ||
|
|
e098acfa69 | ||
|
|
5bbd56b8e6 | ||
|
|
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 | ||
|
|
d0411b6613 | ||
|
|
293fbebef2 | ||
|
|
cfe542acb9 | ||
|
|
8da323d4b7 | ||
|
|
b2edf637cc | ||
|
|
de61a45de1 | ||
|
|
d9324cb0e4 | ||
|
|
4a464f601c | ||
|
|
43e9c24c18 | ||
|
|
1c3492b4c2 | ||
|
|
e0cb56a38c | ||
|
|
6e05cc4898 | ||
|
|
6f9dc2e5a2 | ||
|
|
ddb1ae371d | ||
|
|
6553337b79 | ||
|
|
aedc729d57 | ||
|
|
31fa69b609 | ||
|
|
b819a866b9 | ||
|
|
6cc7d83def | ||
|
|
5154418051 | ||
|
|
7e63c12b95 | ||
|
|
d17e951591 | ||
|
|
9198e5f56d | ||
|
|
97d7e0e01e | ||
|
|
4d5b8c4b08 | ||
|
|
abb011311e | ||
|
|
92cf7623fa | ||
|
|
aedf4c881b | ||
|
|
74baf44c83 | ||
|
|
9afb4a9eb8 | ||
|
|
e1967bef9a | ||
|
|
f17b6aa9e4 | ||
|
|
dd6d7397d9 | ||
|
|
aeabd2d2cc | ||
|
|
d7af2f39c2 | ||
|
|
a674ad11bc | ||
|
|
ccb64d7fd8 | ||
|
|
36691e2a3d | ||
|
|
8971f75f13 | ||
|
|
173db170af | ||
|
|
881851a4f6 | ||
|
|
4b4b64e939 | ||
|
|
e721c1a092 | ||
|
|
0933c9fe51 | ||
|
|
632b3e5dc3 | ||
|
|
434cb48344 | ||
|
|
86d4c3cbbf | ||
|
|
3019f9041c | ||
|
|
9e0a3dee08 | ||
|
|
fefe7d9e5d | ||
|
|
4c382cedff | ||
|
|
6ffd05313b | ||
|
|
9be0214021 | ||
|
|
54300430b7 | ||
|
|
a25038259e | ||
|
|
81be14c8f1 | ||
|
|
62464b83dc | ||
|
|
beb909528c | ||
|
|
ef28715360 | ||
|
|
78cc41fdc0 | ||
|
|
6a868ca5cc | ||
|
|
f43dead38c | ||
|
|
86163252e1 | ||
|
|
0cd5202596 | ||
|
|
33dcde7de1 | ||
|
|
c449b2e2e8 | ||
|
|
f40f7072c8 | ||
|
|
4163ecd833 | ||
|
|
9c59d528af | ||
|
|
c2440c4ebd | ||
|
|
cb275f65ba | ||
|
|
b1923df3ca | ||
|
|
7ddfd155ca | ||
|
|
e01df6d10d | ||
|
|
54010728d5 | ||
|
|
62a3b3827f | ||
|
|
b9abfba20f | ||
|
|
eca9f36e55 | ||
|
|
3c865c6f41 | ||
|
|
3b32c4bcbf | ||
|
|
fcdc1cfed9 | ||
|
|
0fd782c4ab | ||
|
|
bbcaf69973 | ||
|
|
f2b713acac | ||
|
|
6c944d6b15 | ||
|
|
4dd3abb16a | ||
|
|
d2672b9ddf | ||
|
|
ff30492919 | ||
|
|
b5ccdf8165 | ||
|
|
b3c745cfa7 | ||
|
|
67aeafa797 | ||
|
|
3d71b6de44 | ||
|
|
5349045932 | ||
|
|
4960871c84 | ||
|
|
af3861cd6b | ||
|
|
f9a070e9b3 | ||
|
|
fd503b2e33 | ||
|
|
e5a73fcf57 | ||
|
|
6991e01489 | ||
|
|
c8636ee6f3 | ||
|
|
52229dc5a8 | ||
|
|
f013455843 | ||
|
|
cae5bca546 | ||
|
|
49299b06c6 | ||
|
|
8e39027ad5 | ||
|
|
2a1ce2df61 | ||
|
|
7a6d929150 | ||
|
|
6f4a112dbb | ||
|
|
2197b910fb | ||
|
|
7e2a9cd7f9 | ||
|
|
e7ed7a8ed2 | ||
|
|
9ba2d0defe | ||
|
|
231300919c | ||
|
|
664c50586f | ||
|
|
43b9ecfc2b | ||
|
|
f1237ed52a | ||
|
|
ecf8f55cc4 | ||
|
|
ff36693057 | ||
|
|
005785997c | ||
|
|
9917b82b66 | ||
|
|
9c927406ac | ||
|
|
972d95602a | ||
|
|
5e0549a18f | ||
|
|
bcbb159fb2 | ||
|
|
0123ca656a | ||
|
|
1f699c729c | ||
|
|
50c3fcfeba | ||
|
|
2af1e098cc | ||
|
|
c418d9750b | ||
|
|
e96d614076 | ||
|
|
f0a5e0a023 | ||
|
|
6ac6b86060 | ||
|
|
3909171b1a | ||
|
|
769029505f | ||
|
|
080ec3524b | ||
|
|
48d671ad5f | ||
|
|
7115db5d22 | ||
|
|
d0c8792e4b | ||
|
|
84d7c37502 | ||
|
|
8a10638470 | ||
|
|
10dd53ffc2 | ||
|
|
36aefce9e1 | ||
|
|
fe34da19e2 | ||
|
|
fe94dea1db | ||
|
|
3f57b46756 | ||
|
|
7e141533bb | ||
|
|
391ccbafae | ||
|
|
6af674e64e | ||
|
|
7b1653c77b | ||
|
|
c87dafa2e6 | ||
|
|
8375acf315 | ||
|
|
4df5a41b57 | ||
|
|
5796b4c0d9 | ||
|
|
5f4f07803b | ||
|
|
a0a444e3c8 | ||
|
|
30cfe987ed | ||
|
|
412ee0da05 | ||
|
|
d6b675138d | ||
|
|
bde3cef17d | ||
|
|
412ee30584 | ||
|
|
7eecdc87fd | ||
|
|
9ba252d8e3 | ||
|
|
1709a9d255 | ||
|
|
bcf46f09a2 | ||
|
|
d4097a8686 | ||
|
|
2a92292e76 | ||
|
|
fe987a63d6 | ||
|
|
91f3b991ba | ||
|
|
46c6313068 | ||
|
|
86e4a81934 | ||
|
|
234d6ae161 | ||
|
|
2ab203618e | ||
|
|
faae23ee1b | ||
|
|
f6acd4f230 | ||
|
|
71d36a6496 | ||
|
|
9fc014c6f4 | ||
|
|
537f93872c | ||
|
|
06a55175a8 | ||
|
|
5f37016baa | ||
|
|
1af884293f | ||
|
|
ba73ab38e8 | ||
|
|
2d33a720f7 | ||
|
|
dbfdaf6a2e | ||
|
|
278cb4d3ae | ||
|
|
1c6f8b7e54 | ||
|
|
731f5078a6 | ||
|
|
9863d3484d | ||
|
|
f85a684e31 | ||
|
|
e292a67692 | ||
|
|
c82d159c14 | ||
|
|
d890387d3d | ||
|
|
d996d7b113 | ||
|
|
d28a4598d5 | ||
|
|
229f7c4f37 | ||
|
|
9f2138aa18 | ||
|
|
7506ff826c | ||
|
|
317a3ed044 | ||
|
|
d7801881e9 | ||
|
|
a4bbdafd55 | ||
|
|
97673f22cb | ||
|
|
d63cdafad2 | ||
|
|
50f47a7397 | ||
|
|
123d573274 | ||
|
|
64ccde6709 | ||
|
|
c69ef7e1f6 | ||
|
|
d51cca3325 | ||
|
|
2679ac3f5e | ||
|
|
47f476af32 | ||
|
|
ca3d03131e | ||
|
|
a3f3586b02 | ||
|
|
0ced960d1d | ||
|
|
78f1b434b3 | ||
|
|
563fa8f958 | ||
|
|
8de6f04829 | ||
|
|
f74128de49 | ||
|
|
25e1cc42eb | ||
|
|
245d57be1a | ||
|
|
c09c016299 | ||
|
|
d9283ad4cd | ||
|
|
11f319c79c | ||
|
|
cc5d98fe8b | ||
|
|
3e1f9de0de | ||
|
|
34212c6e65 | ||
|
|
10f02d040f | ||
|
|
1114ce8509 | ||
|
|
8687a7b306 | ||
|
|
d325f677df | ||
|
|
f786ec18a9 | ||
|
|
ef0add1d6c | ||
|
|
46f56c60f2 | ||
|
|
060b258921 | ||
|
|
8eb3e63d9d | ||
|
|
6c96acda82 | ||
|
|
c82b179e03 | ||
|
|
1e1265c99c | ||
|
|
22e975f911 | ||
|
|
b1e3f8d4f4 | ||
|
|
95fe573620 | ||
|
|
d29d82cf20 | ||
|
|
23459d69c9 | ||
|
|
5209f4d296 | ||
|
|
39c5983571 | ||
|
|
b5015faffe | ||
|
|
16d3707d13 | ||
|
|
9bb6d740e0 | ||
|
|
f640795de1 | ||
|
|
c1b512d50a | ||
|
|
0ba43b22a9 | ||
|
|
b11b790958 | ||
|
|
81fb769233 | ||
|
|
c5ac806832 | ||
|
|
256baf5097 | ||
|
|
cccefddb72 | ||
|
|
85294b1d96 | ||
|
|
ffcde8dd74 | ||
|
|
13b50b355e | ||
|
|
7a2592173b | ||
|
|
d3b36d0081 | ||
|
|
5bea0d57ec | ||
|
|
903c73b5dd | ||
|
|
603c664c9b | ||
|
|
43d0d582ef | ||
|
|
c12d2ec0bd | ||
|
|
bfedcef9b9 | ||
|
|
d41d93c44e | ||
|
|
11cc8beac1 | ||
|
|
0f827403c5 | ||
|
|
4827a603e5 | ||
|
|
e3f3861d4e | ||
|
|
60df4433ca | ||
|
|
46b6557348 | ||
|
|
639e736c66 | ||
|
|
8a198401a7 | ||
|
|
c917dfeed9 | ||
|
|
25155de30c | ||
|
|
6a927c37be | ||
|
|
9b41bb09a7 | ||
|
|
e58fc6976d | ||
|
|
9def627a57 | ||
|
|
9b56759c1e | ||
|
|
c3f743cafd | ||
|
|
160c495ddc | ||
|
|
9a1cd8545d | ||
|
|
fa81e6cd04 | ||
|
|
746f4ef1e2 | ||
|
|
0149de6ba6 | ||
|
|
1df2f18e0a | ||
|
|
5800824893 | ||
|
|
aac07b6b4b | ||
|
|
2448ce1970 | ||
|
|
7b40e3b8a7 | ||
|
|
29d06cfcc9 | ||
|
|
365d168ddd | ||
|
|
234191336e | ||
|
|
ba57b72658 | ||
|
|
bb08b315b8 | ||
|
|
50621df244 | ||
|
|
2db7b5c99f | ||
|
|
78af3acf35 | ||
|
|
b72f04d44e | ||
|
|
35f287e330 | ||
|
|
0a55f83b46 | ||
|
|
5030d0ba90 | ||
|
|
f582f06ee4 | ||
|
|
662bada5d8 | ||
|
|
3ca338dd25 | ||
|
|
9337a0e71b | ||
|
|
ccbb00197d | ||
|
|
0f59c17e61 | ||
|
|
6253ade3e2 | ||
|
|
e5890378a1 | ||
|
|
b8ab0bcadf | ||
|
|
19cb827577 | ||
|
|
03676d7e5a | ||
|
|
13f3b49b96 | ||
|
|
90c8c56a06 | ||
|
|
afb9e18a7d | ||
|
|
2c2934065f | ||
|
|
0bead67df9 | ||
|
|
2895849203 | ||
|
|
b2400708ac | ||
|
|
0bed9c20b3 | ||
|
|
d3fb7a7b87 | ||
|
|
60dcca4143 | ||
|
|
01f498f239 | ||
|
|
15055b8e8e | ||
|
|
6826619e12 | ||
|
|
b50a8e04a8 | ||
|
|
c6c67c5357 | ||
|
|
c82803d1e2 | ||
|
|
732b30f181 | ||
|
|
0e2e57a657 | ||
|
|
f00b0080a9 | ||
|
|
ad970c1234 | ||
|
|
02ec56bffa | ||
|
|
8388c290bf | ||
|
|
576ee99faf | ||
|
|
8a3534c345 | ||
|
|
e1e91c5568 | ||
|
|
1e09bddb1d | ||
|
|
90e4340595 | ||
|
|
120b17349c | ||
|
|
8a26961304 | ||
|
|
407b675080 | ||
|
|
274844271b | ||
|
|
f11e4e7bda | ||
|
|
96f8c39c6f | ||
|
|
77b79fef8d | ||
|
|
a0d2f285f3 | ||
|
|
3aef05d1ec | ||
|
|
510e391ee4 | ||
|
|
54adfdd694 | ||
|
|
d45f920b4a | ||
|
|
3080ef9a4a | ||
|
|
51cebb52f3 | ||
|
|
7b0d4c47b7 | ||
|
|
a660ab3f97 | ||
|
|
dd8fc16788 | ||
|
|
2b0fab0468 | ||
|
|
3bb88ed433 | ||
|
|
984385cd98 | ||
|
|
09de108676 | ||
|
|
ebc7581718 | ||
|
|
e55162812d | ||
|
|
aa6ccaa024 | ||
|
|
e1b009a6de | ||
|
|
91ddc525b0 | ||
|
|
d7d7954ac2 | ||
|
|
e87c260df7 | ||
|
|
5185c6cd68 | ||
|
|
7599c918e2 | ||
|
|
fa7e22ec91 | ||
|
|
606519e51b | ||
|
|
8e39e010f7 | ||
|
|
dc01cf49a0 | ||
|
|
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 |
238
.claude/skills/ha-integration/BINARY-SENSOR.MD
Normal file
238
.claude/skills/ha-integration/BINARY-SENSOR.MD
Normal file
@@ -0,0 +1,238 @@
|
||||
# Binary Sensor Platform Reference
|
||||
|
||||
Binary sensors represent on/off states.
|
||||
|
||||
## Basic Binary Sensor
|
||||
|
||||
```python
|
||||
"""Binary sensor platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
DoorSensor(coordinator),
|
||||
MotionSensor(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class DoorSensor(MyEntity, BinarySensorEntity):
|
||||
"""Door open/close sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
_attr_translation_key = "door"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_door"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if door is open."""
|
||||
return self.coordinator.data.door_open
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
Common binary sensor device classes:
|
||||
|
||||
| Device Class | On Means | Off Means |
|
||||
|--------------|----------|-----------|
|
||||
| `BATTERY` | Low | Normal |
|
||||
| `BATTERY_CHARGING` | Charging | Not charging |
|
||||
| `CONNECTIVITY` | Connected | Disconnected |
|
||||
| `DOOR` | Open | Closed |
|
||||
| `GARAGE_DOOR` | Open | Closed |
|
||||
| `LOCK` | Unlocked | Locked |
|
||||
| `MOISTURE` | Wet | Dry |
|
||||
| `MOTION` | Motion detected | Clear |
|
||||
| `OCCUPANCY` | Occupied | Clear |
|
||||
| `OPENING` | Open | Closed |
|
||||
| `PLUG` | Plugged in | Unplugged |
|
||||
| `POWER` | Power detected | No power |
|
||||
| `PRESENCE` | Present | Away |
|
||||
| `PROBLEM` | Problem | OK |
|
||||
| `RUNNING` | Running | Not running |
|
||||
| `SAFETY` | Unsafe | Safe |
|
||||
| `SMOKE` | Smoke detected | Clear |
|
||||
| `SOUND` | Sound detected | Clear |
|
||||
| `TAMPER` | Tampering | Clear |
|
||||
| `UPDATE` | Update available | Up-to-date |
|
||||
| `VIBRATION` | Vibration | Clear |
|
||||
| `WINDOW` | Open | Closed |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe My binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[MyData], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[MyBinarySensorEntityDescription, ...] = (
|
||||
MyBinarySensorEntityDescription(
|
||||
key="door",
|
||||
translation_key="door",
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
is_on_fn=lambda data: data.door_open,
|
||||
),
|
||||
MyBinarySensorEntityDescription(
|
||||
key="motion",
|
||||
translation_key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda data: data.motion_detected,
|
||||
),
|
||||
MyBinarySensorEntityDescription(
|
||||
key="low_battery",
|
||||
translation_key="low_battery",
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda data: data.battery_level < 20 if data.battery_level else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MyBinarySensor(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
|
||||
class MyBinarySensor(MyEntity, BinarySensorEntity):
|
||||
"""Binary sensor using entity description."""
|
||||
|
||||
entity_description: MyBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Connectivity Sensor
|
||||
|
||||
```python
|
||||
class ConnectivitySensor(MyEntity, BinarySensorEntity):
|
||||
"""Device connectivity sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "connectivity"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
return self.coordinator.data.is_connected
|
||||
```
|
||||
|
||||
## Problem Sensor
|
||||
|
||||
```python
|
||||
class ProblemSensor(MyEntity, BinarySensorEntity):
|
||||
"""Problem indicator sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "problem"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if there's a problem."""
|
||||
return self.coordinator.data.has_error
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
return {
|
||||
"error_code": self.coordinator.data.error_code,
|
||||
"error_message": self.coordinator.data.error_message,
|
||||
}
|
||||
```
|
||||
|
||||
## Update Available Sensor
|
||||
|
||||
```python
|
||||
class UpdateAvailableSensor(MyEntity, BinarySensorEntity):
|
||||
"""Firmware update available sensor."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.UPDATE
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "update_available"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if an update is available."""
|
||||
return self.coordinator.data.update_available
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"door": {
|
||||
"name": "Door"
|
||||
},
|
||||
"motion": {
|
||||
"name": "Motion"
|
||||
},
|
||||
"low_battery": {
|
||||
"name": "Low battery"
|
||||
},
|
||||
"connectivity": {
|
||||
"name": "Connectivity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
201
.claude/skills/ha-integration/BUTTON.MD
Normal file
201
.claude/skills/ha-integration/BUTTON.MD
Normal file
@@ -0,0 +1,201 @@
|
||||
# Button Platform Reference
|
||||
|
||||
Button entities trigger actions when pressed.
|
||||
|
||||
## Basic Button
|
||||
|
||||
```python
|
||||
"""Button platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up buttons from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
RestartButton(coordinator),
|
||||
IdentifyButton(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class RestartButton(MyEntity, ButtonEntity):
|
||||
"""Restart button."""
|
||||
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
_attr_translation_key = "restart"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_restart"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.coordinator.client.restart()
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
| Device Class | Icon | Use Case |
|
||||
|--------------|------|----------|
|
||||
| `IDENTIFY` | mdi:crosshairs-question | Flash light/beep to locate device |
|
||||
| `RESTART` | mdi:restart | Restart the device |
|
||||
| `UPDATE` | mdi:package-up | Trigger firmware update |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describe My button entity."""
|
||||
|
||||
press_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
BUTTONS: tuple[MyButtonEntityDescription, ...] = (
|
||||
MyButtonEntityDescription(
|
||||
key="restart",
|
||||
translation_key="restart",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.restart(),
|
||||
),
|
||||
MyButtonEntityDescription(
|
||||
key="identify",
|
||||
translation_key="identify",
|
||||
device_class=ButtonDeviceClass.IDENTIFY,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.identify(),
|
||||
),
|
||||
MyButtonEntityDescription(
|
||||
key="factory_reset",
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda client: client.factory_reset(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up buttons from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MyButton(coordinator, description)
|
||||
for description in BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class MyButton(MyEntity, ButtonEntity):
|
||||
"""Button using entity description."""
|
||||
|
||||
entity_description: MyButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.entity_description.press_fn(self.coordinator.client)
|
||||
```
|
||||
|
||||
## Identify Button
|
||||
|
||||
```python
|
||||
class IdentifyButton(MyEntity, ButtonEntity):
|
||||
"""Identify button to locate the device."""
|
||||
|
||||
_attr_device_class = ButtonDeviceClass.IDENTIFY
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "identify"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Flash the device LED to identify it."""
|
||||
await self.coordinator.client.identify()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class SafeButton(MyEntity, ButtonEntity):
|
||||
"""Button with error handling."""
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press with error handling."""
|
||||
try:
|
||||
await self.coordinator.client.perform_action()
|
||||
except MyDeviceError as err:
|
||||
raise HomeAssistantError(f"Failed to perform action: {err}") from err
|
||||
```
|
||||
|
||||
## Confirmation Buttons
|
||||
|
||||
For dangerous operations, consider using a diagnostic category and clear naming:
|
||||
|
||||
```python
|
||||
class FactoryResetButton(MyEntity, ButtonEntity):
|
||||
"""Factory reset button."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_translation_key = "factory_reset"
|
||||
_attr_entity_registry_enabled_default = False # Disabled by default
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Perform factory reset."""
|
||||
await self.coordinator.client.factory_reset()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"restart": {
|
||||
"name": "Restart"
|
||||
},
|
||||
"identify": {
|
||||
"name": "Identify"
|
||||
},
|
||||
"factory_reset": {
|
||||
"name": "Factory reset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
254
.claude/skills/ha-integration/CONFIG-FLOW.MD
Normal file
254
.claude/skills/ha-integration/CONFIG-FLOW.MD
Normal file
@@ -0,0 +1,254 @@
|
||||
# Config Flow Reference
|
||||
|
||||
Configuration flows allow users to set up integrations via the UI.
|
||||
|
||||
## Basic Config Flow
|
||||
|
||||
```python
|
||||
"""Config flow for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_API_KEY
|
||||
from homeassistant.helpers.selector import TextSelector
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): TextSelector(),
|
||||
vol.Required(CONF_API_KEY): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MyIntegrationConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for My Integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
# Test connection
|
||||
client = MyClient(user_input[CONF_HOST], user_input[CONF_API_KEY])
|
||||
info = await client.get_device_info()
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Set unique ID and abort if already configured
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=info.name,
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Version Control
|
||||
|
||||
Always set version numbers:
|
||||
|
||||
```python
|
||||
VERSION = 1 # Bump for breaking changes requiring migration
|
||||
MINOR_VERSION = 1 # Bump for backward-compatible changes
|
||||
```
|
||||
|
||||
## Unique ID Management
|
||||
|
||||
```python
|
||||
# Set unique ID and abort if exists
|
||||
await self.async_set_unique_id(device_serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Or abort if data matches (when no unique ID available)
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
## Reauthentication Flow
|
||||
|
||||
```python
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
client = MyClient(
|
||||
self._get_reauth_entry().data[CONF_HOST],
|
||||
user_input[CONF_API_KEY]
|
||||
)
|
||||
info = await client.get_device_info()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Reconfiguration Flow
|
||||
|
||||
```python
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
client = MyClient(user_input[CONF_HOST], reconfigure_entry.data[CONF_API_KEY])
|
||||
info = await client.get_device_info()
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(info.serial_number)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_device")
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_HOST: user_input[CONF_HOST]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOST, default=reconfigure_entry.data[CONF_HOST]): str
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
## Discovery Flows
|
||||
|
||||
### Zeroconf Discovery
|
||||
|
||||
```python
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
serial = discovery_info.properties.get("serialno")
|
||||
if not serial:
|
||||
return self.async_abort(reason="no_serial")
|
||||
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: str(discovery_info.host)}
|
||||
)
|
||||
|
||||
self._discovered_host = str(discovery_info.host)
|
||||
self._discovered_name = discovery_info.name.removesuffix("._mydevice._tcp.local.")
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._discovered_name,
|
||||
data={CONF_HOST: self._discovered_host},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"name": self._discovered_name},
|
||||
)
|
||||
```
|
||||
|
||||
## strings.json for Config Flow
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to device",
|
||||
"description": "Enter your device credentials.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"api_key": "API key"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate",
|
||||
"description": "Please enter a new API key for {name}.",
|
||||
"data": {
|
||||
"api_key": "API key"
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"title": "Discovered device",
|
||||
"description": "Do you want to set up {name}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"wrong_account": "Wrong account",
|
||||
"wrong_device": "Wrong device"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **Never allow user-configurable entry names** (except helper integrations)
|
||||
2. **Always test connection** before creating entry
|
||||
3. **Always set unique ID** when possible
|
||||
4. **Handle all exceptions** - bare `except Exception:` is allowed in config flows
|
||||
5. **100% test coverage required** for all flow paths
|
||||
239
.claude/skills/ha-integration/COORDINATOR.MD
Normal file
239
.claude/skills/ha-integration/COORDINATOR.MD
Normal file
@@ -0,0 +1,239 @@
|
||||
# Data Update Coordinator Reference
|
||||
|
||||
The coordinator pattern centralizes data fetching and provides efficient polling.
|
||||
|
||||
## Basic Coordinator
|
||||
|
||||
```python
|
||||
"""DataUpdateCoordinator for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from my_library import MyClient, MyData, MyError, AuthError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MyIntegrationConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
"""My integration data update coordinator."""
|
||||
|
||||
config_entry: MyIntegrationConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
client: MyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.client.get_data()
|
||||
except AuthError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from err
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
### Always Pass config_entry
|
||||
|
||||
```python
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry, # Always include this
|
||||
)
|
||||
```
|
||||
|
||||
### Generic Type Parameter
|
||||
|
||||
Specify the data type returned by `_async_update_data`:
|
||||
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
...
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
- **`UpdateFailed`**: API communication errors (will retry)
|
||||
- **`ConfigEntryAuthFailed`**: Authentication issues (triggers reauth flow)
|
||||
|
||||
## Polling Intervals
|
||||
|
||||
**Integration determines intervals** - never make them user-configurable.
|
||||
|
||||
```python
|
||||
# Constants (in const.py)
|
||||
SCAN_INTERVAL_LOCAL = timedelta(seconds=30)
|
||||
SCAN_INTERVAL_CLOUD = timedelta(minutes=5)
|
||||
|
||||
# In coordinator
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, entry: MyIntegrationConfigEntry, client: MyClient) -> None:
|
||||
# Determine interval based on connection type
|
||||
interval = SCAN_INTERVAL_LOCAL if client.is_local else SCAN_INTERVAL_CLOUD
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=interval,
|
||||
config_entry=entry,
|
||||
)
|
||||
```
|
||||
|
||||
**Minimum intervals:**
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
|
||||
## Coordinator with Device Info
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
"""Coordinator with device information."""
|
||||
|
||||
config_entry: MyIntegrationConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
client: MyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, client.serial_number)},
|
||||
name=client.name,
|
||||
manufacturer="My Company",
|
||||
model=client.model,
|
||||
sw_version=client.firmware_version,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
return await self.client.get_data()
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
```
|
||||
|
||||
## Multiple Data Sources
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyCoordinatorData:
|
||||
"""Data class for coordinator."""
|
||||
|
||||
sensors: dict[str, SensorData]
|
||||
status: DeviceStatus
|
||||
settings: DeviceSettings
|
||||
|
||||
|
||||
class MyCoordinator(DataUpdateCoordinator[MyCoordinatorData]):
|
||||
"""Coordinator for multiple data sources."""
|
||||
|
||||
async def _async_update_data(self) -> MyCoordinatorData:
|
||||
"""Fetch all data sources."""
|
||||
try:
|
||||
# Fetch all data concurrently
|
||||
sensors, status, settings = await asyncio.gather(
|
||||
self.client.get_sensors(),
|
||||
self.client.get_status(),
|
||||
self.client.get_settings(),
|
||||
)
|
||||
except MyError as err:
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
return MyCoordinatorData(
|
||||
sensors=sensors,
|
||||
status=status,
|
||||
settings=settings,
|
||||
)
|
||||
```
|
||||
|
||||
## Setup in __init__.py
|
||||
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Set up My Integration from a config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], entry.data[CONF_API_KEY])
|
||||
|
||||
coordinator = MyCoordinator(hass, entry, client)
|
||||
|
||||
# Perform first refresh - raises ConfigEntryNotReady on failure
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Testing Coordinators
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mock_coordinator(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> MyCoordinator:
|
||||
"""Return a mocked coordinator."""
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, MagicMock())
|
||||
coordinator.data = MyData(temperature=21.5, humidity=45)
|
||||
return coordinator
|
||||
|
||||
|
||||
async def test_coordinator_update_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles update failure."""
|
||||
mock_client.get_data.side_effect = MyError("Connection failed")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(UpdateFailed):
|
||||
await coordinator._async_update_data()
|
||||
```
|
||||
248
.claude/skills/ha-integration/DEVICE.MD
Normal file
248
.claude/skills/ha-integration/DEVICE.MD
Normal file
@@ -0,0 +1,248 @@
|
||||
# Device Management Reference
|
||||
|
||||
Device management groups entities and provides device information.
|
||||
|
||||
## Device Info
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Base entity with device info."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.client.serial_number)},
|
||||
name=coordinator.client.name,
|
||||
manufacturer="My Company",
|
||||
model=coordinator.client.model,
|
||||
sw_version=coordinator.client.firmware_version,
|
||||
hw_version=coordinator.client.hardware_version,
|
||||
)
|
||||
```
|
||||
|
||||
## DeviceInfo Fields
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `identifiers` | Set of (domain, id) tuples | `{(DOMAIN, "ABC123")}` |
|
||||
| `connections` | Set of (type, id) tuples | `{(CONNECTION_NETWORK_MAC, mac)}` |
|
||||
| `name` | Device name | `"Living Room Thermostat"` |
|
||||
| `manufacturer` | Manufacturer name | `"My Company"` |
|
||||
| `model` | Model name | `"Smart Thermostat v2"` |
|
||||
| `model_id` | Model identifier | `"THM-2000"` |
|
||||
| `sw_version` | Software/firmware version | `"1.2.3"` |
|
||||
| `hw_version` | Hardware version | `"rev2"` |
|
||||
| `serial_number` | Serial number | `"ABC123456"` |
|
||||
| `configuration_url` | Device config URL | `"http://192.168.1.100"` |
|
||||
| `suggested_area` | Suggested room/area | `"Living Room"` |
|
||||
| `entry_type` | Device entry type | `DeviceEntryType.SERVICE` |
|
||||
| `via_device` | Parent device identifiers | `(DOMAIN, "hub_id")` |
|
||||
|
||||
## Device with Connections
|
||||
|
||||
Use connections (like MAC address) for better device merging:
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(coordinator.client.mac))},
|
||||
identifiers={(DOMAIN, coordinator.client.serial_number)},
|
||||
name=coordinator.client.name,
|
||||
manufacturer="My Company",
|
||||
model=coordinator.client.model,
|
||||
)
|
||||
```
|
||||
|
||||
## Hub and Child Devices
|
||||
|
||||
```python
|
||||
# Hub device
|
||||
class HubEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Hub entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the hub entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.hub_id)},
|
||||
name="My Hub",
|
||||
manufacturer="My Company",
|
||||
model="Hub Pro",
|
||||
)
|
||||
|
||||
|
||||
# Child device connected via hub
|
||||
class ChildEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Child device entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator, device: ChildDevice) -> None:
|
||||
"""Initialize the child entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model=device.model,
|
||||
via_device=(DOMAIN, coordinator.hub_id), # Links to parent hub
|
||||
)
|
||||
```
|
||||
|
||||
## Service Entry Type
|
||||
|
||||
For cloud services without physical devices:
|
||||
|
||||
```python
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name="My Cloud Service",
|
||||
manufacturer="My Company",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
```
|
||||
|
||||
## Dynamic Device Addition
|
||||
|
||||
Auto-detect new devices after initial setup:
|
||||
|
||||
```python
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _check_devices() -> None:
|
||||
"""Check for new devices."""
|
||||
current_devices = set(coordinator.data.devices.keys())
|
||||
new_devices = current_devices - known_devices
|
||||
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
MySensor(coordinator, device_id)
|
||||
for device_id in new_devices
|
||||
)
|
||||
|
||||
# Initial setup
|
||||
_check_devices()
|
||||
|
||||
# Listen for updates
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
|
||||
```
|
||||
|
||||
## Stale Device Removal
|
||||
|
||||
Remove devices when they disappear:
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> MyData:
|
||||
"""Fetch data and handle device removal."""
|
||||
data = await self.client.get_data()
|
||||
|
||||
# Check for removed devices
|
||||
device_registry = dr.async_get(self.hass)
|
||||
current_device_ids = set(data.devices.keys())
|
||||
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
):
|
||||
# Get device ID from identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
if device_id and device_id not in current_device_ids:
|
||||
# Device no longer exists, remove it
|
||||
device_registry.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
## Manual Device Removal
|
||||
|
||||
Allow users to manually remove devices:
|
||||
|
||||
```python
|
||||
# In __init__.py
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: MyIntegrationConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
# Get device ID from identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
if device_id is None:
|
||||
return False
|
||||
|
||||
# Check if device is still present (don't allow removal of active devices)
|
||||
coordinator = config_entry.runtime_data
|
||||
if device_id in coordinator.data.devices:
|
||||
return False # Device still exists, can't remove
|
||||
|
||||
return True # Allow removal of stale device
|
||||
```
|
||||
|
||||
## Device Registry Access
|
||||
|
||||
```python
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
|
||||
# Get device registry
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Get device by identifiers
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
)
|
||||
|
||||
# Get all devices for config entry
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
)
|
||||
|
||||
# Update device
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
sw_version="2.0.0",
|
||||
)
|
||||
```
|
||||
|
||||
## Quality Scale Requirements
|
||||
|
||||
- **Bronze**: No specific device requirements
|
||||
- **Gold**: Devices rule - group entities under devices
|
||||
- **Gold**: Stale device removal - auto-remove disconnected devices
|
||||
- **Gold**: Dynamic device addition - detect new devices at runtime
|
||||
278
.claude/skills/ha-integration/DIAGNOSTICS.MD
Normal file
278
.claude/skills/ha-integration/DIAGNOSTICS.MD
Normal file
@@ -0,0 +1,278 @@
|
||||
# Diagnostics Reference
|
||||
|
||||
Diagnostics provide debug information for troubleshooting integrations.
|
||||
|
||||
## Basic Diagnostics
|
||||
|
||||
```python
|
||||
"""Diagnostics support for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_API_KEY,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
"serial_number",
|
||||
"mac_address",
|
||||
"latitude",
|
||||
"longitude",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
|
||||
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||
"coordinator_data": async_redact_data(
|
||||
coordinator.data.to_dict(), TO_REDACT
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
## What to Include
|
||||
|
||||
**Do include:**
|
||||
- Configuration data (redacted)
|
||||
- Current coordinator data
|
||||
- Device information
|
||||
- Error states and counts
|
||||
- Connection status
|
||||
- Firmware versions
|
||||
- Feature flags
|
||||
|
||||
**Never include (always redact):**
|
||||
- API keys, tokens, passwords
|
||||
- Geographic coordinates (latitude/longitude)
|
||||
- Personal identifiable information
|
||||
- Email addresses
|
||||
- MAC addresses (unless needed for debugging)
|
||||
- Serial numbers (unless needed for debugging)
|
||||
|
||||
## Comprehensive Diagnostics
|
||||
|
||||
```python
|
||||
"""Diagnostics support for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_API_KEY,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
"serial",
|
||||
"serial_number",
|
||||
"mac",
|
||||
"mac_address",
|
||||
"email",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Get device registry entries
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
devices = []
|
||||
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
|
||||
entities = []
|
||||
for entity in er.async_entries_for_device(
|
||||
entity_registry, device.id, include_disabled_entities=True
|
||||
):
|
||||
entities.append({
|
||||
"entity_id": entity.entity_id,
|
||||
"unique_id": entity.unique_id,
|
||||
"platform": entity.platform,
|
||||
"disabled": entity.disabled,
|
||||
"disabled_by": entity.disabled_by,
|
||||
})
|
||||
|
||||
devices.append({
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"manufacturer": device.manufacturer,
|
||||
"sw_version": device.sw_version,
|
||||
"hw_version": device.hw_version,
|
||||
"identifiers": list(device.identifiers),
|
||||
"connections": list(device.connections),
|
||||
"entities": entities,
|
||||
})
|
||||
|
||||
return {
|
||||
"entry": {
|
||||
"version": entry.version,
|
||||
"minor_version": entry.minor_version,
|
||||
"data": async_redact_data(dict(entry.data), TO_REDACT),
|
||||
"options": async_redact_data(dict(entry.options), TO_REDACT),
|
||||
},
|
||||
"coordinator": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
|
||||
"data": async_redact_data(coordinator.data.to_dict(), TO_REDACT),
|
||||
},
|
||||
"devices": devices,
|
||||
}
|
||||
```
|
||||
|
||||
## Device-Level Diagnostics
|
||||
|
||||
For integrations with multiple devices, you can also provide device-level diagnostics:
|
||||
|
||||
```python
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, entry: MyIntegrationConfigEntry, device: dr.DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Find device data based on device identifiers
|
||||
device_id = next(
|
||||
(id for domain, id in device.identifiers if domain == DOMAIN), None
|
||||
)
|
||||
|
||||
if device_id is None:
|
||||
return {"error": "Device not found"}
|
||||
|
||||
device_data = coordinator.data.devices.get(device_id)
|
||||
if device_data is None:
|
||||
return {"error": "Device data not found"}
|
||||
|
||||
return {
|
||||
"device_info": {
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"sw_version": device.sw_version,
|
||||
},
|
||||
"device_data": async_redact_data(device_data.to_dict(), TO_REDACT),
|
||||
}
|
||||
```
|
||||
|
||||
## Redaction Patterns
|
||||
|
||||
### Simple Redaction
|
||||
|
||||
```python
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
|
||||
data = {"api_key": "secret123", "temperature": 21.5}
|
||||
redacted = async_redact_data(data, {"api_key"})
|
||||
# Result: {"api_key": "**REDACTED**", "temperature": 21.5}
|
||||
```
|
||||
|
||||
### Nested Redaction
|
||||
|
||||
`async_redact_data` handles nested dictionaries automatically:
|
||||
|
||||
```python
|
||||
data = {
|
||||
"config": {
|
||||
"host": "192.168.1.1",
|
||||
"api_key": "secret123",
|
||||
},
|
||||
"device": {
|
||||
"name": "My Device",
|
||||
"serial_number": "ABC123",
|
||||
}
|
||||
}
|
||||
redacted = async_redact_data(data, {"api_key", "serial_number"})
|
||||
# Result: {"config": {"host": "192.168.1.1", "api_key": "**REDACTED**"},
|
||||
# "device": {"name": "My Device", "serial_number": "**REDACTED**"}}
|
||||
```
|
||||
|
||||
### Custom Redaction
|
||||
|
||||
For complex redaction needs:
|
||||
|
||||
```python
|
||||
def _redact_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Redact sensitive data."""
|
||||
result = dict(data)
|
||||
|
||||
# Redact specific keys
|
||||
for key in ("api_key", "token", "password"):
|
||||
if key in result:
|
||||
result[key] = "**REDACTED**"
|
||||
|
||||
# Redact partial data (e.g., keep last 4 chars)
|
||||
if "serial" in result:
|
||||
result["serial"] = f"****{result['serial'][-4:]}"
|
||||
|
||||
# Redact coordinates to city level
|
||||
if "latitude" in result:
|
||||
result["latitude"] = round(result["latitude"], 1)
|
||||
if "longitude" in result:
|
||||
result["longitude"] = round(result["longitude"], 1)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
## Testing Diagnostics
|
||||
|
||||
```python
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
|
||||
from custom_components.my_integration.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, init_integration)
|
||||
|
||||
assert diagnostics["entry"]["data"]["host"] == "192.168.1.1"
|
||||
assert diagnostics["entry"]["data"]["api_key"] == REDACTED
|
||||
assert "temperature" in diagnostics["coordinator"]["data"]
|
||||
```
|
||||
|
||||
## Quality Scale Requirement
|
||||
|
||||
Diagnostics are required for **Gold** quality scale and above. Ensure your `quality_scale.yaml` includes:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
diagnostics: done
|
||||
```
|
||||
286
.claude/skills/ha-integration/ENTITY.MD
Normal file
286
.claude/skills/ha-integration/ENTITY.MD
Normal file
@@ -0,0 +1,286 @@
|
||||
# Entity Development Reference
|
||||
|
||||
Base patterns for entity development in Home Assistant.
|
||||
|
||||
## Base Entity Class
|
||||
|
||||
Create a shared base class to reduce duplication:
|
||||
|
||||
```python
|
||||
"""Base entity for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MyCoordinator
|
||||
|
||||
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Base entity for My Integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
```
|
||||
|
||||
## Unique IDs
|
||||
|
||||
Every entity must have a unique ID:
|
||||
|
||||
```python
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor entity."""
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator, sensor_type: str) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
# Unique per platform, don't include domain or platform name
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{sensor_type}"
|
||||
```
|
||||
|
||||
**Acceptable unique ID sources:**
|
||||
- Device serial numbers
|
||||
- MAC addresses (use `format_mac` from device registry)
|
||||
- Physical identifiers
|
||||
|
||||
**Never use:**
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
## Entity Naming
|
||||
|
||||
```python
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor with proper naming."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "temperature" # Translatable name
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
# For the main/primary entity of a device, use None
|
||||
# self._attr_name = None
|
||||
|
||||
# For secondary entities, set the name
|
||||
self._attr_name = "Temperature" # Or use translation_key
|
||||
```
|
||||
|
||||
## Entity Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"battery": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"charging": "Charging",
|
||||
"discharging": "Discharging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Availability
|
||||
|
||||
### Coordinator Pattern
|
||||
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._sensor_key in self.coordinator.data.sensors
|
||||
```
|
||||
|
||||
### Direct Update Pattern
|
||||
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity state."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
## Entity Categories
|
||||
|
||||
```python
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
|
||||
class DiagnosticSensor(MyEntity, SensorEntity):
|
||||
"""Diagnostic sensor (hidden by default in UI)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
|
||||
class ConfigSwitch(MyEntity, SwitchEntity):
|
||||
"""Configuration switch."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
```
|
||||
|
||||
## Disabled by Default
|
||||
|
||||
For noisy or less popular entities:
|
||||
|
||||
```python
|
||||
class SignalStrengthSensor(MyEntity, SensorEntity):
|
||||
"""Signal strength sensor - disabled by default."""
|
||||
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
|
||||
## Event Lifecycle
|
||||
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
"""Entity with event subscriptions."""
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events when added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.client.events.subscribe(
|
||||
"state_changed",
|
||||
self._handle_state_change,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_state_change(self, event: Event) -> None:
|
||||
"""Handle state change event."""
|
||||
self._attr_native_value = event.value
|
||||
self.async_write_ha_state()
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- Subscribe in `async_added_to_hass`
|
||||
- Use `async_on_remove` for automatic cleanup
|
||||
- Never subscribe in `__init__`
|
||||
|
||||
## State Handling
|
||||
|
||||
```python
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state."""
|
||||
value = self.coordinator.data.get(self._key)
|
||||
# Use None for unknown values, never "unknown" or "unavailable" strings
|
||||
if value is None:
|
||||
return None
|
||||
return value
|
||||
```
|
||||
|
||||
## Extra State Attributes
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
data = self.coordinator.data
|
||||
# All keys must always be present, use None for unknown
|
||||
return {
|
||||
"last_updated": data.last_updated,
|
||||
"error_count": data.error_count,
|
||||
"firmware": data.firmware or None, # Never omit keys
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Descriptions Pattern
|
||||
|
||||
For multiple similar entities:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe My sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyData], StateType]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[MySensorEntityDescription, ...] = (
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="humidity",
|
||||
translation_key="humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor using entity description."""
|
||||
|
||||
entity_description: MySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Multiline Lambdas
|
||||
|
||||
When lambdas are too long:
|
||||
|
||||
```python
|
||||
# Good pattern - parentheses on same line as lambda
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
value_fn=lambda data: (
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
229
.claude/skills/ha-integration/NUMBER.MD
Normal file
229
.claude/skills/ha-integration/NUMBER.MD
Normal file
@@ -0,0 +1,229 @@
|
||||
# Number Platform Reference
|
||||
|
||||
Number entities represent numeric values that can be set.
|
||||
|
||||
## Basic Number
|
||||
|
||||
```python
|
||||
"""Number platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up numbers from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
TargetTemperatureNumber(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class TargetTemperatureNumber(MyEntity, NumberEntity):
|
||||
"""Target temperature number entity."""
|
||||
|
||||
_attr_native_min_value = 16
|
||||
_attr_native_max_value = 30
|
||||
_attr_native_step = 0.5
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_translation_key = "target_temperature"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the number entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_target_temp"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.coordinator.data.target_temperature
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the target temperature."""
|
||||
await self.coordinator.client.set_target_temperature(value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Number Modes
|
||||
|
||||
```python
|
||||
from homeassistant.components.number import NumberMode
|
||||
|
||||
# Slider display in UI
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
# Input box display in UI
|
||||
_attr_mode = NumberMode.BOX
|
||||
|
||||
# Auto (slider if range <= 256, else box)
|
||||
_attr_mode = NumberMode.AUTO
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
```python
|
||||
from homeassistant.components.number import NumberDeviceClass
|
||||
|
||||
# For temperature settings
|
||||
_attr_device_class = NumberDeviceClass.TEMPERATURE
|
||||
|
||||
# Other device classes
|
||||
NumberDeviceClass.HUMIDITY
|
||||
NumberDeviceClass.POWER
|
||||
NumberDeviceClass.VOLTAGE
|
||||
NumberDeviceClass.CURRENT
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntityDescription, NumberMode
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MyNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describe My number entity."""
|
||||
|
||||
value_fn: Callable[[MyData], float | None]
|
||||
set_value_fn: Callable[[MyClient, float], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
NUMBERS: tuple[MyNumberEntityDescription, ...] = (
|
||||
MyNumberEntityDescription(
|
||||
key="target_temperature",
|
||||
translation_key="target_temperature",
|
||||
native_min_value=16,
|
||||
native_max_value=30,
|
||||
native_step=0.5,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
mode=NumberMode.SLIDER,
|
||||
value_fn=lambda data: data.target_temperature,
|
||||
set_value_fn=lambda client, value: client.set_target_temperature(value),
|
||||
),
|
||||
MyNumberEntityDescription(
|
||||
key="brightness",
|
||||
translation_key="brightness",
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda data: data.brightness,
|
||||
set_value_fn=lambda client, value: client.set_brightness(int(value)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MyNumber(MyEntity, NumberEntity):
|
||||
"""Number using entity description."""
|
||||
|
||||
entity_description: MyNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MyNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
await self.entity_description.set_value_fn(self.coordinator.client, value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Dynamic Min/Max Values
|
||||
|
||||
```python
|
||||
class DynamicRangeNumber(MyEntity, NumberEntity):
|
||||
"""Number with dynamic range based on device capabilities."""
|
||||
|
||||
_attr_translation_key = "fan_speed"
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return minimum value."""
|
||||
return self.coordinator.data.fan_speed_min
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return maximum value."""
|
||||
return self.coordinator.data.fan_speed_max
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
"""Return step value."""
|
||||
return self.coordinator.data.fan_speed_step or 1
|
||||
```
|
||||
|
||||
## Configuration Number
|
||||
|
||||
```python
|
||||
class ConfigNumber(MyEntity, NumberEntity):
|
||||
"""Configuration number entity."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_native_min_value = 1
|
||||
_attr_native_max_value = 60
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "min"
|
||||
_attr_translation_key = "timeout"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the timeout setting."""
|
||||
return self.coordinator.data.timeout_minutes
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the timeout."""
|
||||
await self.coordinator.client.set_timeout(int(value))
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"number": {
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
},
|
||||
"timeout": {
|
||||
"name": "Timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
252
.claude/skills/ha-integration/SELECT.MD
Normal file
252
.claude/skills/ha-integration/SELECT.MD
Normal file
@@ -0,0 +1,252 @@
|
||||
# Select Platform Reference
|
||||
|
||||
Select entities allow choosing from a predefined list of options.
|
||||
|
||||
## Basic Select
|
||||
|
||||
```python
|
||||
"""Select platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up selects from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
ModeSelect(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class ModeSelect(MyEntity, SelectEntity):
|
||||
"""Mode select entity."""
|
||||
|
||||
_attr_options = ["auto", "cool", "heat", "fan_only", "dry"]
|
||||
_attr_translation_key = "mode"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self.coordinator.data.mode
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.coordinator.client.set_mode(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySelectEntityDescription(SelectEntityDescription):
|
||||
"""Describe My select entity."""
|
||||
|
||||
current_option_fn: Callable[[MyData], str | None]
|
||||
select_option_fn: Callable[[MyClient, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SELECTS: tuple[MySelectEntityDescription, ...] = (
|
||||
MySelectEntityDescription(
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
options=["auto", "cool", "heat", "fan_only", "dry"],
|
||||
current_option_fn=lambda data: data.mode,
|
||||
select_option_fn=lambda client, option: client.set_mode(option),
|
||||
),
|
||||
MySelectEntityDescription(
|
||||
key="fan_speed",
|
||||
translation_key="fan_speed",
|
||||
options=["low", "medium", "high", "auto"],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
current_option_fn=lambda data: data.fan_speed,
|
||||
select_option_fn=lambda client, option: client.set_fan_speed(option),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySelect(MyEntity, SelectEntity):
|
||||
"""Select using entity description."""
|
||||
|
||||
entity_description: MySelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available options."""
|
||||
return list(self.entity_description.options)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current option."""
|
||||
return self.entity_description.current_option_fn(self.coordinator.data)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select an option."""
|
||||
await self.entity_description.select_option_fn(self.coordinator.client, option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Dynamic Options
|
||||
|
||||
```python
|
||||
class DynamicSelect(MyEntity, SelectEntity):
|
||||
"""Select with options from device."""
|
||||
|
||||
_attr_translation_key = "preset"
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available presets from device."""
|
||||
return self.coordinator.data.available_presets
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current preset."""
|
||||
return self.coordinator.data.current_preset
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select a preset."""
|
||||
await self.coordinator.client.set_preset(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Configuration Select
|
||||
|
||||
```python
|
||||
class ConfigSelect(MyEntity, SelectEntity):
|
||||
"""Configuration select (settings)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_options = ["silent", "normal", "boost"]
|
||||
_attr_translation_key = "performance_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return current performance mode."""
|
||||
return self.coordinator.data.performance_mode
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set performance mode."""
|
||||
await self.coordinator.client.set_performance_mode(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"heat": "Heat",
|
||||
"fan_only": "Fan only",
|
||||
"dry": "Dry"
|
||||
}
|
||||
},
|
||||
"fan_speed": {
|
||||
"name": "Fan speed",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"auto": "Auto"
|
||||
}
|
||||
},
|
||||
"performance_mode": {
|
||||
"name": "Performance mode",
|
||||
"state": {
|
||||
"silent": "Silent",
|
||||
"normal": "Normal",
|
||||
"boost": "Boost"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Icon by State
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"default": "mdi:thermostat",
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"heat": "Heat"
|
||||
},
|
||||
"state_icons": {
|
||||
"auto": "mdi:thermostat-auto",
|
||||
"cool": "mdi:snowflake",
|
||||
"heat": "mdi:fire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: State icons are defined in `icons.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"mode": {
|
||||
"default": "mdi:thermostat",
|
||||
"state": {
|
||||
"auto": "mdi:thermostat-auto",
|
||||
"cool": "mdi:snowflake",
|
||||
"heat": "mdi:fire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
271
.claude/skills/ha-integration/SENSOR.MD
Normal file
271
.claude/skills/ha-integration/SENSOR.MD
Normal file
@@ -0,0 +1,271 @@
|
||||
# Sensor Platform Reference
|
||||
|
||||
Sensors represent read-only values from devices.
|
||||
|
||||
## Basic Sensor
|
||||
|
||||
```python
|
||||
"""Sensor platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
TemperatureSensor(coordinator),
|
||||
HumiditySensor(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class TemperatureSensor(MyEntity, SensorEntity):
|
||||
"""Temperature sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_translation_key = "temperature"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_temperature"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data.temperature
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
Common sensor device classes:
|
||||
|
||||
| Device Class | Unit Examples | Use Case |
|
||||
|--------------|---------------|----------|
|
||||
| `TEMPERATURE` | °C, °F | Temperature readings |
|
||||
| `HUMIDITY` | % | Humidity levels |
|
||||
| `PRESSURE` | hPa, mbar | Atmospheric pressure |
|
||||
| `BATTERY` | % | Battery level |
|
||||
| `POWER` | W, kW | Power consumption |
|
||||
| `ENERGY` | Wh, kWh | Energy usage |
|
||||
| `VOLTAGE` | V | Electrical voltage |
|
||||
| `CURRENT` | A, mA | Electrical current |
|
||||
| `CO2` | ppm | Carbon dioxide |
|
||||
| `PM25` | µg/m³ | Particulate matter |
|
||||
|
||||
## State Classes
|
||||
|
||||
```python
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
|
||||
# For instantaneous values that can go up or down
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
# For ever-increasing totals (like energy consumption)
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
# For totals that reset periodically
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
```
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
For multiple sensors with similar structure:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe My sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyData], Any]
|
||||
|
||||
|
||||
SENSORS: tuple[MySensorEntityDescription, ...] = (
|
||||
MySensorEntityDescription(
|
||||
key="temperature",
|
||||
translation_key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="humidity",
|
||||
translation_key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
MySensorEntityDescription(
|
||||
key="signal_strength",
|
||||
translation_key="signal_strength",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False, # Disabled by default
|
||||
value_fn=lambda data: data.rssi,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
MySensor(coordinator, description)
|
||||
for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class MySensor(MyEntity, SensorEntity):
|
||||
"""Sensor using entity description."""
|
||||
|
||||
entity_description: MySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
```
|
||||
|
||||
## Suggested Display Precision
|
||||
|
||||
```python
|
||||
# Control decimal places shown in UI
|
||||
_attr_suggested_display_precision = 1 # Show 21.5 instead of 21.456789
|
||||
```
|
||||
|
||||
## Timestamp Sensors
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
|
||||
|
||||
class LastUpdatedSensor(MyEntity, SensorEntity):
|
||||
"""Last updated timestamp sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_translation_key = "last_updated"
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""Return the last update timestamp."""
|
||||
return self.coordinator.data.last_updated
|
||||
```
|
||||
|
||||
## Enum Sensors
|
||||
|
||||
```python
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
|
||||
|
||||
class StatusSensor(MyEntity, SensorEntity):
|
||||
"""Status sensor with enum values."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = ["idle", "running", "error", "offline"]
|
||||
_attr_translation_key = "status"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current status."""
|
||||
return self.coordinator.data.status
|
||||
```
|
||||
|
||||
With translations in `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"error": "Error",
|
||||
"offline": "Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Icons
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"name": "Battery level",
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"10": "mdi:battery-10",
|
||||
"50": "mdi:battery-50",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PARALLEL_UPDATES
|
||||
|
||||
```python
|
||||
# At module level - limit concurrent updates
|
||||
PARALLEL_UPDATES = 1 # Serialize to prevent overwhelming device
|
||||
|
||||
# Or unlimited for coordinator-based platforms
|
||||
PARALLEL_UPDATES = 0
|
||||
```
|
||||
335
.claude/skills/ha-integration/SERVICES.MD
Normal file
335
.claude/skills/ha-integration/SERVICES.MD
Normal file
@@ -0,0 +1,335 @@
|
||||
# Services Reference
|
||||
|
||||
Services allow automations and users to trigger actions.
|
||||
|
||||
## Service Registration
|
||||
|
||||
Register services in `async_setup`, NOT in `async_setup_entry`:
|
||||
|
||||
```python
|
||||
"""My Integration setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN
|
||||
|
||||
SERVICE_REFRESH = "refresh"
|
||||
SERVICE_SET_SCHEDULE = "set_schedule"
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up My Integration services."""
|
||||
|
||||
async def handle_refresh(call: ServiceCall) -> None:
|
||||
"""Handle refresh service call."""
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH,
|
||||
handle_refresh,
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
}),
|
||||
)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Service with Response
|
||||
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up services with response."""
|
||||
|
||||
async def handle_get_schedule(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle get_schedule service call."""
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
schedule = await coordinator.client.get_schedule()
|
||||
|
||||
return {
|
||||
"schedule": [
|
||||
{"day": item.day, "start": item.start, "end": item.end}
|
||||
for item in schedule
|
||||
]
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_schedule",
|
||||
handle_get_schedule,
|
||||
schema=vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
}),
|
||||
supports_response=SupportsResponse.ONLY, # or SupportsResponse.OPTIONAL
|
||||
)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
## Entity Services
|
||||
|
||||
Register entity-specific services in platform setup:
|
||||
|
||||
```python
|
||||
"""Switch platform with entity service."""
|
||||
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([PowerSwitch(coordinator)])
|
||||
|
||||
# Register entity service
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
"set_timer",
|
||||
{
|
||||
vol.Required("minutes"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=120)
|
||||
),
|
||||
},
|
||||
"async_set_timer",
|
||||
)
|
||||
|
||||
|
||||
class PowerSwitch(MyEntity, SwitchEntity):
|
||||
"""Power switch with timer service."""
|
||||
|
||||
async def async_set_timer(self, minutes: int) -> None:
|
||||
"""Set auto-off timer."""
|
||||
await self.coordinator.client.set_timer(minutes)
|
||||
```
|
||||
|
||||
## Service Validation
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
|
||||
async def handle_set_schedule(call: ServiceCall) -> None:
|
||||
"""Handle set_schedule service call."""
|
||||
start_date = call.data["start_date"]
|
||||
end_date = call.data["end_date"]
|
||||
|
||||
# Validate input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
|
||||
try:
|
||||
await entry.runtime_data.client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
```
|
||||
|
||||
## services.yaml
|
||||
|
||||
Define services in `services.yaml`:
|
||||
|
||||
```yaml
|
||||
refresh:
|
||||
name: Refresh
|
||||
description: Force a data refresh from the device.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to refresh.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
|
||||
set_schedule:
|
||||
name: Set schedule
|
||||
description: Set the device schedule.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to configure.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Schedule start date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Schedule end date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
|
||||
get_schedule:
|
||||
name: Get schedule
|
||||
description: Get the current device schedule.
|
||||
fields:
|
||||
config_entry_id:
|
||||
name: Config entry ID
|
||||
description: The config entry to query.
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: my_integration
|
||||
|
||||
set_timer:
|
||||
name: Set timer
|
||||
description: Set auto-off timer for the switch.
|
||||
target:
|
||||
entity:
|
||||
integration: my_integration
|
||||
domain: switch
|
||||
fields:
|
||||
minutes:
|
||||
name: Minutes
|
||||
description: Timer duration in minutes.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 120
|
||||
unit_of_measurement: min
|
||||
```
|
||||
|
||||
## Exception Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"entry_not_found": {
|
||||
"message": "Config entry not found."
|
||||
},
|
||||
"entry_not_loaded": {
|
||||
"message": "Config entry is not loaded."
|
||||
},
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to connect to the device."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Device-Based Service Targeting
|
||||
|
||||
```python
|
||||
async def handle_device_service(call: ServiceCall) -> None:
|
||||
"""Handle service call targeting a device."""
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
|
||||
# Find config entry for device
|
||||
entry_id = next(
|
||||
(entry_id for entry_id in device.config_entries if entry_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if entry_id is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
# ... continue with service logic
|
||||
```
|
||||
|
||||
## Service Schema Patterns
|
||||
|
||||
```python
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
# Basic schema
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required("value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
|
||||
# With entity targeting
|
||||
SERVICE_SCHEMA_ENTITY = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
})
|
||||
|
||||
# With selectors (for services.yaml)
|
||||
# Use selector in services.yaml, not in Python schema
|
||||
```
|
||||
|
||||
## Quality Scale Requirements
|
||||
|
||||
- **Bronze**: `action-setup` - Register services in `async_setup` if integration has services
|
||||
- Services must validate config entry state before use
|
||||
- Use translated exceptions for error messages
|
||||
165
.claude/skills/ha-integration/SKILL.md
Normal file
165
.claude/skills/ha-integration/SKILL.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: ha-integration
|
||||
description: Develop Home Assistant integrations following best practices. Use when creating, modifying, or reviewing integration code including config flows, entities, coordinators, diagnostics, services, and tests.
|
||||
---
|
||||
|
||||
# Home Assistant Integration Development
|
||||
|
||||
You are developing a Home Assistant integration. Follow these guidelines and reference the supporting documentation for specific components.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Reference File |
|
||||
|-----------|----------------|
|
||||
| Config flow | [CONFIG-FLOW.MD](CONFIG-FLOW.MD) |
|
||||
| Data coordinator | [COORDINATOR.MD](COORDINATOR.MD) |
|
||||
| Entities (base) | [ENTITY.MD](ENTITY.MD) |
|
||||
| Sensors | [SENSOR.MD](SENSOR.MD) |
|
||||
| Binary sensors | [BINARY-SENSOR.MD](BINARY-SENSOR.MD) |
|
||||
| Switches | [SWITCH.MD](SWITCH.MD) |
|
||||
| Numbers | [NUMBER.MD](NUMBER.MD) |
|
||||
| Selects | [SELECT.MD](SELECT.MD) |
|
||||
| Buttons | [BUTTON.MD](BUTTON.MD) |
|
||||
| Device management | [DEVICE.MD](DEVICE.MD) |
|
||||
| Diagnostics | [DIAGNOSTICS.MD](DIAGNOSTICS.MD) |
|
||||
| Services | [SERVICES.MD](SERVICES.MD) |
|
||||
| Testing | [TESTING.MD](TESTING.MD) |
|
||||
|
||||
## Integration Structure
|
||||
|
||||
```
|
||||
homeassistant/components/my_integration/
|
||||
├── __init__.py # Entry point with async_setup_entry
|
||||
├── manifest.json # Integration metadata and dependencies
|
||||
├── const.py # Domain and constants
|
||||
├── config_flow.py # UI configuration flow
|
||||
├── coordinator.py # Data update coordinator
|
||||
├── entity.py # Base entity class
|
||||
├── sensor.py # Sensor platform
|
||||
├── diagnostics.py # Diagnostic data collection
|
||||
├── strings.json # User-facing text and translations
|
||||
├── services.yaml # Service definitions (if applicable)
|
||||
└── quality_scale.yaml # Quality scale rule status
|
||||
```
|
||||
|
||||
## Quality Scale Levels
|
||||
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality (entity unavailability, parallel updates, auth flows)
|
||||
- **Gold**: Advanced features (device management, diagnostics, translations)
|
||||
- **Platinum**: Highest quality (strict typing, async dependencies, websession injection)
|
||||
|
||||
Check `manifest.json` for `"quality_scale"` key and `quality_scale.yaml` for rule status.
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Entry Point (`__init__.py`)
|
||||
|
||||
```python
|
||||
"""Integration for My Device."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MyCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Set up My Integration from a config entry."""
|
||||
coordinator = MyCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
```
|
||||
|
||||
### Constants (`const.py`)
|
||||
|
||||
```python
|
||||
"""Constants for My Integration."""
|
||||
|
||||
DOMAIN = "my_integration"
|
||||
```
|
||||
|
||||
### Manifest (`manifest.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@username"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/my_integration",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["my-library==1.0.0"],
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
```
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
- **Type hints**: Required for all functions and methods
|
||||
- **f-strings**: Preferred over `%` or `.format()`
|
||||
- **Async**: All external I/O must be async
|
||||
|
||||
## Code Quality
|
||||
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Testing**: pytest with >95% coverage
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
```python
|
||||
# Blocking operations
|
||||
data = requests.get(url) # Use async or executor
|
||||
time.sleep(5) # Use asyncio.sleep()
|
||||
|
||||
# Hardcoded strings
|
||||
self._attr_name = "Temperature" # Use translation_key
|
||||
|
||||
# Too much in try block
|
||||
try:
|
||||
data = await client.get_data()
|
||||
processed = data["value"] * 100 # Move outside try
|
||||
except Error:
|
||||
pass
|
||||
|
||||
# User-configurable polling
|
||||
vol.Optional("scan_interval"): cv.positive_int # Not allowed
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
pytest ./tests/components/<domain> \
|
||||
--cov=homeassistant.components.<domain> \
|
||||
--cov-report term-missing \
|
||||
--numprocesses=auto
|
||||
|
||||
# Type checking
|
||||
mypy homeassistant/components/<domain>
|
||||
|
||||
# Linting
|
||||
pylint homeassistant/components/<domain>
|
||||
|
||||
# Validate integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/<domain>
|
||||
```
|
||||
236
.claude/skills/ha-integration/SWITCH.MD
Normal file
236
.claude/skills/ha-integration/SWITCH.MD
Normal file
@@ -0,0 +1,236 @@
|
||||
# Switch Platform Reference
|
||||
|
||||
Switches control on/off functionality.
|
||||
|
||||
## Basic Switch
|
||||
|
||||
```python
|
||||
"""Switch platform for My Integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyIntegrationConfigEntry
|
||||
from .entity import MyEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MyIntegrationConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches from config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([
|
||||
PowerSwitch(coordinator),
|
||||
])
|
||||
|
||||
|
||||
class PowerSwitch(MyEntity, SwitchEntity):
|
||||
"""Power switch."""
|
||||
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
_attr_translation_key = "power"
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_power"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on."""
|
||||
return self.coordinator.data.is_on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.coordinator.client.turn_on()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.coordinator.client.turn_off()
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Device Classes
|
||||
|
||||
| Device Class | Use Case |
|
||||
|--------------|----------|
|
||||
| `OUTLET` | Electrical outlet |
|
||||
| `SWITCH` | Generic switch |
|
||||
|
||||
## Entity Description Pattern
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MySwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describe My switch entity."""
|
||||
|
||||
is_on_fn: Callable[[MyData], bool | None]
|
||||
turn_on_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SWITCHES: tuple[MySwitchEntityDescription, ...] = (
|
||||
MySwitchEntityDescription(
|
||||
key="power",
|
||||
translation_key="power",
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
is_on_fn=lambda data: data.is_on,
|
||||
turn_on_fn=lambda client: client.turn_on(),
|
||||
turn_off_fn=lambda client: client.turn_off(),
|
||||
),
|
||||
MySwitchEntityDescription(
|
||||
key="child_lock",
|
||||
translation_key="child_lock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda data: data.child_lock_enabled,
|
||||
turn_on_fn=lambda client: client.set_child_lock(True),
|
||||
turn_off_fn=lambda client: client.set_child_lock(False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySwitch(MyEntity, SwitchEntity):
|
||||
"""Switch using entity description."""
|
||||
|
||||
entity_description: MySwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MyCoordinator,
|
||||
description: MySwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.entity_description.turn_on_fn(self.coordinator.client)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.entity_description.turn_off_fn(self.coordinator.client)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Configuration Switch
|
||||
|
||||
```python
|
||||
class ConfigSwitch(MyEntity, SwitchEntity):
|
||||
"""Configuration switch (e.g., enable/disable a feature)."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "auto_mode"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if auto mode is enabled."""
|
||||
return self.coordinator.data.auto_mode_enabled
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable auto mode."""
|
||||
await self.coordinator.client.set_auto_mode(True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable auto mode."""
|
||||
await self.coordinator.client.set_auto_mode(False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For devices with slow response:
|
||||
|
||||
```python
|
||||
class OptimisticSwitch(MyEntity, SwitchEntity):
|
||||
"""Switch with optimistic state updates."""
|
||||
|
||||
_attr_assumed_state = True # Indicates state may not be accurate
|
||||
|
||||
def __init__(self, coordinator: MyCoordinator) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._optimistic_state: bool | None = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return optimistic state if set, otherwise coordinator state."""
|
||||
if self._optimistic_state is not None:
|
||||
return self._optimistic_state
|
||||
return self.coordinator.data.is_on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on with optimistic update."""
|
||||
self._optimistic_state = True
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.coordinator.client.turn_on()
|
||||
finally:
|
||||
self._optimistic_state = None
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class RobustSwitch(MyEntity, SwitchEntity):
|
||||
"""Switch with proper error handling."""
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.coordinator.client.turn_on()
|
||||
except MyDeviceError as err:
|
||||
raise HomeAssistantError(f"Failed to turn on: {err}") from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
```
|
||||
|
||||
## Translations
|
||||
|
||||
In `strings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"power": {
|
||||
"name": "Power"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
},
|
||||
"auto_mode": {
|
||||
"name": "Auto mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
396
.claude/skills/ha-integration/TESTING.MD
Normal file
396
.claude/skills/ha-integration/TESTING.MD
Normal file
@@ -0,0 +1,396 @@
|
||||
# Testing Reference
|
||||
|
||||
Testing patterns for Home Assistant integrations.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/components/my_integration/
|
||||
├── __init__.py
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_config_flow.py # Config flow tests (100% coverage required)
|
||||
├── test_init.py # Integration setup tests
|
||||
├── test_sensor.py # Sensor platform tests
|
||||
├── test_diagnostics.py # Diagnostics tests
|
||||
├── snapshots/ # Snapshot files
|
||||
│ └── test_sensor.ambr
|
||||
└── fixtures/ # Test data fixtures
|
||||
└── device_data.json
|
||||
```
|
||||
|
||||
## conftest.py
|
||||
|
||||
```python
|
||||
"""Fixtures for My Integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.my_integration.const import DOMAIN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="My Device",
|
||||
data={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_api_key",
|
||||
},
|
||||
unique_id="device_serial_123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Generator[MagicMock]:
|
||||
"""Return a mocked client."""
|
||||
with patch(
|
||||
"homeassistant.components.my_integration.MyClient",
|
||||
autospec=True,
|
||||
) as client_mock:
|
||||
client = client_mock.return_value
|
||||
client.get_data = AsyncMock(
|
||||
return_value=MyData.from_json(load_fixture("device_data.json", DOMAIN))
|
||||
)
|
||||
client.serial_number = "device_serial_123"
|
||||
client.name = "My Device"
|
||||
client.model = "Model X"
|
||||
client.firmware_version = "1.2.3"
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Return platforms to test."""
|
||||
return [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.my_integration.PLATFORMS",
|
||||
platforms,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Config Flow Tests
|
||||
|
||||
**100% coverage required for all paths:**
|
||||
|
||||
```python
|
||||
"""Test config flow for My Integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.my_integration.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
}
|
||||
assert result["result"].unique_id == "device_serial_123"
|
||||
|
||||
|
||||
async def test_user_flow_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test connection error in user flow."""
|
||||
mock_client.get_data.side_effect = ConnectionError("Cannot connect")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test already configured error."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "192.168.1.100",
|
||||
CONF_API_KEY: "test_key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test reauthentication flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_API_KEY: "new_api_key"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
|
||||
```
|
||||
|
||||
## Entity Tests with Snapshots
|
||||
|
||||
```python
|
||||
"""Test sensor platform."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Override platforms for sensor tests."""
|
||||
return [Platform.SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_sensor_device_assignment(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensors are assigned to correct device."""
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={("my_integration", "device_serial_123")}
|
||||
)
|
||||
assert device is not None
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity in entities:
|
||||
assert entity.device_id == device.id
|
||||
```
|
||||
|
||||
## Coordinator Tests
|
||||
|
||||
```python
|
||||
"""Test coordinator."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
|
||||
async def test_coordinator_update_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful coordinator update."""
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert coordinator.data.temperature == 21.5
|
||||
assert coordinator.last_update_success
|
||||
|
||||
|
||||
async def test_coordinator_update_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles API error."""
|
||||
mock_client.get_data.side_effect = MyError("Connection failed")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(UpdateFailed):
|
||||
await coordinator._async_update_data()
|
||||
|
||||
|
||||
async def test_coordinator_auth_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles auth error."""
|
||||
mock_client.get_data.side_effect = AuthError("Invalid token")
|
||||
|
||||
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
|
||||
|
||||
with pytest.raises(ConfigEntryAuthFailed):
|
||||
await coordinator._async_update_data()
|
||||
```
|
||||
|
||||
## Diagnostics Tests
|
||||
|
||||
```python
|
||||
"""Test diagnostics."""
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.my_integration import snapshot_platform
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
init_integration: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, init_integration
|
||||
) == snapshot
|
||||
```
|
||||
|
||||
## Common Fixtures
|
||||
|
||||
```python
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
# Load JSON fixture
|
||||
data = load_fixture("device_data.json", DOMAIN)
|
||||
|
||||
# Enable all entities (including disabled by default)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
|
||||
# Freeze time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
|
||||
async def test_with_frozen_time(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
await hass.async_block_till_done()
|
||||
```
|
||||
|
||||
## Update Snapshots
|
||||
|
||||
```bash
|
||||
# Update snapshots
|
||||
pytest tests/components/my_integration --snapshot-update
|
||||
|
||||
# Always re-run without flag to verify
|
||||
pytest tests/components/my_integration
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
pytest tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing \
|
||||
--numprocesses=auto
|
||||
|
||||
# Run specific test
|
||||
pytest tests/components/my_integration/test_config_flow.py::test_user_flow_success
|
||||
|
||||
# Quick test of changed files
|
||||
pytest --timeout=10 --picked
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never access `hass.data` directly** - Use fixtures and proper setup
|
||||
2. **Mock all external APIs** - Use fixtures with realistic JSON data
|
||||
3. **Use snapshot testing** - For entity states and attributes
|
||||
4. **Test error paths** - Connection errors, auth failures, invalid data
|
||||
5. **Test edge cases** - Empty data, missing fields, None values
|
||||
6. **>95% coverage required** - All code paths must be tested
|
||||
@@ -91,6 +91,7 @@ components: &components
|
||||
- homeassistant/components/input_number/**
|
||||
- homeassistant/components/input_select/**
|
||||
- homeassistant/components/input_text/**
|
||||
- homeassistant/components/labs/**
|
||||
- homeassistant/components/logbook/**
|
||||
- homeassistant/components/logger/**
|
||||
- homeassistant/components/lovelace/**
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
|
||||
21
.github/copilot-instructions.md
vendored
21
.github/copilot-instructions.md
vendored
@@ -51,6 +51,9 @@ rules:
|
||||
- **Missing imports** - We use static analysis tooling to catch that
|
||||
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
|
||||
## Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
@@ -844,8 +847,8 @@ rules:
|
||||
## Development Commands
|
||||
|
||||
### Code Quality & Linting
|
||||
- **Run all linters on all files**: `pre-commit run --all-files`
|
||||
- **Run linters on staged files only**: `pre-commit run`
|
||||
- **Run all linters on all files**: `prek run --all-files`
|
||||
- **Run linters on staged files only**: `prek run`
|
||||
- **PyLint on everything** (slow): `pylint homeassistant`
|
||||
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
|
||||
- **MyPy type checking (whole project)**: `mypy homeassistant/`
|
||||
@@ -1021,18 +1024,6 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Performance Optimization
|
||||
```python
|
||||
# Use __slots__ for memory efficiency
|
||||
class MySensor(SensorEntity):
|
||||
__slots__ = ("_attr_native_value", "_attr_available")
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Disable polling when using coordinator."""
|
||||
return False # ✅ Let coordinator handle updates
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Testing Best Practices
|
||||
@@ -1178,4 +1169,4 @@ python -m script.hassfest --integration-path homeassistant/components/my_integra
|
||||
pytest ./tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing
|
||||
```
|
||||
```
|
||||
|
||||
18
.github/workflows/builder.yml
vendored
18
.github/workflows/builder.yml
vendored
@@ -15,7 +15,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2025.11.3"
|
||||
BASE_IMAGE_VERSION: "2025.12.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
jobs:
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -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
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -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'
|
||||
@@ -482,7 +482,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -551,7 +551,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
202
.github/workflows/ci.yaml
vendored
202
.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
|
||||
@@ -59,7 +59,6 @@ env:
|
||||
# 15 is the latest version
|
||||
# - 15.2 is the latest (as of 9 Feb 2023)
|
||||
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
|
||||
PRE_COMMIT_CACHE: ~/.cache/pre-commit
|
||||
UV_CACHE_DIR: /tmp/uv-cache
|
||||
APT_CACHE_BASE: /home/runner/work/apt
|
||||
APT_CACHE_DIR: /home/runner/work/apt/cache
|
||||
@@ -83,7 +82,6 @@ jobs:
|
||||
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
|
||||
integrations: ${{ steps.integrations.outputs.changes }}
|
||||
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
|
||||
pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
|
||||
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
|
||||
requirements: ${{ steps.core.outputs.requirements }}
|
||||
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
|
||||
@@ -111,11 +109,6 @@ jobs:
|
||||
hashFiles('requirements_all.txt') }}-${{
|
||||
hashFiles('homeassistant/package_constraints.txt') }}-${{
|
||||
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial pre-commit restore key
|
||||
id: generate_pre-commit_cache_key
|
||||
run: >-
|
||||
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
|
||||
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Generate partial apt restore key
|
||||
id: generate_apt_cache_key
|
||||
run: |
|
||||
@@ -244,8 +237,8 @@ jobs:
|
||||
echo "skip_coverage: ${skip_coverage}"
|
||||
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
|
||||
|
||||
pre-commit:
|
||||
name: Prepare pre-commit base
|
||||
prek:
|
||||
name: Run prek checks
|
||||
runs-on: *runs-on-ubuntu
|
||||
needs: [info]
|
||||
if: |
|
||||
@@ -254,147 +247,17 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- *checkout
|
||||
- &setup-python-default
|
||||
name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
path: venv
|
||||
key: &key-pre-commit-venv >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: *actions-cache
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
key: &key-pre-commit-env >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Install pre-commit dependencies
|
||||
if: steps.cache-precommit.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit install-hooks
|
||||
|
||||
lint-ruff-format:
|
||||
name: Check ruff-format
|
||||
runs-on: *runs-on-ubuntu
|
||||
needs: &needs-pre-commit
|
||||
- info
|
||||
- pre-commit
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python-default
|
||||
- &cache-restore-pre-commit-venv
|
||||
name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: &actions-cache-restore actions/cache/restore@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: *key-pre-commit-venv
|
||||
- &cache-restore-pre-commit-env
|
||||
name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: *actions-cache-restore
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: *key-pre-commit-env
|
||||
- name: Run ruff-format
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
|
||||
env:
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
lint-ruff:
|
||||
name: Check ruff
|
||||
runs-on: *runs-on-ubuntu
|
||||
needs: *needs-pre-commit
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python-default
|
||||
- *cache-restore-pre-commit-venv
|
||||
- *cache-restore-pre-commit-env
|
||||
- name: Run ruff
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
|
||||
env:
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
lint-other:
|
||||
name: Check other linters
|
||||
runs-on: *runs-on-ubuntu
|
||||
needs: *needs-pre-commit
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python-default
|
||||
- *cache-restore-pre-commit-venv
|
||||
- *cache-restore-pre-commit-env
|
||||
|
||||
- name: Register yamllint problem matcher
|
||||
- name: Register problem matchers
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
|
||||
- name: Run yamllint
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
|
||||
|
||||
- name: Register check-json problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/check-json.json"
|
||||
- name: Run check-json
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
|
||||
|
||||
- name: Run prettier (fully)
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
|
||||
|
||||
- name: Run prettier (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
shopt -s globstar
|
||||
pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
|
||||
|
||||
- name: Register check executables problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
- name: Run executables check
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
|
||||
|
||||
- name: Register codespell problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run codespell
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
lint-hadolint:
|
||||
name: Check ${{ matrix.file }}
|
||||
@@ -434,7 +297,7 @@ jobs:
|
||||
- &setup-python-matrix
|
||||
name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: *actions-setup-python
|
||||
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
@@ -447,7 +310,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: *actions-cache
|
||||
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: venv
|
||||
key: &key-python-venv >-
|
||||
@@ -511,7 +374,7 @@ jobs:
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: &actions-cache-save actions/cache/save@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
key: *key-apt-cache
|
||||
@@ -534,7 +397,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -562,7 +425,7 @@ jobs:
|
||||
steps:
|
||||
- &cache-restore-apt
|
||||
name: Restore apt cache
|
||||
uses: *actions-cache-restore
|
||||
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
fail-on-cache-miss: true
|
||||
@@ -579,7 +442,13 @@ jobs:
|
||||
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
|
||||
libturbojpeg
|
||||
- *checkout
|
||||
- *setup-python-default
|
||||
- &setup-python-default
|
||||
name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: *actions-setup-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
- &cache-restore-python-default
|
||||
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
|
||||
id: cache-venv
|
||||
@@ -782,9 +651,7 @@ jobs:
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- lint-other
|
||||
- lint-ruff
|
||||
- lint-ruff-format
|
||||
- prek
|
||||
- mypy
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
@@ -823,9 +690,7 @@ jobs:
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- lint-other
|
||||
- lint-ruff
|
||||
- lint-ruff-format
|
||||
- prek
|
||||
- mypy
|
||||
- prepare-pytest-full
|
||||
if: |
|
||||
@@ -864,7 +729,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- &compile-english-translations
|
||||
@@ -949,9 +814,7 @@ jobs:
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- lint-other
|
||||
- lint-ruff
|
||||
- lint-ruff-format
|
||||
- prek
|
||||
- mypy
|
||||
if: |
|
||||
needs.info.outputs.lint_only != 'true'
|
||||
@@ -1066,9 +929,7 @@ jobs:
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- lint-other
|
||||
- lint-ruff
|
||||
- lint-ruff-format
|
||||
- prek
|
||||
- mypy
|
||||
if: |
|
||||
needs.info.outputs.lint_only != 'true'
|
||||
@@ -1202,9 +1063,7 @@ jobs:
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- lint-other
|
||||
- lint-ruff
|
||||
- lint-ruff-format
|
||||
- prek
|
||||
- mypy
|
||||
if: |
|
||||
needs.info.outputs.lint_only != 'true'
|
||||
@@ -1328,6 +1187,8 @@ jobs:
|
||||
- pytest-postgres
|
||||
- pytest-mariadb
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write
|
||||
# codecov/test-results-action currently doesn't support tokenless uploads
|
||||
# therefore we can't run it on forks
|
||||
if: |
|
||||
@@ -1339,8 +1200,9 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"owner": "check-executables-have-shebangs",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+):\\s(.+)$",
|
||||
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
|
||||
"file": 1,
|
||||
"message": 2
|
||||
}
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
) > .env_file
|
||||
|
||||
- name: Upload env_file
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -92,6 +92,7 @@ pip-selfcheck.json
|
||||
venv
|
||||
.venv
|
||||
Pipfile*
|
||||
uv.lock
|
||||
share/*
|
||||
/Scripts/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.0
|
||||
rev: v0.14.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
@@ -39,14 +39,14 @@ repos:
|
||||
- id: prettier
|
||||
additional_dependencies:
|
||||
- prettier@3.6.2
|
||||
- prettier-plugin-sort-json@4.1.1
|
||||
- prettier-plugin-sort-json@4.2.0
|
||||
- repo: https://github.com/cdce8p/python-typing-update
|
||||
rev: v0.6.0
|
||||
hooks:
|
||||
# Run `python-typing-update` hook manually from time to time
|
||||
# to update python typing syntax.
|
||||
# Will require manual work, before submitting changes!
|
||||
# pre-commit run --hook-stage manual python-typing-update --all-files
|
||||
# prek run --hook-stage manual python-typing-update --all-files
|
||||
- id: python-typing-update
|
||||
stages: [manual]
|
||||
args:
|
||||
|
||||
@@ -407,6 +407,7 @@ homeassistant.components.person.*
|
||||
homeassistant.components.pi_hole.*
|
||||
homeassistant.components.ping.*
|
||||
homeassistant.components.plugwise.*
|
||||
homeassistant.components.pooldose.*
|
||||
homeassistant.components.portainer.*
|
||||
homeassistant.components.powerfox.*
|
||||
homeassistant.components.powerwall.*
|
||||
@@ -454,6 +455,7 @@ homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
homeassistant.components.samsungtv.*
|
||||
homeassistant.components.saunum.*
|
||||
homeassistant.components.scene.*
|
||||
homeassistant.components.schedule.*
|
||||
homeassistant.components.schlage.*
|
||||
@@ -567,6 +569,7 @@ homeassistant.components.wake_word.*
|
||||
homeassistant.components.wallbox.*
|
||||
homeassistant.components.waqi.*
|
||||
homeassistant.components.water_heater.*
|
||||
homeassistant.components.watts.*
|
||||
homeassistant.components.watttime.*
|
||||
homeassistant.components.weather.*
|
||||
homeassistant.components.webhook.*
|
||||
|
||||
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 not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
},
|
||||
|
||||
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"label": "Ruff",
|
||||
"type": "shell",
|
||||
"command": "pre-commit run ruff-check --all-files",
|
||||
"command": "prek run ruff-check --all-files",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
@@ -57,9 +57,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Pre-commit",
|
||||
"label": "Prek",
|
||||
"type": "shell",
|
||||
"command": "pre-commit run --show-diff-on-failure",
|
||||
"command": "prek run --show-diff-on-failure",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
@@ -120,7 +120,7 @@
|
||||
{
|
||||
"label": "Generate Requirements",
|
||||
"type": "shell",
|
||||
"command": "./script/gen_requirements_all.py",
|
||||
"command": "${command:python.interpreterPath} -m script.gen_requirements_all",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
|
||||
41
CODEOWNERS
generated
41
CODEOWNERS
generated
@@ -220,8 +220,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
|
||||
/homeassistant/components/blebox/ @bbx-a @swistakm
|
||||
/tests/components/blebox/ @bbx-a @swistakm
|
||||
/homeassistant/components/blink/ @fronzbot @mkmer
|
||||
/tests/components/blink/ @fronzbot @mkmer
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
/homeassistant/components/bluemaestro/ @bdraco
|
||||
@@ -308,8 +308,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/config/ @home-assistant/core
|
||||
/homeassistant/components/configurator/ @home-assistant/core
|
||||
/tests/components/configurator/ @home-assistant/core
|
||||
/homeassistant/components/control4/ @lawtancool
|
||||
/tests/components/control4/ @lawtancool
|
||||
/homeassistant/components/control4/ @lawtancool @davidrecordon
|
||||
/tests/components/control4/ @lawtancool @davidrecordon
|
||||
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
/homeassistant/components/cookidoo/ @miaucl
|
||||
@@ -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
|
||||
@@ -664,7 +670,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
/tests/components/here_travel_time/ @eifinger
|
||||
/homeassistant/components/hikvision/ @mezz64
|
||||
/homeassistant/components/hikvision/ @mezz64 @ptarjan
|
||||
/tests/components/hikvision/ @mezz64 @ptarjan
|
||||
/homeassistant/components/hikvisioncam/ @fbradyirl
|
||||
/homeassistant/components/hisense_aehw4a1/ @bannhead
|
||||
/tests/components/hisense_aehw4a1/ @bannhead
|
||||
@@ -793,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
|
||||
@@ -1008,8 +1017,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/mill/ @danielhiversen
|
||||
/homeassistant/components/min_max/ @gjohansson-ST
|
||||
/tests/components/min_max/ @gjohansson-ST
|
||||
/homeassistant/components/minecraft_server/ @elmurato
|
||||
/tests/components/minecraft_server/ @elmurato
|
||||
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
|
||||
/tests/components/minecraft_server/ @elmurato @zachdeibert
|
||||
/homeassistant/components/minio/ @tkislan
|
||||
/tests/components/minio/ @tkislan
|
||||
/homeassistant/components/moat/ @bdraco
|
||||
@@ -1059,6 +1068,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/myuplink/ @pajzo @astrandb
|
||||
/homeassistant/components/nam/ @bieniu
|
||||
/tests/components/nam/ @bieniu
|
||||
/homeassistant/components/namecheapdns/ @tr4nt0r
|
||||
/tests/components/namecheapdns/ @tr4nt0r
|
||||
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek
|
||||
/homeassistant/components/nasweb/ @nasWebio
|
||||
@@ -1163,6 +1174,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
|
||||
@@ -1194,8 +1207,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek
|
||||
/tests/components/overseerr/ @joostlek
|
||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||
/tests/components/overseerr/ @joostlek @AmGarera
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1260,7 +1273,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/prosegur/ @dgomes
|
||||
/homeassistant/components/proximity/ @mib1185
|
||||
/tests/components/proximity/ @mib1185
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/homeassistant/components/ps4/ @ktnrg45
|
||||
/tests/components/ps4/ @ktnrg45
|
||||
/homeassistant/components/pterodactyl/ @elmurato
|
||||
@@ -1692,8 +1706,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
|
||||
@@ -1794,9 +1808,12 @@ 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
|
||||
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
|
||||
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
|
||||
/homeassistant/components/watttime/ @bachya
|
||||
/tests/components/watttime/ @bachya
|
||||
/homeassistant/components/waze_travel_time/ @eifinger
|
||||
|
||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -24,13 +24,13 @@ ENV \
|
||||
COPY rootfs /
|
||||
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.9.6
|
||||
&& pip3 install uv==0.9.17
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -402,6 +402,8 @@ class AuthManager:
|
||||
if user.is_owner:
|
||||
raise ValueError("Unable to deactivate the owner")
|
||||
await self._store.async_deactivate_user(user)
|
||||
for refresh_token in list(user.refresh_tokens.values()):
|
||||
self.async_remove_refresh_token(refresh_token)
|
||||
|
||||
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
|
||||
@@ -624,13 +624,16 @@ async def async_enable_logging(
|
||||
|
||||
if log_file is None:
|
||||
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
if "SUPERVISOR" in os.environ:
|
||||
_LOGGER.info("Running in Supervisor, not logging to file")
|
||||
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
|
||||
# Rename the default log file if it exists, since previous versions created
|
||||
# it even on Supervisor
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
def rename_old_file() -> None:
|
||||
"""Rename old log file in executor."""
|
||||
if os.path.isfile(default_log_path):
|
||||
with contextlib.suppress(OSError):
|
||||
os.rename(default_log_path, f"{default_log_path}.old")
|
||||
|
||||
await hass.async_add_executor_job(rename_old_file)
|
||||
err_log_path = None
|
||||
else:
|
||||
err_log_path = default_log_path
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==4.2.2"]
|
||||
"requirements": ["accuweather==5.0.0"]
|
||||
}
|
||||
|
||||
@@ -9,15 +9,16 @@ from actron_neo_api import (
|
||||
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import (
|
||||
ActronAirConfigEntry,
|
||||
ActronAirRuntimeData,
|
||||
ActronAirSystemCoordinator,
|
||||
)
|
||||
|
||||
PLATFORM = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||
@@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
try:
|
||||
systems = await api.get_ac_systems()
|
||||
await api.update_status()
|
||||
except ActronAirAuthError:
|
||||
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
||||
raise
|
||||
except ActronAirAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
||||
raise
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||
for system in systems:
|
||||
@@ -48,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
system_coordinators=system_coordinators,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -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:
|
||||
@@ -148,7 +118,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self._status.user_aircon_settings.fan_mode
|
||||
fan_mode = self._status.user_aircon_settings.base_fan_mode
|
||||
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
|
||||
|
||||
@property
|
||||
@@ -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))
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Setup config flow for Actron Air integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from actron_neo_api import ActronAirAPI, ActronAirAuthError
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
unique_id = str(user_data["id"])
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Check if this is a reauth flow
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_data["email"],
|
||||
data={CONF_API_TOKEN: self._api.refresh_token_value},
|
||||
@@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
del self.login_task
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication request."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
async def async_step_connection_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -5,16 +5,24 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAPIError,
|
||||
ActronAirAuthError,
|
||||
ActronAirStatus,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER
|
||||
from .const import _LOGGER, DOMAIN
|
||||
|
||||
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
|
||||
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
@@ -29,9 +37,6 @@ class ActronAirRuntimeData:
|
||||
|
||||
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
|
||||
|
||||
AUTH_ERROR_THRESHOLD = 3
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
"""System coordinator for Actron Air integration."""
|
||||
@@ -59,7 +64,20 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
|
||||
async def _async_update_data(self) -> ActronAirStatus:
|
||||
"""Fetch updates and merge incremental changes into the full state."""
|
||||
await self.api.update_status()
|
||||
try:
|
||||
await self.api.update_status()
|
||||
except ActronAirAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
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()
|
||||
return self.status
|
||||
|
||||
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),
|
||||
)
|
||||
30
homeassistant/components/actron_air/icons.json
Normal file
30
homeassistant/components/actron_air/icons.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"away_mode": {
|
||||
"default": "mdi:home-export-outline",
|
||||
"state": {
|
||||
"off": "mdi:home-import-outline"
|
||||
}
|
||||
},
|
||||
"continuous_fan": {
|
||||
"default": "mdi:fan",
|
||||
"state": {
|
||||
"off": "mdi:fan-off"
|
||||
}
|
||||
},
|
||||
"quiet_mode": {
|
||||
"default": "mdi:volume-low",
|
||||
"state": {
|
||||
"off": "mdi:volume-high"
|
||||
}
|
||||
},
|
||||
"turbo_mode": {
|
||||
"default": "mdi:fan-plus",
|
||||
"state": {
|
||||
"off": "mdi:fan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["actron-neo-api==0.1.87"]
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"oauth2_error": "Failed to start OAuth2 flow"
|
||||
"oauth2_error": "Failed to start authentication flow",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
|
||||
},
|
||||
"error": {
|
||||
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
|
||||
"oauth2_error": "Failed to start authentication flow. Please try again later."
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
|
||||
@@ -16,14 +18,42 @@
|
||||
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
|
||||
"title": "Connection error"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
|
||||
"title": "Authentication expired"
|
||||
},
|
||||
"timeout": {
|
||||
"data": {},
|
||||
"description": "The authorization process timed out. Please try again.",
|
||||
"title": "Authorization timeout"
|
||||
"description": "The authentication process timed out. Please try again.",
|
||||
"title": "Authentication timeout"
|
||||
},
|
||||
"user": {
|
||||
"title": "Actron Air OAuth2 Authorization"
|
||||
"title": "Actron Air Authentication"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"away_mode": {
|
||||
"name": "Away mode"
|
||||
},
|
||||
"continuous_fan": {
|
||||
"name": "Continuous fan"
|
||||
},
|
||||
"quiet_mode": {
|
||||
"name": "Quiet mode"
|
||||
},
|
||||
"turbo_mode": {
|
||||
"name": "Turbo mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
homeassistant/components/actron_air/switch.py
Normal file
102
homeassistant/components/actron_air/switch.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Switch platform for Actron Air integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class describing Actron Air switch entities."""
|
||||
|
||||
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
|
||||
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
|
||||
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
|
||||
|
||||
|
||||
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="away_mode",
|
||||
translation_key="away_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="continuous_fan",
|
||||
translation_key="continuous_fan",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="quiet_mode",
|
||||
translation_key="quiet_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
|
||||
),
|
||||
ActronAirSwitchEntityDescription(
|
||||
key="turbo_mode",
|
||||
translation_key="turbo_mode",
|
||||
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
|
||||
set_fn=lambda coordinator,
|
||||
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
|
||||
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ActronAirConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Actron Air switch entities."""
|
||||
system_coordinators = entry.runtime_data.system_coordinators
|
||||
async_add_entities(
|
||||
ActronAirSwitch(coordinator, description)
|
||||
for coordinator in system_coordinators.values()
|
||||
for description in SWITCHES
|
||||
if description.is_supported_fn(coordinator)
|
||||
)
|
||||
|
||||
|
||||
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
|
||||
"""Actron Air switch."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: ActronAirSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
description: ActronAirSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.entity_description.set_fn(self.coordinator, True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.entity_description.set_fn(self.coordinator, False)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information about this AdGuard Home instance."""
|
||||
if self._entry.source == SOURCE_HASSIO:
|
||||
config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
|
||||
config_url = "homeassistant://app/a0d7b954_adguard"
|
||||
elif self.adguard.tls:
|
||||
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
|
||||
else:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Bre77"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/advantage_air",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["advantage_air"],
|
||||
"requirements": ["advantage-air==0.4.4"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Noltari"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aemet",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aemet_opendata"],
|
||||
"requirements": ["AEMET-OpenData==0.6.4"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aftership",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pyaftership==21.11.0"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@ispysoftware"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/agent_dvr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["agent"],
|
||||
"requirements": ["agent-py==0.0.24"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@asymworks"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairnow"],
|
||||
"requirements": ["pyairnow==1.3.1"]
|
||||
|
||||
@@ -7,7 +7,13 @@ 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,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
||||
|
||||
96
homeassistant/components/airobot/button.py
Normal file
96
homeassistant/components/airobot/button.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""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(),
|
||||
),
|
||||
AirobotButtonEntityDescription(
|
||||
key="recalibrate_co2",
|
||||
translation_key="recalibrate_co2",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
|
||||
_attr_min_temp = SETPOINT_TEMP_MIN
|
||||
_attr_max_temp = SETPOINT_TEMP_MAX
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.status.device_id
|
||||
|
||||
@property
|
||||
def _status(self) -> ThermostatStatus:
|
||||
"""Get status from coordinator data."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
|
||||
status = coordinator.data.status
|
||||
settings = coordinator.data.settings
|
||||
|
||||
self._attr_unique_id = status.device_id
|
||||
|
||||
connections = set()
|
||||
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
|
||||
connections.add((CONNECTION_NETWORK_MAC, mac))
|
||||
|
||||
22
homeassistant/components/airobot/icons.json
Normal file
22
homeassistant/components/airobot/icons.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"recalibrate_co2": {
|
||||
"default": "mdi:molecule-co2"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"default": "mdi:valve"
|
||||
},
|
||||
"child_lock": {
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
"quality_scale": "gold",
|
||||
"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()
|
||||
@@ -43,12 +43,12 @@ rules:
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pyairobotrest.models import ThermostatStatus
|
||||
|
||||
@@ -23,6 +24,8 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from . import AirobotConfigEntry
|
||||
from .entity import AirobotEntity
|
||||
@@ -34,10 +37,15 @@ PARALLEL_UPDATES = 0
|
||||
class AirobotSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Airobot sensor entity."""
|
||||
|
||||
value_fn: Callable[[ThermostatStatus], StateType]
|
||||
value_fn: Callable[[ThermostatStatus], StateType | datetime]
|
||||
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(
|
||||
lambda value: utcnow().replace(microsecond=0) - timedelta(seconds=value),
|
||||
timedelta(minutes=2),
|
||||
)
|
||||
|
||||
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
|
||||
AirobotSensorEntityDescription(
|
||||
key="air_temperature",
|
||||
@@ -96,6 +104,14 @@ SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda status: status.errors,
|
||||
),
|
||||
AirobotSensorEntityDescription(
|
||||
key="device_uptime",
|
||||
translation_key="device_uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda status: uptime_to_stable_datetime(status.device_uptime),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -129,6 +145,6 @@ class AirobotSensor(AirobotEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data.status)
|
||||
|
||||
@@ -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,16 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"recalibrate_co2": {
|
||||
"name": "Recalibrate CO2 sensor"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"hysteresis_band": {
|
||||
"name": "Hysteresis band"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"air_temperature": {
|
||||
"name": "Air temperature"
|
||||
@@ -60,12 +85,23 @@
|
||||
"heating_uptime": {
|
||||
"name": "Heating uptime"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"name": "Actuator exercise disabled"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"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 +110,15 @@
|
||||
},
|
||||
"set_temperature_failed": {
|
||||
"message": "Failed to set temperature to {temperature}."
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set value: {error}"
|
||||
},
|
||||
"switch_turn_off_failed": {
|
||||
"message": "Failed to turn off {switch}."
|
||||
},
|
||||
"switch_turn_on_failed": {
|
||||
"message": "Failed to turn on {switch}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
homeassistant/components/airobot/switch.py
Normal file
118
homeassistant/components/airobot/switch.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Switch platform for Airobot thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyairobotrest.exceptions import AirobotError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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 AirobotSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes Airobot switch entity."""
|
||||
|
||||
is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool]
|
||||
turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = (
|
||||
AirobotSwitchEntityDescription(
|
||||
key="child_lock",
|
||||
translation_key="child_lock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.settings.setting_flags.childlock_enabled
|
||||
),
|
||||
turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True),
|
||||
turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False),
|
||||
),
|
||||
AirobotSwitchEntityDescription(
|
||||
key="actuator_exercise_disabled",
|
||||
translation_key="actuator_exercise_disabled",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.settings.setting_flags.actuator_exercise_disabled
|
||||
),
|
||||
turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
|
||||
True
|
||||
),
|
||||
turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
|
||||
False
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot switch entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirobotSwitch(coordinator, description) for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
|
||||
class AirobotSwitch(AirobotEntity, SwitchEntity):
|
||||
"""Representation of an Airobot switch."""
|
||||
|
||||
entity_description: AirobotSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirobotDataUpdateCoordinator,
|
||||
description: AirobotSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.entity_description.turn_on_fn(self.coordinator)
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn_on_failed",
|
||||
translation_placeholders={"switch": self.entity_description.key},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
try:
|
||||
await self.entity_description.turn_off_fn(self.coordinator)
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn_off_failed",
|
||||
translation_placeholders={"switch": self.entity_description.key},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.0"]
|
||||
"requirements": ["airos==0.6.1"]
|
||||
}
|
||||
|
||||
@@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
|
||||
super().__init__(coordinator, unit_id)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
|
||||
|
||||
@property
|
||||
def climate_data(self) -> dict[str, Any]:
|
||||
"""Return the climate data."""
|
||||
return self.device_data.get("climate") or {}
|
||||
|
||||
@property
|
||||
def params(self) -> dict[str, Any]:
|
||||
"""Return the current parameters for the climate entity."""
|
||||
return self.climate_data.get("ParametersData") or {}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and bool(self.climate_data)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "airpatrol"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
||||
|
||||
@@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
|
||||
"""Return the device data."""
|
||||
return self.coordinator.data[self._unit_id]
|
||||
|
||||
@property
|
||||
def climate_data(self) -> dict[str, Any]:
|
||||
"""Return the climate data for this unit."""
|
||||
return self.device_data["climate"]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._unit_id in self.coordinator.data
|
||||
return (
|
||||
super().available
|
||||
and self._unit_id in self.coordinator.data
|
||||
and "climate" in self.device_data
|
||||
and self.climate_data is not None
|
||||
)
|
||||
|
||||
89
homeassistant/components/airpatrol/sensor.py
Normal file
89
homeassistant/components/airpatrol/sensor.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Sensors for AirPatrol integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AirPatrolConfigEntry
|
||||
from .coordinator import AirPatrolDataUpdateCoordinator
|
||||
from .entity import AirPatrolEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirPatrolSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes AirPatrol sensor entity."""
|
||||
|
||||
data_field: str
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
AirPatrolSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
data_field="RoomTemp",
|
||||
),
|
||||
AirPatrolSensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
data_field="RoomHumidity",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirPatrolConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AirPatrol sensors."""
|
||||
coordinator = config_entry.runtime_data
|
||||
units = coordinator.data
|
||||
|
||||
async_add_entities(
|
||||
AirPatrolSensor(coordinator, unit_id, description)
|
||||
for unit_id, unit in units.items()
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if "climate" in unit and unit["climate"] is not None
|
||||
)
|
||||
|
||||
|
||||
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
|
||||
"""AirPatrol sensor entity."""
|
||||
|
||||
entity_description: AirPatrolSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirPatrolDataUpdateCoordinator,
|
||||
unit_id: str,
|
||||
description: AirPatrolSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize AirPatrol sensor."""
|
||||
super().__init__(coordinator, unit_id)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
if value := self.climate_data.get(self.entity_description.data_field):
|
||||
return float(value)
|
||||
return None
|
||||
@@ -17,6 +17,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["airthings"],
|
||||
"requirements": ["airthings-cloud==0.2.0"]
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airthings-ble==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@samsinnamon"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airtouch4",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["airtouch4pyapi"],
|
||||
"requirements": ["airtouch4pyapi==1.0.5"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@danzel"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["airtouch5py"],
|
||||
"requirements": ["airtouch5py==0.3.0"]
|
||||
|
||||
@@ -85,6 +85,22 @@ class AirzoneSystemEntity(AirzoneEntity):
|
||||
value = system[key]
|
||||
return value
|
||||
|
||||
async def _async_update_sys_params(self, params: dict[str, Any]) -> None:
|
||||
"""Send system parameters to API."""
|
||||
_params = {
|
||||
API_SYSTEM_ID: self.system_id,
|
||||
**params,
|
||||
}
|
||||
_LOGGER.debug("update_sys_params=%s", _params)
|
||||
try:
|
||||
await self.coordinator.airzone.set_sys_parameters(_params)
|
||||
except AirzoneError as error:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to set system {self.entity_id}: {error}"
|
||||
) from error
|
||||
|
||||
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
|
||||
|
||||
|
||||
class AirzoneHotWaterEntity(AirzoneEntity):
|
||||
"""Define an Airzone Hot Water entity."""
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.4"]
|
||||
"requirements": ["aioairzone==1.0.5"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from aioairzone.const import (
|
||||
AZD_MODES,
|
||||
AZD_Q_ADAPT,
|
||||
AZD_SLEEP,
|
||||
AZD_SYSTEMS,
|
||||
AZD_ZONES,
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
|
||||
from .entity import AirzoneEntity, AirzoneZoneEntity
|
||||
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -85,14 +86,7 @@ def main_zone_options(
|
||||
return [k for k, v in options.items() if v in modes]
|
||||
|
||||
|
||||
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_MODE,
|
||||
key=AZD_MODE,
|
||||
options_dict=MODE_DICT,
|
||||
options_fn=main_zone_options,
|
||||
translation_key="modes",
|
||||
),
|
||||
SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_Q_ADAPT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -104,6 +98,17 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
)
|
||||
|
||||
|
||||
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_MODE,
|
||||
key=AZD_MODE,
|
||||
options_dict=MODE_DICT,
|
||||
options_fn=main_zone_options,
|
||||
translation_key="modes",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_COLD_ANGLE,
|
||||
@@ -140,16 +145,37 @@ async def async_setup_entry(
|
||||
"""Add Airzone select from a config_entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
added_systems: set[str] = set()
|
||||
added_zones: set[str] = set()
|
||||
|
||||
def _async_entity_listener() -> None:
|
||||
"""Handle additions of select."""
|
||||
|
||||
entities: list[AirzoneBaseSelect] = []
|
||||
|
||||
systems_data = coordinator.data.get(AZD_SYSTEMS, {})
|
||||
received_systems = set(systems_data)
|
||||
new_systems = received_systems - added_systems
|
||||
if new_systems:
|
||||
entities.extend(
|
||||
AirzoneSystemSelect(
|
||||
coordinator,
|
||||
description,
|
||||
entry,
|
||||
system_id,
|
||||
systems_data.get(system_id),
|
||||
)
|
||||
for system_id in new_systems
|
||||
for description in SYSTEM_SELECT_TYPES
|
||||
if description.key in systems_data.get(system_id)
|
||||
)
|
||||
added_systems.update(new_systems)
|
||||
|
||||
zones_data = coordinator.data.get(AZD_ZONES, {})
|
||||
received_zones = set(zones_data)
|
||||
new_zones = received_zones - added_zones
|
||||
if new_zones:
|
||||
entities: list[AirzoneZoneSelect] = [
|
||||
entities.extend(
|
||||
AirzoneZoneSelect(
|
||||
coordinator,
|
||||
description,
|
||||
@@ -161,8 +187,8 @@ async def async_setup_entry(
|
||||
for description in MAIN_ZONE_SELECT_TYPES
|
||||
if description.key in zones_data.get(system_zone_id)
|
||||
and zones_data.get(system_zone_id).get(AZD_MASTER) is True
|
||||
]
|
||||
entities += [
|
||||
)
|
||||
entities.extend(
|
||||
AirzoneZoneSelect(
|
||||
coordinator,
|
||||
description,
|
||||
@@ -173,10 +199,11 @@ async def async_setup_entry(
|
||||
for system_zone_id in new_zones
|
||||
for description in ZONE_SELECT_TYPES
|
||||
if description.key in zones_data.get(system_zone_id)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
)
|
||||
added_zones.update(new_zones)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
|
||||
_async_entity_listener()
|
||||
|
||||
@@ -203,6 +230,38 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
|
||||
self._attr_current_option = self._get_current_option()
|
||||
|
||||
|
||||
class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect):
|
||||
"""Define an Airzone System select."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirzoneUpdateCoordinator,
|
||||
description: AirzoneSelectDescription,
|
||||
entry: ConfigEntry,
|
||||
system_id: str,
|
||||
system_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator, entry, system_data)
|
||||
|
||||
self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_options = self.entity_description.options_fn(
|
||||
system_data, description.options_dict
|
||||
)
|
||||
|
||||
self.values_dict = {v: k for k, v in description.options_dict.items()}
|
||||
|
||||
self._async_update_attrs()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
param = self.entity_description.api_param
|
||||
value = self.entity_description.options_dict[option]
|
||||
await self._async_update_sys_params({param: value})
|
||||
|
||||
|
||||
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
|
||||
"""Define an Airzone Zone select."""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Noltari"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.7.2"]
|
||||
|
||||
93
homeassistant/components/alarm_control_panel/condition.py
Normal file
93
homeassistant/components/alarm_control_panel/condition.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Provides conditions for alarm control panels."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityStateConditionBase,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||
|
||||
|
||||
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Test if an entity supports the specified features."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
return False
|
||||
|
||||
|
||||
class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
|
||||
"""State condition."""
|
||||
|
||||
_required_features: int
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain with the required features."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if supports_feature(self._hass, entity_id, self._required_features)
|
||||
}
|
||||
|
||||
|
||||
def make_entity_state_required_features_condition(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityStateRequiredFeaturesCondition]:
|
||||
"""Create an entity state condition class with required feature filtering."""
|
||||
|
||||
class CustomCondition(EntityStateRequiredFeaturesCondition):
|
||||
"""Condition for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
return CustomCondition
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_armed": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
{
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
},
|
||||
),
|
||||
"is_armed_away": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY,
|
||||
),
|
||||
"is_armed_home": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelEntityFeature.ARM_HOME,
|
||||
),
|
||||
"is_armed_night": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT,
|
||||
),
|
||||
"is_armed_vacation": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"is_triggered": make_entity_state_condition(
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the alarm control panel conditions."""
|
||||
return CONDITIONS
|
||||
52
homeassistant/components/alarm_control_panel/conditions.yaml
Normal file
52
homeassistant/components/alarm_control_panel/conditions.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_armed: *condition_common
|
||||
|
||||
is_armed_away:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
is_armed_home:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
is_armed_night:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
|
||||
is_armed_vacation:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
|
||||
is_disarmed: *condition_common
|
||||
|
||||
is_triggered: *condition_common
|
||||
@@ -1,4 +1,27 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_armed": {
|
||||
"condition": "mdi:shield"
|
||||
},
|
||||
"is_armed_away": {
|
||||
"condition": "mdi:shield-lock"
|
||||
},
|
||||
"is_armed_home": {
|
||||
"condition": "mdi:shield-home"
|
||||
},
|
||||
"is_armed_night": {
|
||||
"condition": "mdi:shield-moon"
|
||||
},
|
||||
"is_armed_vacation": {
|
||||
"condition": "mdi:shield-airplane"
|
||||
},
|
||||
"is_disarmed": {
|
||||
"condition": "mdi:shield-off"
|
||||
},
|
||||
"is_triggered": {
|
||||
"condition": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:shield",
|
||||
|
||||
@@ -1,8 +1,82 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted alarms.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_armed": {
|
||||
"description": "Tests if one or more alarms are armed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed"
|
||||
},
|
||||
"is_armed_away": {
|
||||
"description": "Tests if one or more alarms are armed in away mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed away"
|
||||
},
|
||||
"is_armed_home": {
|
||||
"description": "Tests if one or more alarms are armed in home mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed home"
|
||||
},
|
||||
"is_armed_night": {
|
||||
"description": "Tests if one or more alarms are armed in night mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed night"
|
||||
},
|
||||
"is_armed_vacation": {
|
||||
"description": "Tests if one or more alarms are armed in vacation mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed vacation"
|
||||
},
|
||||
"is_disarmed": {
|
||||
"description": "Tests if one or more alarms are disarmed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is disarmed"
|
||||
},
|
||||
"is_triggered": {
|
||||
"description": "Tests if one or more alarms are triggered.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is triggered"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"arm_away": "Arm {entity_name} away",
|
||||
@@ -76,6 +150,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -4,24 +4,24 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityStateTriggerBase,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
make_conditional_entity_state_trigger,
|
||||
make_entity_state_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||
|
||||
|
||||
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
"""Test if an entity supports the specified features."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
return False
|
||||
|
||||
|
||||
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
||||
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_required_features: int
|
||||
@@ -38,21 +38,21 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
|
||||
|
||||
def make_entity_state_trigger_required_features(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityStateTriggerBase]:
|
||||
"""Create an entity state trigger class."""
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create an entity state trigger class with required feature filtering."""
|
||||
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_to_state = to_state
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"armed": make_conditional_entity_state_trigger(
|
||||
"armed": make_entity_transition_trigger(
|
||||
DOMAIN,
|
||||
from_states={
|
||||
AlarmControlPanelState.ARMING,
|
||||
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
|
||||
"disarmed": make_entity_target_state_trigger(
|
||||
DOMAIN, AlarmControlPanelState.DISARMED
|
||||
),
|
||||
"triggered": make_entity_target_state_trigger(
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@madpilot"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["amberelectric"],
|
||||
"requirements": ["amberelectric==2.0.12"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@engrbm87"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pydroid-ipcam==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,13 +4,28 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import VolumeConverter
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
|
||||
@@ -44,6 +59,107 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data from Anglian Water's API."""
|
||||
try:
|
||||
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
|
||||
await self._insert_statistics()
|
||||
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
|
||||
raise UpdateFailed from err
|
||||
|
||||
async def _insert_statistics(self) -> None:
|
||||
"""Insert statistics for water meters into Home Assistant."""
|
||||
for meter in self.api.meters.values():
|
||||
id_prefix = (
|
||||
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
|
||||
)
|
||||
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
|
||||
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
|
||||
name_prefix = (
|
||||
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
|
||||
f"{meter.serial_number}"
|
||||
)
|
||||
usage_metadata = StatisticMetaData(
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"{name_prefix} Usage",
|
||||
source=DOMAIN,
|
||||
statistic_id=usage_statistic_id,
|
||||
unit_class=VolumeConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
)
|
||||
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
|
||||
)
|
||||
if not last_stat:
|
||||
_LOGGER.debug("Updating statistics for the first time")
|
||||
usage_sum = 0.0
|
||||
last_stats_time = None
|
||||
else:
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
continue
|
||||
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
|
||||
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
|
||||
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
|
||||
if not parsed_read_at:
|
||||
_LOGGER.debug(
|
||||
"Could not parse read_at time %s, skipping update",
|
||||
meter.readings[0]["read_at"],
|
||||
)
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
_LOGGER.debug("Getting statistics at %s", start)
|
||||
for end in (start + timedelta(seconds=1), None):
|
||||
stats = await get_instance(self.hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
self.hass,
|
||||
start,
|
||||
end,
|
||||
{
|
||||
usage_statistic_id,
|
||||
},
|
||||
"hour",
|
||||
None,
|
||||
{"sum"},
|
||||
)
|
||||
if stats:
|
||||
break
|
||||
if end:
|
||||
_LOGGER.debug(
|
||||
"Not found, trying to find oldest statistic after %s",
|
||||
start,
|
||||
)
|
||||
assert stats
|
||||
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
|
||||
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
|
||||
last_stats_time = stats[usage_statistic_id][0]["start"]
|
||||
|
||||
usage_statistics = []
|
||||
|
||||
for read in meter.readings:
|
||||
parsed_read_at = dt_util.parse_datetime(read["read_at"])
|
||||
if not parsed_read_at:
|
||||
_LOGGER.debug(
|
||||
"Could not parse read_at time %s, skipping reading",
|
||||
read["read_at"],
|
||||
)
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||
continue
|
||||
usage_state = max(0, read["consumption"] / 1000)
|
||||
usage_sum = max(0, read["read"])
|
||||
usage_statistics.append(
|
||||
StatisticData(
|
||||
start=start,
|
||||
state=usage_state,
|
||||
sum=usage_sum,
|
||||
)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
|
||||
)
|
||||
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"domain": "anglian_water",
|
||||
"name": "Anglian Water",
|
||||
"after_dependencies": ["recorder"],
|
||||
"codeowners": ["@pantherale0"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==3.0.0"]
|
||||
"requirements": ["pyanglianwater==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Lash-L"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anova",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["anova_wifi"],
|
||||
"requirements": ["anova-wifi==0.17.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@hyralex"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthemav",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["anthemav"],
|
||||
"requirements": ["anthemav==1.4.1"]
|
||||
|
||||
@@ -69,6 +69,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -193,7 +194,7 @@ def _convert_content(
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
external_tool = False
|
||||
if not messages or messages[-1]["role"] != (
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@bdr99"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.15"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@yuxincs"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["apcaccess"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import dateutil
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -179,6 +181,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
LAST_S_TEST: SensorEntityDescription(
|
||||
key=LAST_S_TEST,
|
||||
translation_key="last_self_test",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
"lastxfer": SensorEntityDescription(
|
||||
key="lastxfer",
|
||||
@@ -232,6 +235,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"masterupd": SensorEntityDescription(
|
||||
key="masterupd",
|
||||
translation_key="master_update",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"maxlinev": SensorEntityDescription(
|
||||
@@ -365,6 +369,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"starttime": SensorEntityDescription(
|
||||
key="starttime",
|
||||
translation_key="startup_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"statflag": SensorEntityDescription(
|
||||
@@ -416,16 +421,19 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"xoffbat": SensorEntityDescription(
|
||||
key="xoffbat",
|
||||
translation_key="transfer_from_battery",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xoffbatt": SensorEntityDescription(
|
||||
key="xoffbatt",
|
||||
translation_key="transfer_from_battery",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"xonbatt": SensorEntityDescription(
|
||||
key="xonbatt",
|
||||
translation_key="transfer_to_battery",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
@@ -529,7 +537,13 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
||||
data = self.coordinator.data[key]
|
||||
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
return
|
||||
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
if not self.native_unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = inferred_unit
|
||||
|
||||
|
||||
@@ -5,9 +5,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from random import randrange
|
||||
import sys
|
||||
from typing import Any, cast
|
||||
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -24,11 +29,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -42,18 +43,6 @@ from .const import (
|
||||
SIGNAL_DISCONNECTED,
|
||||
)
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
else:
|
||||
|
||||
class DeviceListener:
|
||||
"""Dummy class."""
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME_TV = "Apple TV"
|
||||
@@ -64,30 +53,25 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
else:
|
||||
AUTH_EXCEPTIONS = ()
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = ()
|
||||
DEVICE_EXCEPTIONS = ()
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
|
||||
|
||||
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
@@ -95,10 +79,6 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
|
||||
"""Set up a config entry for Apple TV."""
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
manager = AppleTVManager(hass, entry)
|
||||
|
||||
if manager.is_on:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
"requirements": ["pyatv==0.17.0"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
||||
@@ -239,6 +239,15 @@ class AppleTvMediaPlayer(
|
||||
"""
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def volume_device_update(
|
||||
self, output_device: OutputDevice, old_level: float, new_level: float
|
||||
) -> None:
|
||||
"""Output device volume was updated.
|
||||
|
||||
This is a callback function from pyatv.interface.AudioListener.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def outputdevices_update(
|
||||
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@elupus"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.8.2"],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@ikalnyi"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/arve",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["asyncarve==0.1.1"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user